Serialization

Jackson serialization

You need to make the messages, events, or the state of Akka components serializable with Jackson. The same is true for inputs and outputs of HTTP Endpoints. There are two ways to do this.

  1. If you are using Java record then no annotation is needed. It just works. It’s as simple as using record instead of class. Akka leverages Jackson under the hood and makes these records serializable for you.

  2. If you are using Java class then you need to annotate them with the proper Jackson annotation.

Akka uses a predefined Jackson configuration, for serialization. Use the JsonSupport utility to update the ObjectMapper with your custom requirements. To minimize the number of Jackson annotations, Java classes are compiled with the -parameters flag.

@Setup
public class Bootstrap implements ServiceSetup {


  @Override
  public void onStartup() {
    JsonSupport.getObjectMapper().configure(FAIL_ON_NULL_CREATOR_PROPERTIES, true); (1)
  }
}
1 Sets custom ObjectMapper configuration.

Type name

It’s highly recommended to add a @TypeName annotation to all persistent classes: entity states, events, Workflow step inputs/results. Information about the type, persisted together with the JSON payload, is used to deserialize the payload and to route it to an appropriate Subscription or View handler. By default, a FQCN is used, which requires extra attention in case of renaming or repacking. Therefore, we recommend using a logical type name to simplify refactoring tasks. Migration from the old name is also possible, see renaming class.

Schema evolution

When using Event Sourcing, but also for rolling updates, schema evolution becomes an important aspect of your application development. A production-ready solution should be able to update any persisted models. The requirements as well as our own understanding of the business domain may (and will) change over time.

Removing a field

Removing a field can be done without any migration code. The Jackson serializer will ignore properties that do not exist in the class.

Adding an optional field

Adding an optional field can be done without any migration code. The default value will be Optional.empty or null if the field is not wrapped with an Optional type.

Old class:

record NameChanged(String newName) implements CustomerEvent {}

New class with optional oldName and nullable reason.

record NameChanged(String newName, Optional<String> oldName, String reason)
  implements CustomerEvent {}

Adding a mandatory field

Let’s say we want to have a mandatory reason field. Always set to a some (non-null) value. One solution could be to override the constructor, but with more complex and nested types, this might quickly become a hard to follow solution.

Another approach is to use the JsonMigration extension that allows you to create a complex migration logic based on the payload version number.

public class NameChangedMigration extends JsonMigration { (1)

  @Override
  public int currentVersion() {
    return 1; (2)
  }

  @Override
  public JsonNode transform(int fromVersion, JsonNode json) {
    if (fromVersion < 1) { (3)
      ObjectNode objectNode = ((ObjectNode) json);
      objectNode.set("reason", TextNode.valueOf("default reason")); (4)
    }
    return json; (5)
  }
}
1 Migration must extend JsonMigration class.
2 Sets current version number. The first version, when no migration was used, is always 0. Increase this version number whenever you perform a change that is not backwards compatible without migration code.
3 Implements the transformation of the old JSON structure to the new JSON structure.
4 The JsonNode is mutable, so you can add and remove fields, or change values. Note that you have to cast to specific sub-classes such as ObjectNode and ArrayNode to get access to mutators.
5 Returns updated JSON matching the new class structure.

The migration class must be linked to the updated model with the @Migration annotation.

@Migration(NameChangedMigration.class) (1)
record NameChanged(String newName, Optional<String> oldName, String reason)
  implements CustomerEvent {}
1 Links the migration implementation with the updated event.

Renaming a field

Renaming a field is a very similar migration.

Old class:

record AddressChanged(Address address) implements CustomerEvent {}

New class:

@Migration(AddressChangedMigration.class)
record AddressChanged(Address newAddress) implements CustomerEvent {}

The migration implementation:

public class AddressChangedMigration extends JsonMigration {

  @Override
  public int currentVersion() {
    return 1;
  }

  @Override
  public JsonNode transform(int fromVersion, JsonNode json) {
    if (fromVersion < 1) {
      ObjectNode objectNode = ((ObjectNode) json);
      JsonNode oldField = json.get("address"); (1)
      objectNode.set("newAddress", oldField); (2)
      objectNode.remove("address"); (3)
    }
    return json;
  }
}
1 Finds the old address field.
2 Updates the JSON tree with the newAddress field name.
3 Removes the old field.

Changing the structure

Old class:

record CustomerCreatedOld(String email, String name, String street, String city)
  implements CustomerEvent {}

New class with the Address type:

@Migration(CustomerCreatedMigration.class)
record CustomerCreated(String email, String name, Address address)
  implements CustomerEvent {}

The migration implementation:

public class CustomerCreatedMigration extends JsonMigration {

  @Override
  public int currentVersion() {
    return 1;
  }

  @Override
  public JsonNode transform(int fromVersion, JsonNode json) {
    if (fromVersion == 0) {
      ObjectNode root = ((ObjectNode) json);
      ObjectNode address = root.with("address"); (1)
      address.set("street", root.get("street"));
      address.set("city", root.get("city"));
      root.remove("city");
      root.remove("street");
    }
    return json;
  }
}
1 Creates a new nested JSON object, with the data from the old schema.

Renaming class

Renaming the class doesn’t require any additional work when @TypeName annotation is used. For other cases, the JsonMigration implementation can specify all old class names.

public class AddressChangedMigration extends JsonMigration {

  @Override
  public int currentVersion() {
    return 1;
  }


  @Override
  public List<String> supportedClassNames() {
    return List.of("customer.domain.CustomerEvent$OldAddressChanged"); (1)
  }

}
1 Specifies the old event name.

Testing

It’s highly recommended to cover all schema changes with unit tests. In most cases it won’t be possible to reuse the same class for serialization and deserialization, since the model is different from version 0 to version N. One solution could be to create a byte array snapshot of each version and save it to a file. To generate the snapshot use SerializationTestkit utility.

byte[] serialized = SerializationTestkit.serialize(
  new CustomerCreatedOld("[email protected]", "bob", "Wall Street", "New York")
);
var tmpDir = Files.createTempFile("customer-created-old", ".json");
// save serialized to a file and remove `CustomerCreatedOld`
Files.write(tmpDir.toAbsolutePath(), serialized); (1)
1 Save old class payload to a file.

Test example:

@Test
public void shouldDeserializeCustomerCreated_V0() throws IOException {
  // load serialized bytes and deserialize with the new schema
  var serialized = getClass()
    .getResourceAsStream("/customer-created-old.json")
    .readAllBytes(); (1)
  CustomerCreated deserialized = SerializationTestkit.deserialize(
    CustomerCreated.class,
    serialized
  ); (2)

  assertEquals("Wall Street", deserialized.address().street());
  assertEquals("New York", deserialized.address().city());
}
1 Loading old payload from a file.
2 Deserializing with the latest schema.

Protobuf Serialization

As an alternative to JSON with Jackson, it is possible to use Protobuf messages. In most cases the messages are serialized to binary form for storage.

Protobuf serialization is an advanced feature and not recommended as the default choice.

It is possible to use Protobuf messages for:

Event Sourced Entity

Entity state and events, commands and their replies.

Since there is no way to mark a sealed interface for the distinct event types applyEvent must accept com.google.protobuf.GeneratedMessageV3 and do its own type matching for the expected message types.

The concrete Event Sourced Entity class must also have the annotation akka.javasdk.annotations.ProtoEventTypes listing all event types that the entity will use.

Key Value Entity

Entity state, commands and their replies.

Workflow

Workflow state and step input, commands and their replies.

Consumer

Consumer input and output.

The consumer handler method must accept com.google.protobuf.GeneratedMessageV3 and do its own type matching for the expected message types.

If it is consuming events from an Event Sourced Entity or a Key Value entity in the same service, the concrete message types are inferred. For all other cases the consumer class must be annotated with akka.javasdk.annotations.ProtoEventTypes listing all event types that the consumer will accept.

Unlisted message types arriving will fail the stream and stall the consumer until a service version supporting the event type is deployed.

View

View updater input, view state, query input and result type.

For views the state, input and output are serialized to JSON and not a binary representation.

If the updater is consuming events from an Event Sourced Entity or a Key Value entity in the same service, the concrete message types are inferred. For all other cases the updater class must be annotated with akka.javasdk.annotations.ProtoEventTypes listing all event types that the updater will accept.

Unlisted message types arriving will fail the stream and stall view updates until a service version supporting the event type is deployed.

Agent

Commands and their replies.

For agents the messages are serialized to JSON and not a binary representation.