Event Versioning

In the lifecycle of an application, events will typically change their format. As events are stored indefinitely, the application should be able to cope with several versions of an event. This chapter will discuss what to keep in mind when creating your events, for backwards (and forwards) compatibility. It will explain Axon’s versioning approach using payload conversion and when you might need upcasters.

Event versioning at handling time

Axon Framework provides a flexible approach to event versioning that minimizes the need for complex migration strategies. We dub this payload conversion at handling time, which allows different handlers to receive events in the format they expect.

When events are stored in the event store, they retain their serialized form (for example JSON or Avro binary) along with metadata about their MessageType. When a handler needs to process an event, Axon converts the payload to the handler’s expected type at that moment.

This means:

  • Different handlers can process the same event using different Java representations.

  • You can evolve your event handling without modifying stored events.

  • Simple structural changes often require no upcasters.

For example, consider an event stored with this structure:

@Event(name = "OrderPlaced", version = "1.0.0")
public record OrderPlacedEvent(
    String orderId,
    String customerId,
    List<String> productIds
) {
}

Later, you want to process this same event with additional computed information:

@Event(name = "OrderPlaced", version = "2.0.0")
public record EnrichedOrderPlacedEvent(
    String orderId,
    String customerId,
    List<String> productIds,
    int productCount  // Computed from productIds
) {
    // Compact constructor that computes productCount
    public EnrichedOrderPlacedEvent(String orderId, String customerId, List<String> productIds) {
        this(orderId, customerId, productIds, productIds != null ? productIds.size() : 0);
    }
}

You can add a handler that receives the event in this new format:

@EventHandler
public void on(EnrichedOrderPlacedEvent event) {
    // Axon converts the stored OrderPlacedEvent to EnrichedOrderPlacedEvent
    // The productCount field is computed during conversion
    notifyAnalytics(event.getProductCount());
}

In doing the above, Axon will know you want to handle the "OrderPlaced" and desire it in the EnrichedOrderPlacedEvent. It will thus convert the stored format of the "OrderPlaced" into a EnrichedOrderPlacedEvent. In the meantime, another handler (in for example another application) can still use the old format:

@EventHandler(eventName = "OrderPlaced")
public void on(OrderPlacedEvent event) {
    // Old handling flow...
}

Both handlers process the same stored event, each receiving it in their preferred format. Axon’s Converter performs the transformation automatically at handling time.

For complete details on payload conversion, see Conversion.

When payload conversion is sufficient

Payload conversion handles many common versioning scenarios without requiring upcasters:

  • Adding fields with defaults - New fields can have default values during conversion.

  • Removing fields - Handlers simply don’t declare fields they don’t need.

  • Renaming fields - Use Jackson annotations like @JsonProperty to map old names to new fields.

  • Changing field types - Converters can transform compatible types (for example String to Integer, Date to Instant).

  • Restructuring for different handlers - Different handlers can use completely different Java types for the same event.

Event Upcasting

Upcasters not available in Axon 5.0

Event upcasters are not yet available in Axon Framework 5.0. They are scheduled to be reintroduced in Axon Framework 5.2.0 (see issue #3597) with updated APIs aligned with the new conversion architecture.

For now, rely on payload conversion at handling time for event versioning. This approach handles most common versioning scenarios without requiring upcasters.

The documentation below describes the upcasting concepts and approach that will be available in future releases.

For scenarios where payload conversion is not sufficient, Axon will provide upcasters to transform the stored representation of events.

Upcasting transforms events from their original stored structure to a new structure. This is useful when you need to modify the event stream itself, such as splitting events, merging events, or fundamentally changing event types.

To upcast an event means to transform it from its original structure to its new structure. Unlike payload conversion which happens at handling time, upcasting modifies how events are read from storage. Manually written upcasters must be provided to specify how to transform the old structure to the new structure.

Benefits of Upcasters

Upcasting allows you to do non-destructive refactoring of stored events. In other words, the complete event history remains intact, while events are transformed when read from the store.

Upcasters take one input event of version x and output zero or more new events of version x + 1. Moreover, upcasters are processed in a chain, meaning that the output of one upcaster is sent to the input of the next. This allows you to update events in an incremental manner, writing an upcaster for each new event version, making them small, isolated, and easy to understand.

In the remainder of this section we’ll explain when you need an upcaster, how to write an upcaster, describe the different (abstract) implementations of the Upcaster, and explain how the serialized representations of events affects how upcasters are written.

When to use upcasters vs payload conversion

For most event versioning scenarios, payload conversion at handling time is simpler and more flexible. Only use upcasters when you need to transform the stored event structure in ways that conversion cannot handle.

See When upcasters are needed for specific scenarios where upcasters are required.

When upcasters are needed

Upcasters are required when you need to transform the stored representation of events in ways that payload conversion cannot handle:

  • Splitting one event into multiple events - When you need to split a single stored event into multiple distinct events.

  • Merging multiple events - When you need to combine several stored events into one.

  • Complex structural transformations - When the transformation requires access to multiple events or contextual information.

  • Changing event identity - When you need to change the event’s MessageType (qualified name or version) in storage.

  • Generic transformation for all handlers - When you have multiple handlers for the same event and want a single transformation to apply to all of them, rather than adjusting each handler individually.

For most scenarios, payload conversion is simpler and more flexible than upcasters. Only use upcasters when the stored event structure itself must change.

Event version identification

To allow an upcaster to determine what version of serialized object they are receiving, the Event Store stores a version along with the fully qualified name of the event’s MessageType.

This version is specified using the @Event annotation on your event class:

@Event(
    namespace = "com.example.orders",  // Defaults to package name
    name = "OrderPlaced",              // Defaults to simple class name
    version = "1.0.0"                  // Defaults to "0.0.1"
)
public record OrderPlacedEvent(String orderId) {
}

The version field in the @Event annotation determines the version stored in the event store. Upcasters use this version to decide whether to transform an event.

Axon’s upcasters do not work with the EventMessage directly, but with an IntermediateEventRepresentation. The IntermediateEventRepresentation provides functionality to retrieve all necessary fields to construct an EventMessage (and thus a upcasted EventMessage too), together with the actual upcast functions. These upcast functions by default only allow the adjustment of the event’s payload, payload type and additions to the event’s metadata. The actual representation of the events in the upcast function may vary based on the event serializer used or the desired form to work with, so the upcast function of the IntermediateEventRepresentation allows the selection of the expected representation type. The other fields, for example the message/aggregate identifier, aggregate type, timestamp etc. are not adjustable by the IntermediateEventRepresentation. Adjusting those fields is not the intended work for an upcaster. As such, those options are not provided by the provided IntermediateEventRepresentation implementations.

The basic Upcaster interface for events in the Axon Framework works on a Stream of IntermediateEventRepresentations and returns a Stream of IntermediateEventRepresentations. The upcasting process thus does not directly return the end result of the introduced upcast functions, but chains every upcasting function from one revision to another together by stacking IntermediateEventRepresentations. Once this process has taken place and the end result is pulled from them, that is when the actual upcasting function is performed on the serialized event.

Different serialization formats

Sometimes the event store can contain events in different serialized formats, since differing Converter implementations were used.

During upcasting it is important to note what the format is of the IntermediateEventRepresentation, as it influences the upcaster solution provided. To validate if the intermediate representation supports a given type, you can invoke IntermediateEventRepresentation#canConvertDataTo(Class<?>).

Provided abstract Upcaster implementations

As described earlier, the Upcaster interface does not upcast a single event; it requires a Stream<IntermediateEventRepresentation> and returns one. However, an upcaster is usually written to adjust a single event out of this stream. More elaborate upcasting setups are also imaginable. For example from one event to multiple, or an upcaster which pulls state from an earlier event and pushes it in a later one. This section describes the currently provided (abstract) implementations of event upcasters which a user can extend to add their own desired upcast functionality.

  • SingleEventUpcaster - a one-to-one implementation of an event upcaster. Extending from this implementation requires one to implement a canUpcast and doUpcast function, which respectively check whether the event at hand is to be upcasted, and if so how it should be upcasted. This is most likely the implementation to extend from, as most event adjustments are based on self contained data and are one to one.

  • EventMultiUpcaster - a one-to-many implementation of an event upcaster. It is mostly identical to a SingleEventUpcaster, with the exception that the doUpcast function returns a Stream instead of a single IntermediateEventRepresentation. As such, this upcaster allows you to convert a single event to several events. This might be useful if you for example have figured out you want more fine grained events from a fat event.

  • ContextAwareSingleEventUpcaster - a one-to-one implementation of an upcaster, which can store context of events during the process. Next to the canUpcast and doUpcast, the context aware upcaster requires one to implement a buildContext function, which is used to instantiate a context which is carried between events going through the upcaster. The canUpcast and doUpcast functions receive the context as a second parameter, next to the IntermediateEventRepresentation. The context can then be used within the upcasting process to pull fields from earlier events and populate other events. It thus allows you to move a field from one event to a completely different event.

  • ContextAwareEventMultiUpcaster - a one-to-many implementation of an upcaster, which can store context of events during the process. This abstract implementation is a combination of the EventMultiUpcaster and ContextAwareSingleEventUpcaster, and thus services the goal of keeping context of IntermediateEventRepresentations and upcasting one such representation to several. This implementation is useful if you not only want to copy a field from one event to another, but have the requirement to generate several new events in the process.

  • EventTypeUpcaster - a full upcaster implementation dedicated to changing the event type. The EventTypeUpcaster is an implementation of the SingleEventUpcaster with predefined canUpcast and doUpcast functions to be able to change an event from one event type to another. This can be used to for example change the class or package name of an event with ease. To create an EventTypeUpcaster, it is recommended to use the EventTypeUpcaster#from(String expectedPayloadType, expectedRevision) and EventTypeUpcaster.Builder#to(upcastedPayloadType, upcastedRevision) methods.

What’s part of the context in a Context-Aware Upcasters?

A context-aware upcaster allows you to collect state from previous events. As upcasters work on a stream of events, all that can ever belong to the context is the state of that stream.

However, this "stream of events" is not identical at all times. For example, when an entity is event-sourced, the event stream consists of entity instance-specific events. Furthermore, the stream of events starts from the tracking token’s position for a streaming event processor. Hence, the context contains different states depending on what’s included in the event stream.

Writing an Upcaster

The following Java snippets will serve as a basic example of a one-to-one upcaster (the SingleEventUpcaster).

Old version of the event:

@Event(name = "Complaint", version = "1.0")
public record ComplaintEvent(
    String id,
    String companyName
) {
}

New version of the event:

@Event(name = "Complaint", version = "2.0")
public record ComplaintEvent(
    String id,
    String companyName,
    String description
) {
}

Upcaster from 1.0 revision to 2.0 revision:

  • Event with XStream

  • Event with Jackson

public class ComplaintEvent1_to_2Upcaster extends SingleEventUpcaster {

   private static final SimpleSerializedType TARGET_TYPE =
           new SimpleSerializedType(ComplaintEvent.class.getTypeName(), "1.0");

   @Override
   protected boolean canUpcast(IntermediateEventRepresentation intermediateRepresentation) {
      return intermediateRepresentation.getType().equals(TARGET_TYPE);
   }

   @Override
   protected IntermediateEventRepresentation doUpcast(
           IntermediateEventRepresentation intermediateRepresentation
   ) {
      return intermediateRepresentation.upcastPayload(
              new SimpleSerializedType(TARGET_TYPE.getName(), "2.0"),
              org.dom4j.Document.class,
              document -> {
                 document.getRootElement()
                         .addElement("description")
                         .setText("no complaint description"); // Default value
                 return document;
              }
      );
   }
}
public class ComplaintEvent1_to_2Upcaster extends SingleEventUpcaster {
   // upcaster implementation...

   private static final SimpleSerializedType TARGET_TYPE =
           new SimpleSerializedType(ComplaintEvent.class.getTypeName(), "1.0");

   @Override
   protected boolean canUpcast(IntermediateEventRepresentation intermediateRepresentation) {
      return intermediateRepresentation.getType().equals(TARGET_TYPE);
   }

   @Override
   protected IntermediateEventRepresentation doUpcast(
           IntermediateEventRepresentation intermediateRepresentation
   ) {
      return intermediateRepresentation.upcastPayload(
              new SimpleSerializedType(TARGET_TYPE.getName(), "2.0"),
              com.fasterxml.jackson.databind.JsonNode.class,
              event -> {
                  ((ObjectNode) event).put("description", "no complaint description");
                  return event;
              }
      );
   }
}

Configuring an Upcaster

After choosing an upcaster type and constructing your first instance, it is time to configure it in your application. Important in the configuration is knowing that upcasters need to be invoked in order. Events tend to move through several format iterations, each with its own upcasting requirements. Since an upcaster only adjusts an event from one version to another, it is paramount to maintain the ordering of the upcasters.

The component in charge of that ordering is the EventUpcasterChain. The upcaster chain is what the EventStore uses to attach all the upcast functions to the event stream. When configuring your upcasters, most scenarios will not require you to touch the EventUpcasterChain directly. Instead, consider the following snippets when it comes to registering upcasters:

  • Configuration API

  • Spring Boot with @Order annotation

  • Spring Boot with EventUpcasterChain bean

import org.axonframework.messaging.core.configuration.MessagingConfigurer;

public class AxonConfig {
    // omitting other configuration methods...
    public void configureUpcasters(MessagingConfigurer configurer) {
        // The method invocation order imposes the upcaster ordering
        configurer.registerEventUpcaster(config -> new ComplaintEvent0_to_1Upcaster())
                  .registerEventUpcaster(config -> new ComplaintEvent1_to_2Upcaster());
    }
}

Axon honors Spring’s Order annotation on upcasters. The numbers used in the annotation will dictate the ordering. The lower the number, the earlier it is registered to the upcaster chain:

@Component
@Order(0)
public class ComplaintEvent0_to_1Upcaster extends SingleEventUpcaster {
   // upcaster implementation...

}

@Component
@Order(1)
public class ComplaintEvent1_to_2Upcaster extends SingleEventUpcaster {
   // upcaster implementation...

}

The annotation can be placed both on the class itself, or on bean creation methods:

@Configuration
public class AxonConfig {
    // omitting other configuration methods...
    @Bean
    @Order(0)
    public SingleEventUpcaster complaintEventUpcasterOne() {
        return new ComplaintEvent0_to_1Upcaster();
    }

    @Bean
    @Order(1)
    public SingleEventUpcaster complaintEventUpcasterTwo() {
        return new ComplaintEvent0_to_1Upcaster();
    }
}

Adding an EventUpcasterChain bean to the Application Context will tell Axon to configure it for your event source:

@Configuration
public class AxonConfig {
    // omitting other configuration methods...
    @Bean
    public EventUpcasterChain eventUpcasterChain() {
        return new EventUpcasterChain(
                new ComplaintEvent0_to_1Upcaster(),
                new ComplaintEvent0_to_1Upcaster()
        );
    }
}

Upcasting snapshots

Although the description above leans towards event upcasting, note that all the given constructs can be used to upcast a snapshot.

From the abstract upcaster implementations shared earlier, the only feasible upcaster is the SingleEventUpcaster. We can logically deduce that a multi-upcaster would be incorrect, as only the last returned snapshot is used to load an entity. Furthermore, context-aware upcasters aren’t necessary either, as any context-specific information in a snapshot is generated during event sourcing. Hence, if any data should carry over, simply reconstruct the snapshot instead.

When upcasting events, the type to upcaster equals the fully qualified class name of the event itself. When upcasting snapshots, the type is the fully qualified class name of the entity. Knowing this detail, let’s look at an example of a snapshot upcaster for the GiftCard entity (as exemplified here):

public class GiftCardSnapshotNull_to_1Upcaster extends SingleEventUpcaster {

   private static final SimpleSerializedType TARGET_TYPE =
           new SimpleSerializedType(GiftCard.class.getTypeName(), null);

   @Override
   protected boolean canUpcast(IntermediateEventRepresentation intermediateRepresentation) {
      return intermediateRepresentation.getType().equals(TARGET_TYPE);
   }

   @Override
   protected IntermediateEventRepresentation doUpcast(
           IntermediateEventRepresentation intermediateRepresentation
   ) {
      return intermediateRepresentation.upcastPayload(
              new SimpleSerializedType(TARGET_TYPE.getName(), "1"),
              org.dom4j.Document.class,
                            com.fasterxml.jackson.databind.JsonNode.class,
              event -> {
                  ((ObjectNode) event).put("owner", "[owner-id]");
                  return event;
              }
      );
   }
}

Whenever constructing a snapshot upcaster, be mindful of SnapshotFilters. When using the @Event annotation’s version field on your Entity, a VersionSnapshotFilter may be constructed by default. This filter can prevent snapshot events from reaching your upcaster.

As such, it is advised to override the SnapshotFilter of an Entity to a no-op entry when you prefer to upcast your snapshots.