Defining Transformations

A transformation is built using the static factory methods on EventTransformation. You name the event identity it matches (from), the identity it produces (to), and a mapper that rewrites the payload.

A single transformation

A single transformation maps one stored version of an event to the next. The from and to identities name the same event and differ only in version, and the mapper rewrites the payload from the old shape to the new one, so a transformation only ever changes an event’s version and payload. Each step is small and self-contained, and you register one per version so the chain can compose them.

A v1 CoursePublished stored a single capacity field. This transformation lifts it into the v2 shape that carries minCapacity and maxCapacity:

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 CoursePublishedV1ToV2 {

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

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

    private static JsonNode map(JsonNode v1) { (4)
        int capacity = v1.get("capacity").asInt();
        ObjectNode v2 = JsonNodeFactory.instance.objectNode();
        v2.set("catalogId", v1.get("catalogId"));
        v2.set("courseId",  v1.get("courseId"));
        v2.set("name",      v1.get("name"));
        v2.put("minCapacity", capacity);
        v2.put("maxCapacity", capacity);
        return v2;
    }
}
1 from(…​) matches the stored event identity by its MessageType, a qualified name plus a version. Only events with this exact identity are passed to the mapper.
2 to(…​) declares the identity of the event the mapper produces. It must keep the same qualified name as from, and only the version may change.
3 transform(…​) converts the stored payload to the requested type before calling your mapper, which returns the new payload.
4 The mapper takes just the converted payload and returns the new one. Most mappers need nothing else. If yours does, see Reading from the processing context.
The JsonNode used here is the Jackson 3 type (tools.jackson.databind.JsonNode), the same representation Axon Framework’s default converter works with.
transform(…​) changes the version, not the name

A transform(…​) may change an event’s version and payload, but not its qualified name: the to identity must share the qualified name of from. To change the name itself, with the payload carried over unchanged, use a rename instead.

If a transform(…​)’s `from and to qualified names differ, the chain raises a ChainConfigurationException:

  • with a concrete from, the mismatch is detected when the chain is built, so the failure surfaces at startup.

  • with a predicate from, a match can only be resolved per event, so the failure surfaces when such an event is read.

Working with generic payloads

transform(Class<T>, …​) works for any non-generic type. When the type you want carries type parameters (such as Map<String, Object> or List<…​>), use the TypeReference overload so the generic information is preserved:

private static final TypeReference<Map<String, Object>> INPUT_TYPE = new TypeReference<>() { (1)
};

public static EventTransformation build() {
    return EventTransformation.from(FROM)
                              .to(TO)
                              .transform(INPUT_TYPE, StudentRegisteredV1ToV2::map); (2)
}

private static Map<String, Object> map(Map<String, Object> v1) {
    Map<String, Object> v2 = new LinkedHashMap<>();
    v2.put("catalogId", v1.get("catalogId"));
    v2.put("studentId", v1.get("studentId"));
    v2.put("fullName", combine(v1.get("firstName"), v1.get("lastName"))); (3)
    return v2;
}
1 A TypeReference captures the full Map<String, Object> type that a Class token would erase.
2 Pass it to the TypeReference overload of transform(…​).
3 The mapper combines the separate firstName and lastName fields into a single fullName.

Mapping to a typed event

Reading and writing JsonNode or Map is convenient, but it gives up compile-time safety: a misspelled field or wrong type only surfaces at read time, and the loosely typed nodes coerce values in ways you did not ask for (dates and UUIDs become strings, a small number is an int while a larger one is a long).

You can avoid that by mapping between records and letting the compiler check the result. Describe the stored shape as a small record that mirrors the old version, and map it to the next version of the event. Axon Framework deserializes the stored payload into that record before your mapper runs:

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

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

private static CoursePublished map(V2Schema v2) { (2)
    return new CoursePublished(
            new CatalogId(v2.catalogId().value()),
            new CourseId(v2.courseId().value()),
            v2.name(),
            new CapacityRange(v2.minCapacity(), v2.maxCapacity())
    );
}

// Mirrors how a v2 CoursePublished was stored: capacity split into a min and a max.
record V2Schema(Id catalogId, Id courseId, String name, int minCapacity, int maxCapacity) { } (3)

// Stored id value object: a single value string.
record Id(String value) { }
1 The mapper’s input type is the schema record, not JsonNode. Axon Framework deserializes the stored payload into it before the mapper runs.
2 The mapper works on named, typed fields and returns the current CoursePublished event, so a misspelled field or wrong type is now a compile error.
3 The schema record only describes the stored shape. It mirrors the wire form exactly, including identifiers written as {"value": …​} objects, and stays free of the live value objects, which the mapper builds at the end.
The stored-shape record is a throwaway, not a domain class

The input record (here V2Schema) exists only to read one historic version. Keep it next to the transformation that uses it, and do not add it, or a handler for it, to your domain model.

You never accumulate a class or an event handler per historic version: the chain lifts every stored version to the current shape, so handlers only ever deal with the current event. When the event changes again, you add one more transformation, nothing else.

Mapping the final hop straight to the current event class, as above, is fully type-safe but couples that transformation to the class. When the event changes again, map what is now an older version to its own stored-shape record instead, exactly as the earlier hops do, so the historic transformation keeps compiling unchanged.

Because the mapper returns a typed object, Axon Framework resolves its identity and rejects a wrong-shaped output: if the produced event’s identity does not match the declared to, the chain raises a ChainConfigurationException. A JsonNode or Map output skips that check, so the typed form catches a class of mistakes the untyped one cannot.

Reading from the processing context

The mappers shown so far take only the payload, which is all most transformations need. When a mapper needs read-only access to the surrounding ProcessingContext, add it as a second parameter:

.transform(JsonNode.class, (payload, context) -> map(payload, context)); (1)
1 The context-taking overload of transform(…​). The ProcessingContext is for read-only access and may be null.

Reach for this overload only when the mapper genuinely depends on the context. Keep it out of the signature otherwise, so the common case stays a plain payload-to-payload function.

Matching multiple event versions

An event that has changed more than once is handled one version step at a time: each step is its own transformation, with its own mapper, and the chain composes them, so a stored v1 is lifted to v2 and then to v3 on read. Write one transformation per version step rather than one that jumps straight from v1 to v3.

The framework offers no way to bind a single transformation to a set of source versions, and that is deliberate. Reusing one transformation across several MessageType versions is a smell rather than something to reach for: a single mapper covering several versions has to branch over which one it received, while a per-version transformation stays a straight-line mapper that reads more clearly and is easier to evolve. If you genuinely want the same mapper on several versions, nothing stops you from registering one transformation per version yourself, but the framework will not suggest it for you.

The one shape that does not decompose into version steps is a whole range of historic versions that collapses to a single target and cannot be enumerated up front. Match that with a predicate.

Matching with a predicate

This is the exception, not the norm: prefer exact matching with one transformation per version step, and reach for a predicate only when a whole range of historic versions genuinely folds to a single target and the versions cannot be listed up front. Pass the predicate to from(…​) and scope it to the names it is meant for with declaringFromTypes(…​). The declared names act as a pre-filter: the predicate only ever sees events of those names, so it can match on version alone.

private static final Predicate<MessageType> BETA_VERSION =
        type -> type.version().startsWith("0."); (1)

private static final QualifiedName FROM_NAME = new QualifiedName("coursecatalog.WelcomeMessageSent");
private static final MessageType TO = new MessageType(FROM_NAME, "1.0.0");

public static EventTransformation build() {
    return EventTransformation.from(BETA_VERSION)            (2)
                              .declaringFromTypes(FROM_NAME) (3)
                              .to(TO)
                              .transform(JsonNode.class, WelcomeMessageBetaCleanup::map);
}
1 The predicate matches on version alone. The pre-filter takes care of the name.
2 Pass the predicate to the from(…​) overload instead of a concrete MessageType.
3 declaringFromTypes(…​) declares the names the predicate runs against. The predicate is only ever evaluated for events of these names, so a type-filtering read for WelcomeMessageSent stays scoped to that name rather than scanning every stored event.

This single transformation folds every 0.x version of WelcomeMessageSent up to 1.0.0 without listing each one.

A predicate cannot be optimized

The chain cannot look inside a predicate, so unlike an exact from it is evaluated for every event that reaches the fallback step (an exact match always takes precedence; see When more than one transformation matches). Never use one merely to cover a handful of known versions, such as type → versions.contains(type.version()): that runs on every read and the chain cannot optimize it. Match known versions exactly instead, one transformation per version step.

Without a pre-filter

You can omit declaringFromTypes(…​) and go straight to to(…​). The predicate then sees every event read, so it must guard the qualified name itself:

private static final Predicate<MessageType> FROM_PREDICATE =
        type -> "coursecatalog.WelcomeMessageSent".equals(type.qualifiedName().name()) (1)
                && type.version().startsWith("0.");

public static EventTransformation build() {
    return EventTransformation.from(FROM_PREDICATE) (2)
                              .to(new MessageType("coursecatalog.WelcomeMessageSent", "1.0.0"))
                              .transform(JsonNode.class, WelcomeMessageBetaCleanupWithoutPreFilter::map);
}
1 Without a pre-filter, the name guard must live inside the predicate.

Both forms produce the same result, but it is typically preferred to use declaringFromTypes(…​). The predicate stays simpler (version only) and more importantly, the Framework can optimize reads from the event store as it knows the types that are expected. Because the chain cannot look inside a predicate, the form without declaringFromTypes(…​) drops the type filter on reads, a broader read that returns every stored event so the predicate can run against each.

Keep the matched names aligned with the to identity’s name either way: a transform(…​) changes the version, not the name (use a rename to change the name).

Renaming an event

The transformations above change an event’s payload and version while keeping its qualified name. When the name itself must change and the payload stays the same, use rename(…​) instead of transform(…​).

An early version of the catalog published courses with a CourseOffered event. A later refinement renamed it to CoursePublished. A single rename(…​) reads every stored CourseOffered back as a CoursePublished, with its payload untouched:

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.

A rename may change the qualified name, the version, or both. Handlers registered for the new identity receive the event. Handlers still on the old identity no longer match it. A rename(…​) whose from and to are identical is rejected, since it would leave the event unchanged.

Renames compose with payload transformations

A renamed event keeps flowing through the chain. Renaming CourseOffered to CoursePublished 1.0.0 lets the existing CoursePublished 1.0.02.0.03.0.0 transformers apply in turn, so a stored CourseOffered reaches your handler as the current CoursePublished. Register the rename alongside those transform(…​) steps and the chain applies them in sequence.

Custom criteria still find renamed events

If you write a custom EventCriteria (through an @EventCriteriaBuilder method or a CriteriaResolver) that filters by type, list only the current name. A filter on CoursePublished also matches events stored as CourseOffered: Axon adds the old names for you, so renamed events are never missed.

Dropping an event

Sometimes a stored event should no longer reach any handler at all. An old monitoring sidecar might have written SystemHeartbeat pings into the stream, or a class of event might now be regulated and must be suppressed from processing without being deleted from storage. drop(…​) removes every matching event from the read stream:

private static final MessageType FROM = new MessageType("coursecatalog.SystemHeartbeat", "1.0.0");

public static EventTransformation build() {
    return EventTransformation.drop(FROM); (1)
}
1 drop(from) takes only the identity to match. There is no to and no mapper: a matched event produces no output, the 1:0 case.

A drop matches its from by exact identity, like a concrete from(…​); it does not take a predicate. No payload conversion happens, because nothing is produced.

The stored events stay exactly as they were written; only the read path is affected. A dropped event never reaches a handler, and surviving events keep their original tracking token: once a streaming processor reads an event after a drop, the dropped position is covered, so a restart does not reprocess it.

Unlike a rename, a drop declares no to, so it adds no read-time widening: a type-filtering read is never broadened to fetch a dropped event.

Dropping an event versus dropping a field

drop(…​) removes a whole event from the read stream. To drop a field that handlers no longer read, let the converter ignore it instead: that is a conversion concern, not a transformation. See the overview for when each applies.

Next steps

With your transformations defined, the next step is to assemble and register them. See Configuring transformations.