How to make Akka serialization bulletproof

If you encounter problems related to serialization in Akka, this article’s for you, in which we show VirtusLab’s Akka Serialization Helper.

Akka serialization helper

Every message that leaves a JVM boundary in Akka needs to be serialized first. However, the existing solutions for serialization in Scala leave a lot of room for runtime failures. These failures are the result of programmer’s oversights. Such oversights aren’t reported as possible runtime errors in compile time. This is why VirtusLab is glad to introduce Akka Serialization Helper. It’s a toolkit with a Circe-based, runtime-safe serializer and a set of Scala compiler plugins to counteract the common caveats in Akka serialization.

Although Akka is a great tool to work with, it also has some downsides when it comes to serialization. Several situations might cause unexpected errors when working with standard Akka serialization, such as:

  1. Missing serialization binding
  2. Incompatibility of persistent data
  3. Jackson Akka serializer drawbacks
  4. Missing codec registration

The similarity between these situations is bugs in the application code caused by programmers’ mistakes. The Scala compiler, on its own, passes over these bugs, which can easily break your app in runtime. Fortunately, there is a way to catch them during compilation with Akka Serialization Helper, or ASH.

How to enable Akka Serialization Helper in your project

Before we can use Akka Serialization Helper, we need to add the following line to the project/plugins.sbt file:

addSbtPlugin("org.virtuslab.ash" % "sbt-akka-serialization-helper" % Version)

You can find the newest Version in ASH GitHub Releases.

Once this is done, let’s enable the sbt plugin in the target project:

lazy val app = (project in file("app"))
  .enablePlugins(AkkaSerializationHelperPlugin)

Akka Serialization Helper usage

Akka-specific objects that get serialized include: Messages, Events and States. We might encounter runtime errors during serialization. Akka Serialization Helper assists with spotting these errors and avoiding them in runtime. Let’s see common runtime errors related to Akka serialization.

1. Missing serialization binding

A proper serialization in Akka follows a certain concept: 

First, you need to define a Scala trait, to serialize a message, persistent state or event:

package org
trait MySer

Second, bind a serializer to this trait in a configuration file:

akka.actor {
  serializers {
    jackson-json = "akka.serialization.jackson.JacksonJsonSerializer"
  }
  serialization-bindings {
    "org.MySer" = jackson-json
  }
}

A serialization error in runtime occurs if a class is not extended with the base trait bound to the serializer:

trait MySer
case class MyMessage() // extends MySer

Now let’s wire up Akka Serialization Helper. The serializability-checker-plugin, part of ASH, detects messages, events and persistent states. It checks whether they

extend the given base trait and reports an error when they don’t. 

This ensures that Akka uses the specified serializer. The serializer protects a running application against an unintended fallback to Java serialization or outright serialization failure.
This plugin requires you to add an @SerializabilityTrait annotation to the base trait:

@SerializabilityTrait
trait MySerializable
It allows catching errors like these:
import akka.actor.typed.Behavior

object BehaviorTest {
  sealed trait Command //extends MySerializable
  def method(msg: Command): Behavior[Command] = ???
}

If we enable serializability-checker-plugin and add an @SerializabilityTrait annotation to the base trait, the compiler will be able to catch errors like this during compilation:

test0.scala:7: error: org.random.project.BehaviorTest.Command is used as Akka message
but does not extend a trait annotated with org.virtuslab.ash.annotation.SerializabilityTrait.
Passing an object of a class that does NOT extend a trait annotated with SerializabilityTrait as a message may cause Akka to
fall back to Java serialization during runtime.


  def method(msg: Command): Behavior[Command] = ???
                            ^
test0.scala:6: error: Make sure this type is itself annotated, or extends a type annotated
with  @org.virtuslab.ash.annotation.SerializabilityTrait.
  sealed trait Command extends MySerializable
               ^

2. Incompatibility of persistent data

A common problem with persistence in Akka is the incompatibility of already persisted data with schemas defined in a new version of an application.

The solution for this incompatibility is the dump-persistence-schema-plugin – another part of Akka Serializer Helper toolbox. It is a mix of a compiler plugin and a sbt task. The plugin can be used for dumping schema of akka-persistence to a file and detecting accidental changes of events (journal) and states (snapshots) with a simple diff.

If you want to dump a persistence schema for each sbt module where Akka Serialization Helper Plugin is enabled, run:

sbt ashDumpPersistenceSchema

It saves the created dump into a yaml file. The default is target/<sbt-module-name>-dump-persistence-schema-<version>.yaml

Example dump

- name: org.random.project.Data
  typeSymbol: trait
- name: org.random.project.Data.ClassTest
  typeSymbol: class
  fields:
  - name: a
    typeName: java.lang.String
  - name: b
    typeName: scala.Int
  - name: c
    typeName: scala.Double
  parents:
  - org.random.project.Data
- name: org.random.project.Data.ClassWithAdditionData
  typeSymbol: class
  fields:
  - name: ad
    typeName: org.random.project.Data.AdditionalData
  parents:
  - org.random.project.Data

Then, a simple diff command can be used to check the difference between the version of a schema from develop/main branch and the version from the current commit. Such comparison lets us catch possible incompatibilities of persisted data.

3. Jackson Akka Serializer drawbacks

One more pitfall is to use the Jackson Serializer for Akka. Let’s dive into some examples that might occur when combining Jackson with Scala code:

Jackson Serializer – Scala example 1

Let’s take a look at a dangerous code for Jackson:

case class Message(animal: Animal) extends MySer

sealed trait Animal

final case class Lion(name: String) extends Animal
final case class Tiger(name: String) extends Animal

This code seems to be alright, but unfortunately it will not work with the Jackson serialization. At runtime, there will be an exception with a message such as: “Cannot construct instance of Animal(…)”. The reason behind it is that abstract types need to be mapped to concrete types explicitly in code. If you want to make this code work, you need to add a lot of Jackson annotations:

case class Message(animal: Animal) extends MultiDocPrintService

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
  Array(
    new JsonSubTypes.Type(value = classOf[Lion], name = "lion"),
    new JsonSubTypes.Type(value = classOf[Tiger], name = "tiger")))
sealed trait Animal

final case class Lion(name: String) extends Animal
final case class Tiger(name: String) extends Animal

Jackson Serializer – Scala example 2

If a Scala object is defined:

case object Tick

Then there will be no exceptions during serialization. During deserialization though, Jackson will create another instance of object Tick’s underlying class instead of restoring the object Tick’s underlying singleton. This means, the deserialization will end up in an unexpected but unreported behavior

actorRef ! Tick

// Inside the actor:
def receive = {
  case Tick => // this won't get matched !!
} // message will be unhandled !!

Akka Serialization Helper as alternative

Akka Serialization Helper provides a more Scala-friendly serializer that uses Circe.

Use our Circe-based Akka serializer, to get rid of problems as shown in the examples above. Circe Akka Serializer comes with Akka Serialization Helper toolbox. It uses Circe codecs that are derived using Shapeless and are generated during compilation. This ensures that the serializer doesn’t crash at runtime, as reflection-based serializers might do.

The Circe Akka Serializer is easy to use, just add the following lines to project dependencies:

import org.virtuslab.ash.AkkaSerializationHelperPlugin

lazy val app = (project in file("app"))
  // ...
  .settings(libraryDependencies += AkkaSerializationHelperPlugin.circeAkkaSerializer)

Then create a custom serializer by extending Circe Akka Serializer base class:

import org.virtuslab.ash.circe.CirceAkkaSerializer

class ExampleSerializer(actorSystem: ExtendedActorSystem)
    extends CirceAkkaSerializer[MySerializable](actorSystem) {

  override def identifier: Int = 41

  override lazy val codecs = Seq(Register[CommandOne], Register[CommandTwo])

  override lazy val manifestMigrations = Nil

  override lazy val packagePrefix = "org.project"
}

MySerializable in the example above is the name of the base trait

Last but not least, remember to add your custom serializer to the Akka configuration. Shortly, add two following configurations to the .conf file – akka.actor.serializers and akka.actor.serialization-bindings:

akka {
  actor {
    serializers {
      circe-json = "org.example.ExampleSerializer"
    }
    serialization-bindings {
      "org.example.MySerializable" = circe-json
    }
  }
}

From now on you’ll have a safe Circe-based serializer to cope with the serialization of your objects.

4. Missing Codec registration

Last situation in which unexpected runtime exceptions might occur during serialization is the missing registration of a codec.

import org.virtuslab.ash.circe.CirceAkkaSerializer
import org.virtuslab.ash.circe.Register

class ExampleSerializer(actorSystem: ExtendedActorSystem)
  extends CirceAkkaSerializer[MySerializable](actorSystem) {
  // ...
  override lazy val codecs = Seq(Register[CommandOne]) // WHOOPS someone forgot to register CommandTwo...
}
java.lang.RuntimeException: Serialization of [CommandTwo] failed. Call Register[A]
for this class or its supertype and append the result to `def codecs`.

Akka Serialization Helper can help by using the @Serializer annotation.

Akka Serialization Helper toolbox includes the codec-registration-checker-plugin. It gathers all direct descendants of the class marked with @SerializabilityTrait during compilation and checks the body of classes annotated with @Serializer for any reference of these direct descendants
Let’s take a look at how we can apply the plugin to check a class extending CirceAkkaSerializer:

import org.virtuslab.ash.circe.CirceAkkaSerializer
import org.virtuslab.ash.circe.Register

@Serializer(
  classOf[MySerializable],
  typeRegexPattern = Register.REGISTRATION_REGEX)
class ExampleSerializer(actorSystem: ExtendedActorSystem)
  extends CirceAkkaSerializer[MySerializable](actorSystem) {
    // ...
    override lazy val codecs = Seq(Register[CommandOne]) // WHOOPS someone forgot to register CommandTwo...
    // ... but Codec Registration Checker will throw a compilation error here:
    // `No codec for `CommandOne` is registered in a class annotated with @org.virtuslab.ash.annotation.Serializer`
}

The Plugin catches all missing codec registrations in compile-time.

Summary

Akka Serialization Helper is the right tool to make Akka serialization bulletproof by catching possible runtime exceptions during compilation. It is free to use and easy to configure. Moreover, it is already used in commercial projects, although it has not reached full maturity yet. If you want to know more, check out ASH readme on GitHub.

Written by

Łukasz Kontowski
Łukasz Kontowski Junior Scala Developer Sep 1, 2022