Skip to content

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.

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
}

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;
}
}

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.

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");
}

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());
}

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.


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<>(...));
}

This works for any stored data — not just messages, but also snapshots, key-value entries, and documents.


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:

  1. increment that type’s @Revision;
  2. register an ordinary @Upcast for its serialized source type and revision;
  3. return Data<ObjectNode> when the upcast also changes the type name;
  4. 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.


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:

Terminal window
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);

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.


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]

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"));

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 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;
}
}

To downcast an object to a desired revision, use:

Fluxzero.downcast(object, revision);

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.

@RegisterType
package com.example.api;

The 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 .


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);

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());

  • On deserialization:

    • Fluxzero detects the revision of the stored object
    • Applies all applicable @Upcast methods (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 @Downcast can 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.


  • Use @Revision to 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 ObjectNode for structural changes, or Data<ObjectNode> when serialized content, type or revision evolve together; use a Metadata parameter/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