Skip to content

Serialization and evolution

Everything ankka stores or sends — an entity's events and state, a view's rows, a handler's argument and reply, a message on a topic — is encoded by a serializer, and stored with the serializer's manifest: a name for the type that you choose. Records and sum types are encoded as JSON; primitives are encoded as plain text. The encoding is the same in Scala and Python, so a journal written by a service in one language is read by the same service in the other.

What is in a journal stays there. A change to a stored type is therefore a change to data that already exists, and the rules for making one safely are the most important part of this page.

Declaring a serializer

In Scala, Codecs.serializer[A](manifest) derives a JSON serializer for a type at compile time and names its manifest. A type that cannot be encoded fails the build, not the first replay:

import com.thinkmorestupidless.ankka.core.{Codecs, Serializer}

given Serializer[LineItem] = Codecs.serializer[LineItem]("line-item")

In Python, json_codec(type, manifest) does the same for a dataclass, a union of dataclasses, an Enum, or any combination of them with lists, dicts and scalars:

from ankka import json_codec

state_codec = json_codec(ShoppingCart, "shopping-cart")
event_codec = json_codec(ShoppingCartEvent, "shopping-cart-event")

An entity names a serializer for its state and one for its events. A view names one for its rows. A consumer that publishes names one for its output. Handler arguments and replies need one too: in Scala, primitives, Done, Unit, Option and FiniteDuration are provided by import com.thinkmorestupidless.ankka.core.Serializers.given, and any other type needs a given of its own. The Python SDK chooses the codec for a handler's argument and reply from its type annotations.

Manifests are names you keep

A manifest is written beside every stored value, and replay finds the decoder by it. That is why ankka asks you to name it rather than deriving it from the class name: with an explicit manifest, the class can be renamed or moved freely, because nothing stored refers to the class. A manifest derived from a class name would change the moment the class was renamed, and every existing event would stop decoding.

Treat a manifest like a wire name: choose it once, and never change it for a type that has been stored.

What the JSON looks like

The encoding is defined once, in the protocol's encoding document, and both SDKs produce and accept exactly it.

Value Encoding Example
a record: a case class or a dataclass a JSON object with every field, including empty collections and absent options {"productId":"p1","name":"Pen","quantity":2}
a case of a sum type: a Scala enum case or a member of a union of dataclasses the case's object with "type" set to the case's simple name {"type":"ItemAdded","item":{"productId":"p1","name":"Pen","quantity":2}}
a case with no fields an object holding only the discriminator {"type":"CheckedOut"}
an absent optional field null {"note":null}
a sequence an array; empty is [] {"items":[]}
a map with string keys an object {"quantities":{"p1":5}}
an instant an ISO-8601 string in UTC "2026-09-23T10:00:00Z"
a duration inside a record an ISO-8601 duration "PT1.5S"

A handler whose argument or reply is a primitive, rather than containing one, is encoded as plain UTF-8 text with no JSON quoting:

Type Manifest Example
String / str string hello
Int / int int 42
Long long 42
Double / float double 1.5
Boolean / bool boolean true
FiniteDuration duration-millis 1500
Done done zero bytes

This matters at an HTTP endpoint: a route whose reply is a String answers text/plain, not a JSON string, and a String body is posted raw.

Field names are the contract

The JSON field names are the names of the fields as declared, in whichever language wrote them. A Scala case class field productId and a Python dataclass field productId produce the same JSON; a Python field named product_id would not, and a cart written by one could not be read by the other. When a service may be read or written from both languages, or may move from one to the other, declare the fields with the same names in both, as the shopping cart samples do.

The sum-type discriminator is the case's simple name: ItemAdded, not a qualified name. Renaming an event case is therefore a stored-data change, even though renaming its enclosing type is not.

Changing a stored type safely

Events, state, snapshots and view rows are read back long after they were written, by code that did not write them. Reading is lenient where writing is strict, which gives a set of changes that are always safe:

Change Safe Why
Add an optional field: Option[A] in Scala, Optional[A] in Python yes An old value lacks the field, which reads as absent.
Add a field with a default value yes An old value lacks the field, which reads as the default.
Add a new case to a sum type yes Old values never use it. Deploy the readers before anything writes it.
Remove a field no, while old data may be read Reading ignores the unknown field, but code that needed the value has lost it.
Rename a field no An old value has the old name, and the new required field is missing.
Change a field's type no The old value no longer decodes.
Rename or remove a sum-type case no An old value names a case the reader refuses.
Change a manifest no Replay cannot find the decoder.

A required field missing from a stored value fails decoding, and so does a "type" the reader does not know. For an event sourced entity that means the entity cannot be recovered, so a breaking change to an event type breaks every entity that ever persisted it.

When a change that is not in the safe column is really needed, add rather than alter. Introduce a new event case, or a new field with a default, have the event handler understand both old and new, and keep the old case forever. Events describe what happened, and what happened does not change.

Absent, null and defaults

A JSON null on an optional field reads as absent, and an absent field with a default reads as its default. Together those have a consequence worth knowing: in Scala, an Option field whose default is not None cannot express "none". Given port: Option[Int] = Some(9000), both {} and {"port": null} decode as Some(9000). When a type needs to say "none" positively, give it a field that says so, such as http: Boolean = true, rather than relying on a null to override a default.

Writing your own codec

Either SDK accepts a serializer of your own: in Scala any Serializer[A] with a manifest, toBytes and fromBytes; in Python any object satisfying the Codec protocol in ankka.codec, with a manifest, a content_type, encode and decode. A custom codec is a contract you own. The guarantee that another language, or the other SDK, reads the data applies only to the default encoding.