Upcasting and downcasting
Your code changes while old events and documents remain stored. Upcasting translates that old serialized data into the shape your current code expects. Downcasting provides an older shape to a reader that still needs it.
For example, renaming id to userId in CreateUser should not make yesterday’s commands unreadable. The next two
sections show that change: declare the new revision, then translate the old JSON when it is read.
Fluxzero uses JSON serialization by default. You normally evolve its data with revisions and upcasters; replacing the serializer is a separate customization.
Revisions
Section titled “Revisions”To track changes in your data model, annotate your class with @Revision. When deserializing, Fluxzero will use this
revision number to determine whether any transformation is required.
@Revision(1)public record CreateUser(String userId) { // userId renamed from id}@Revision(1)data class CreateUser(val userId: String) // userId renamed from idUpcasting
Section titled “Upcasting”Upcasting transforms a serialized object from an older revision to a newer one.
If only the fully qualified type or package name changed and the JSON structure stayed compatible, use a type alias instead of an upcaster.
class CreateUserUpcaster {
@Upcast(type = "com.example.CreateUser", revision = 0) ObjectNode upcastV0toV1(ObjectNode json) { json.set("userId", json.remove("id")); return json; }}class CreateUserUpcaster {
@Upcast(type = "com.example.CreateUser", revision = 0) fun upcastV0toV1(json: ObjectNode): ObjectNode { json.set("userId", json.remove("id")) return json }}This method is applied before deserialization. The object is transformed as needed so your code always receives the current version.
Registering a batch of upcasters or downcasters is atomic: if any declaration conflicts, none of that batch becomes active. Consumer filtering retains historical upcaster inputs because a caster can change the resulting payload type or web route. Exact aliases without such transformations can retain narrower selection; see message tracking.
Changing the type name
Section titled “Changing the type name”To modify the type name, or the type and content together, return a Data<ObjectNode>. This ordinary upcaster is
the preferred owner of a class rename; a separate typecaster is not required:
@Upcast(type = "com.example.CreateUser", revision = 0)Data<ObjectNode> renameType(Data<ObjectNode> data) { ObjectNode json = data.getValue(); json.set("userId", json.remove("id")); return data.withType("com.example.RegisterUser");}@Upcast(type = "com.example.CreateUser", revision = 0)fun renameType(data: Data<ObjectNode>): Data<ObjectNode> { data.value.set<JsonNode>("userId", data.value.remove("id")) return data.withType("com.example.RegisterUser")}Changing message metadata
Section titled “Changing message metadata”You can also change a message’s metadata during upcasting:
@Upcast(type = "com.example.CreateUser", revision = 0)Metadata changeMetadata(Metadata metadata, SerializedMessage message) { return metadata.with("timestamp", Instant.ofEpochMilli(message.getTimestamp()).toString());}@Upcast(type = "com.example.CreateUser", revision = 0)fun changeMetadata(metadata: Metadata, message: SerializedMessage): Metadata { return metadata.with("timestamp", Instant.ofEpochMilli(message.timestamp).toString())}Metadata can be injected alongside the payload, Data, or SerializedMessage. Returning Metadata replaces the
message metadata, leaves the payload unchanged, and advances the revision. The original SerializedMessage is not
mutated.
This can be useful for retrofitting missing fields, adding tracing info, or migrating older messages to include required metadata keys.
An upcaster for both messages and stored documents must allow missing metadata: use @Nullable Metadata
in Java or Metadata? in Kotlin. Returning Metadata is only meaningful for message input. See
Upcast for supported parameters and return types.
Dropping or splitting messages
Section titled “Dropping or splitting messages”Upcasters can also drop a message by returning null or void, or split it into multiple new ones:
@Upcast(type = "com.example.CreateUser", revision = 0)void dropIfDeprecated(ObjectNode json) { // returning void removes this message from the stream}
@Upcast(type = "com.example.CreateUser", revision = 0)Stream<Data<ObjectNode>> split(Data<ObjectNode> data) { return Stream.of(data, new Data<>(...));}@Upcast(type = "com.example.CreateUser", revision = 0)fun dropIfDeprecated(json: ObjectNode) { // returning Unit removes this message from the stream}
@Upcast(type = "com.example.CreateUser", revision = 0)fun split(data: Data<ObjectNode>): Stream<Data<ObjectNode>> { return Stream.of(data, Data(...))}This works for any stored data — not just messages, but also snapshots, key-value entries, and documents.
Materialized Model Graphs
Section titled “Materialized Model Graphs”A materialized Graph has no single schema revision. Its hidden manifest records the serialized type and revision for every root and descendant placement. When a node is first accessed, Fluxzero passes that node through the same registered serializer chain as its direct Model document or snapshot.
For every changed Model type:
- increment that type’s
@Revision; - register an ordinary
@Upcastfor its serialized source type and revision; - return
Data<ObjectNode>when the upcast also changes the type name; - test the old node JSON with
TestFixture.whenUpcasting(...).
No Graph-specific upcaster is needed. Lazy upcasting makes reads correct immediately. If search should also retain the
evolved JSON, use a schema-only @HandleDocument handler as described in
Tracking, updating documents.
Returning its complete Graph migrates evolved canonical nodes and refreshes affected projections. Business values,
event history and relationships stay unchanged. Select GraphMigrationTarget.PROJECTION only when deliberately
migrating the stored composition alone.
Type aliases
Section titled “Type aliases”Use a type alias when serialized data contains an old Java/Kotlin type name, but its payload and revision do not need
to change. For most applications, configure all exact aliases and package aliases together in
application.properties:
fluxzero.serialization.typeAliases=host.example.LegacyCommand=io.example.CurrentCommand,host.example.events.*=io.example.events.*For deployment configuration, use the conventional environment-variable name. Quote the value so the shell does not
interpret the * characters:
export FLUXZERO_SERIALIZATION_TYPE_ALIASES='host.example.LegacyCommand=io.example.CurrentCommand,host.example.events.*=io.example.events.*'The compact FLUXZERO_SERIALIZATION_TYPEALIASES spelling is also accepted. Environment variables follow the normal
property resolution order and take precedence over system and application properties. The selected property value is
the complete comma-, semicolon-, or newline-separated alias list. Add .* on both sides to identify a package alias.
Aliases also resolve polymorphic @class values in JSON. Test a representative old message or document after a
rename, including any nested polymorphic values.
Use FluxzeroBuilder when the aliases are intentionally owned by application code or a test:
Fluxzero fluxzero = DefaultFluxzero.builder() .addTypeAlias("host.example.LegacyCommand", "io.example.CurrentCommand") .addPackageAlias("host.example.events", "io.example.events") .build(client);val fluxzero = DefaultFluxzero.builder() .addTypeAlias("host.example.LegacyCommand", "io.example.CurrentCommand") .addPackageAlias("host.example.events", "io.example.events") .build(client)Exact aliases win over package aliases; the longest matching package prefix wins between package aliases. Programmatic aliases override property aliases with the same source. See Serializer for the full alias contract and custom serializer support.
Upcasting flow
Section titled “Upcasting flow”When a stored data object is deserialized, Fluxzero uses the object’s type and revision to traverse the tree of registered upcasters. Each upcaster specifies which type+revision it can handle, and may:
- Transform the data to the next revision,
- Change the type name,
- Split the object into multiple new ones,
- Or drop it entirely.
The traversal continues until no upcasters match the current type+revision. Fluxzero then resolves exact and package aliases before deserializing the data into a Java/Kotlin object.
graph TD
RAW["Serialized data (type, revision)"] --> FIND{Find matching upcasters}
FIND -->|None found| ALIAS[Resolve type aliases]
ALIAS --> DESERIALIZE[Ready for deserialization]
FIND -->|Upcaster found| APPLY[Apply upcaster]
APPLY --> ACTION{Upcaster result}
ACTION -->|"Transformed (rev+1)"| FIND
ACTION -->|Renamed type| FIND
ACTION -->|Split into multiple| FIND
ACTION -->|"Dropped (null/void)"| END[Discard]
Testing upcasters from JSON
Section titled “Testing upcasters from JSON”Use @class and @revision to pass an older serialized payload through the normal upcaster chain without declaring a
full Data wrapper:
{ "@class": "com.example.CreateUser", "@revision": 0, "id": "user-123"}fixture.whenUpcasting("/users/create-user-revision-0.json") .expectResult(new CreateUser("user-123"));fixture.whenUpcasting("/users/create-user-revision-0.json") .expectResult(CreateUser("user-123"))The @class value is used as Data.type, so the upcaster is selected by the same type-and-revision pair as stored
data. Both control markers are removed before upcasting. Registered exact and package aliases are then applied to the
resulting type before deserialization. Configure them through fluxzero.serialization.typeAliases or
FLUXZERO_SERIALIZATION_TYPE_ALIASES, on FluxzeroBuilder, or directly on the fixture with
registerTypeAlias(...) and registerPackageAlias(...). A property named revision without the @ prefix is always
ordinary payload data.
Untyped JsonUtils.fromFile(...) and JsonUtils.fromJson(...) calls expose the same representation as
Data<JsonNode>, making revisioned JSON useful outside TestFixture as well. Explicitly typed JsonUtils overloads
keep their declared return type.
Downcasting
Section titled “Downcasting”Downcasting does the reverse: it converts a newer object into an older format. This is useful for emitting legacy-compatible data or supporting external systems.
class CreateUserDowncaster {
@Downcast(type = "com.example.CreateUser", revision = 1) ObjectNode downcastV1toV0(ObjectNode json) { json.set("id", json.remove("userId")); return json; }}class CreateUserDowncaster {
@Downcast(type = "com.example.CreateUser", revision = 1) fun downcastV1toV0(json: ObjectNode): ObjectNode { json.set("id", json.remove("userId")) return json }}To downcast an object to a desired revision, use:
Fluxzero.downcast(object, revision);Fluxzero.downcast(object, revision)Registered simple type names
Section titled “Registered simple type names”Use @RegisterType when a frontend or another external producer should not need to send a Java/Kotlin fully qualified
name. The annotation processor indexes a type or package at compile time.
@RegisterTypepackage com.example.api;@RegisterType(root = "com.example.api")object TypeRegistryMarkerThe producer can then use CreateUser, or a distinguishing suffix such as user.CreateUser, as the serialized
envelope type. This is not limited to commands and queries: events, documents, snapshots, and other serialized payloads
use the same resolution. Root and nested JSON @class values use the same registry:
{ "@class": "CreateUser", "userId": "user-123"}Simple names must be unique across the registered types. When names collide, send enough trailing package segments to
distinguish the intended class. Annotation processing must run in every module that contributes registered types;
Kotlin uses kapt.
When packaging a custom uber-JAR, merge the generated type indexes; see RegisterType for the annotation-processing and packaging requirements.
Fully qualified names continue to work. Use type aliases for old FQNs that may already be stored or queued after a package move; use registered simple names to keep current producers independent of backend package layout.
For resolution and alias ordering, see TypeRegistry and Serializer .
Registration
Section titled “Registration”In standalone tests, register casters before the first conversion or historical Given step:
TestFixture.create().registerCasters(new CreateUserUpcaster()) .whenUpcasting("/users/create-user-rev0.json") .expectResult(expectedCurrentCommand);TestFixture.create().registerCasters(CreateUserUpcaster()) .whenUpcasting("/users/create-user-rev0.json") .expectResult(expectedCurrentCommand)The resource’s type and revision must match that caster; expectedCurrentCommand must retain the old ID and business
values. TestFixture.create(caster) registers a handler, not a caster. Kotlin uses
TestFixture.create().registerCasters(CreateUserUpcaster()).whenUpcasting<CreateUser>(resource).
For a complete value-preserving name → details.name example, Model event reconstruction and an old-writer/new-reader
storage test, use Testing Model migrations.
When using Spring, any bean containing @Upcast or @Downcast methods is automatically registered with the
serializer.
Outside of Spring, register them manually:
serializer.registerCasters(new CreateUserUpcaster(), new CreateUserDowncaster());serializer.registerCasters(CreateUserUpcaster(), CreateUserDowncaster())How it works
Section titled “How it works”-
On deserialization:
- Fluxzero detects the revision of the stored object
- Applies all applicable
@Upcastmethods (in order) - Resolves exact and package type aliases
- Then deserializes into the latest version
-
On serialization:
- Fluxzero stores the latest type and revision
- If needed, a
@Downcastcan adapt it for external use
All casting occurs in your application, not in the Fluxzero Runtime. Stored messages remain unchanged.
Enum constants with their own method implementations use the declaring enum’s type and revision in Jackson serialization. Old envelopes containing the generated constant subclass name remain readable while that class is available. Existing custom codecs that support the subclass retain their original type and revision.
Best practices
Section titled “Best practices”- Use
@Revisionto version any payloads that are stored or transmitted - Use type or package aliases for pure fully qualified name changes that do not alter the payload or revision
- Use
ObjectNodefor structural changes, orData<ObjectNode>when serialized content, type or revision evolve together; use aMetadataparameter/result specifically for message metadata - Chain upcasters one revision at a time (
v0 → v1,v1 → v2, etc.) - Ensure upcasters are side-effect free and deterministic
© 2026 Fluxzero