Skip to content

Upcasting and downcasting

Fluxzero uses a Serializer to convert message payloads, snapshots, key-value entries, and other stored data into a binary format (typically byte[]). By default, the client uses a Jackson-based implementation that serializes objects to JSON.

The serializer is fully pluggable, and you can supply your own by implementing or extending AbstractSerializer.


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.

Separate classpath entries and Spring Boot nested JARs retain the generated module indexes automatically. A custom uber-JAR is responsible for combining the fixed registry resource itself. With Maven Shade, for example:

<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/io.fluxzero.common.serialization.TypeRegistry</resource>
</transformer>

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.

Resolution first applies historical exact or package aliases and then resolves the resulting unique registered name. Canonical generic type declarations and unknown identifiers retain Jackson’s existing behavior. For incoming envelope types, registered names are normalized before revision upcasters are selected. Older revisions therefore use the same upcaster as their fully qualified type. Historical aliases intentionally remain a post-upcast compatibility step, allowing an upcaster for the old type to run before its package is mapped.


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.

To also modify the type name, return a Data<ObjectNode>:

@Upcast(type = "com.example.CreateUser", revision = 0)
Data<ObjectNode> renameType(Data<ObjectNode> data) {
return data.withType("com.example.RegisterUser");
}

You can even 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.

Upcasting also applies to stored data that has no message context, such as snapshots, key-value entries, and documents. Such input cannot provide metadata. A non-nullable Metadata parameter therefore causes deserialization to fail. If the upcaster supports both cases, make the parameter nullable:

@Upcast(type = "com.example.CreateUser", revision = 0)
ObjectNode upcast(ObjectNode json, @Nullable Metadata metadata) {
return metadata == null ? json : json.put("tenant", metadata.get("tenant"));
}

For Java, any runtime annotation whose simple name is Nullable is accepted. Kotlin nullable parameter types are recognized directly. Returning Metadata still requires message input because non-message data has nowhere to store it.


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.


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 apply to the top-level serialized type, to polymorphic @class values nested anywhere in Jackson JSON, and to JSON-encoded message metadata when it is read as an object through Metadata#get. Raw metadata strings remain unchanged. TestFixture also resolves a legacy root @class in a non-revisioned fixture. A revisioned fixture keeps its declared old type through the upcaster chain and applies the alias afterwards.

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

A package alias includes the package itself and all its subpackages while preserving the remainder of the class name. Package boundaries are respected: host.example does not match host.examples.SomeType. Exact aliases take precedence over package aliases, and the longest matching package prefix wins when package aliases overlap. Aliases may chain, but cycles are rejected during registration.

Programmatic aliases take precedence over property aliases with the same source. Aliases are configured on the primary and snapshot serializers and on a serializer-backed document serializer. A custom Serializer may support exact aliases through registerTypeAlias(...) while opting out of package aliases.


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

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.


  • 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 simple structural changes, or Data<ObjectNode> to modify metadata
  • Chain upcasters one revision at a time (v0 → v1, v1 → v2, etc.)
  • Ensure upcasters are side-effect free and deterministic

© 2026 Fluxzero