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.
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.
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.
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.
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");}@Upcast(type = "com.example.CreateUser", revision = 0)fun renameType(data: Data<ObjectNode>): Data<ObjectNode> { 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());}@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.
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"));}@Upcast(type = "com.example.CreateUser", revision = 0)fun upcast(json: ObjectNode, metadata: Metadata?): ObjectNode { return metadata?.get("tenant")?.let { json.put("tenant", it) } ?: json}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.
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.
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 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);val 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.
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)Registration
Section titled “Registration”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.
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 simple structural changes, orData<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