Upcaster Migration

In Axon Framework 4, structural changes to stored events were handled by upcasters. The most common form was the SingleEventUpcaster: a one-to-one transform from one event revision to the next. Axon Framework 5 replaces the single-event upcaster with an event transformation: the same idea, rewriting a stored event on the way out, expressed through a small declarative API.

This path covers migrating one-to-one SingleEventUpcaster implementations to event transformations. Like the single-event upcaster, a payload-mapping transformation is one-to-one and keeps the event’s qualified name: it may change the version and payload, but not the name. To change the name itself, use a rename, which carries the payload over unchanged. You map a deserialized payload (JsonNode, Map<String, Object>, …​) rather than an IntermediateEventRepresentation, and mappers must be deterministic and side-effect-free, exactly as upcasters were expected to be.

Before migrating an upcaster, check whether you still need one. Axon Framework 5 converts stored events to the type each handler asks for at handling time, which covers many changes that previously required an upcaster. See Event versioning for the conversion-first approach, and reach for a transformation only for structural changes conversion cannot express.

How the concepts map

Axon Framework 4 (SingleEventUpcaster) Axon Framework 5 (EventTransformation)

canUpcast(…​): decide whether this event matches

from(MessageType), or from(Predicate<MessageType>) for a range of versions

The target type passed to upcastPayload(…​)

to(MessageType): declare the produced identity

doUpcast(…​): rewrite the serialized representation

transform(type, mapper): map the deserialized payload

SimpleSerializedType (payload class name + revision)

MessageType (qualified name + version)

IntermediateEventRepresentation

a typed payload (JsonNode, Map<String, Object>, …​)

EventUpcasterChain and registration order

EventTransformerChain.builder().register(…​) in order

Before and after

Both versions below lift an unversioned SystemAnnouncement onto 1.0.0, renaming the payload’s message field to text.

Axon Framework 4 single-event upcaster:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.axonframework.serialization.SimpleSerializedType;
import org.axonframework.serialization.upcasting.event.IntermediateEventRepresentation;
import org.axonframework.serialization.upcasting.event.SingleEventUpcaster;

public class SystemAnnouncement0_to_1Upcaster extends SingleEventUpcaster {

    private static final SimpleSerializedType TARGET =
            new SimpleSerializedType(SystemAnnouncementEvent.class.getTypeName(), null);

    @Override
    protected boolean canUpcast(IntermediateEventRepresentation rep) {
        return rep.getType().equals(TARGET);
    }

    @Override
    protected IntermediateEventRepresentation doUpcast(IntermediateEventRepresentation rep) {
        return rep.upcastPayload(
                new SimpleSerializedType(TARGET.getName(), "1.0.0"),
                JsonNode.class,
                legacy -> {
                    ObjectNode v1 = JsonNodeFactory.instance.objectNode();
                    v1.set("catalogId", legacy.get("catalogId"));
                    v1.put("text", legacy.get("message").asText());
                    return v1;
                });
    }
}

Axon Framework 5 event transformation:

import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import io.axoniq.framework.messaging.transformation.events.EventTransformation;
import org.axonframework.messaging.core.MessageType;

public final class SystemAnnouncementLegacyUplift {

    private static final MessageType FROM = new MessageType("coursecatalog.SystemAnnouncement", "0.0.1");
    private static final MessageType TO   = new MessageType("coursecatalog.SystemAnnouncement", "1.0.0");

    public static EventTransformation build() {
        return EventTransformation.from(FROM)
                                  .to(TO)
                                  .transform(JsonNode.class, SystemAnnouncementLegacyUplift::map);
    }

    private static JsonNode map(JsonNode legacy) {
        ObjectNode v1 = JsonNodeFactory.instance.objectNode();
        v1.set("catalogId", legacy.get("catalogId"));
        v1.put("text", legacy.get("message").asString());
        return v1;
    }
}

The behavior is the same, but the Axon Framework 5 version drops the serialized-type plumbing and works with a typed payload directly. The identity also differs: Axon Framework 4 keyed off the payload’s class name, while Axon Framework 5 keys off a logical qualified name that is decoupled from the class.

Unversioned events

Events that do not have an explicit version set via the @Event annotation default to version 0.0.1 in Axon Framework 5. Match that version in from(…​) when lifting legacy events, as shown above.

Events that were already stored without a version (the Axon Framework 4 default when no revision was specified) are a separate case: they resolve to version 0.0.0, kept distinct from the 0.0.1 default so that they always sort as older than any explicitly versioned event. See Events without a revision for how the EventTypeResolver resolves this at read time and how to configure a different fallback.

Renaming an event

Axon Framework 4 keyed an event off its payload class name (the SerializedType name defaulted to the fully qualified class name), so a name change had to be bridged at the serialization layer or by an upcaster that rewrote the stored SerializedType. Axon Framework 5 keys off a logical MessageType decoupled from the class, so a rename becomes a first-class operation: declare the old and new identity and the payload is carried over unchanged.

A payload-mapping transform(…​) may only change the version, never the name. For renames, use the dedicated rename(…​) factory instead, which does not require a mapper:

import io.axoniq.framework.messaging.transformation.events.EventTransformation;
import org.axonframework.messaging.core.MessageType;

public final class CourseOfferedToCoursePublished {

    private static final MessageType FROM = new MessageType("coursecatalog.CourseOffered", "1.0.0");
    private static final MessageType TO   = new MessageType("coursecatalog.CoursePublished", "1.0.0");

    public static EventTransformation build() {
        return EventTransformation.rename(FROM, TO); (1)
    }
}
1 rename(from, to) takes the two identities and nothing else: there is no mapper, because the payload is carried over unchanged. The new identity may change the qualified name, the version, or both.

A renamed event keeps flowing through the chain, so a rename can sit in front of the version-step transformations that lift it the rest of the way. See Renaming an event for the full detail.

Registering the transformations

In Axon Framework 4 you registered upcasters with an EventUpcasterChain. In Axon Framework 5 you register transformations with an EventTransformerChain, one per version step. The chain matches each event by its type and applies every matching step in turn until the event reaches its current version, so a stored v1 event still reaches your handler fully upcasted. When several transformations could match the same event, the most specific one wins (an exact from beats a predicate), independent of registration order.

Axon Framework 4 composed upcasters into an EventUpcasterChain:

EventUpcasterChain upcasters =
        new EventUpcasterChain(new SystemAnnouncement0_to_1Upcaster());

Axon Framework 5 builds an EventTransformerChain explicitly and exposes it as a component:

EventTransformerChain chain =
        EventTransformerChain.builder()
                             .register(SystemAnnouncementLegacyUplift.build())
                             .build();

See Configuring transformations for the full setup, including the configuration.

Structural changes not yet covered

A couple of changes may arrive as the transformation API grows (see issue #3597):

  • Splitting one event into several: deriving multiple events from a single stored event.

  • Adjusting metadata: changing an event’s metadata rather than its payload or identity.

Combining or carrying state across events, such as merging several events into one or enriching an event with data from an earlier one (a context-aware transformation), stays outside transformation by design: each event is transformed on its own as it is read, and a read can start anywhere in the stream, so reaching across events has no dependable view and would risk mixing data between unrelated entities. For these, a stateful projection (which grows its view as events arrive) or a one-time copy-and-replace migration (which rewrites the stored events) is the right tool.

For everything else, rely on payload conversion at handling time wherever the change can be expressed at handling time.