Saga Migration

Axon Framework 5 has no Saga construct. Replace it with existing core framework building blocks. The familiar @Saga stereotype and related APIs are not part of the Axon Framework 5 core; a legacy layer allows migrating running sagas, and the same applies to DeadlineManager and related APIs.

For new processes, Axon Framework 5 provides several ways to implement them with the existing framework building blocks. The Sagas and Process Managers guide describes them in detail.

For sagas that are already running (considering business processes running for months), rewriting is not an option. That case is served by the axon-legacy module, described in Running existing sagas on Axon Framework 5.

For saga migration you need to consider two scenarios:

Writing new processes: Pick an approach from the Sagas and Process Managers guide and write it today.

Migrating running sagas: Use the Axon Framework 4 compatible APIs of the axon-legacy module to keep existing saga classes running until all legacy saga instances are drained naturally. The legacy module should be treated as a way to finish existing instances, not to keep sagas in Axon Framework 5.

Running existing sagas on Axon Framework 5

The axon-legacy module allows migrating to Axon Framework 5 while saga instances started on Axon Framework 4 are still running. Business processes covered by sagas can span weeks or months. axon-legacy lets running saga instances continue while new instances start on an Axon Framework 5 native implementation of the business process.

The axon-legacy module brings forward the following Axon Framework 4 saga infrastructure, adapted to the Axon Framework 5 APIs:

  • @SagaEventHandler, @StartSaga, @EndSaga, SagaLifecycle and the association-value mechanism

  • SagaStore and its concrete implementations, to support existing saga stores

The org.axonframework.spring.stereotype.Saga annotation itself keeps its Axon Framework 4 package. Spring Boot autoconfiguration for legacy sagas activates only when both axon-legacy and axon-spring-boot-starter are on the classpath; see Configuring legacy sagas with Spring Boot.

The legacy module supports existing saga classes as-is. Add axon-legacy as a dependency to your project.

The saga side of axon-legacy (GitHub Issue #3728), including Spring Boot autoconfiguration, targets 5.4.0. Deadlines (GitHub Issue #3065 and GitHub Issue #5006) are not yet ported: DeadlineManager and @DeadlineHandler remain outstanding. Until they land, plan on the code-level migration below and on the drainage strategy in Migrating an application with running sagas for the deadline part of a saga.

The axon-legacy module is a migration aid, not a supported way to write new processes. @StartSaga is deprecated. Use the legacy module to let existing instances receive their events and run to completion while new work goes to the Axon Framework 5 implementation written alongside it. A ported @StartSaga handler can still create instances until you remove or disable that annotation as part of the drainage strategy.

Configuring legacy sagas with Spring Boot

Add axon-legacy next to axon-spring-boot-starter as a dependency. Autoconfiguration for legacy sagas activates once axon-legacy is on the classpath; no further setup is needed for a @Saga-annotated, component-scanned class to start receiving events.

Processor assignment

Each @Saga class gets its own pooled streaming event processor, named <SimpleName>Processor (for example, OrderSagaProcessor for OrderSaga). The processor starts at the head of the stream (the latest token), so a saga ignores events that occurred before the processor first starts, matching the Axon Framework 4 TrackingEventProcessor default for sagas.

Tuning a saga’s processor does not change which events it sees. The head token survives every axon.eventhandling.processors.<name>.* entry and any EventProcessorDefinition that does not set an initial token of its own.

To make a saga process the stream from the start, set the initial token explicitly with a SagaProcessorDefinition:

@Bean
SagaProcessorDefinition replayIntoOrderSaga() {
    return SagaProcessorDefinition.forSaga(OrderSaga.class)
                                  .whenPooledStreaming(config -> config.initialToken(
                                          source -> source.firstToken(null)
                                  ));
}

Every axon.eventhandling.processors.<name>.* property (mode, source, token-claim-interval, thread-count, batch-size, and token-store) applies to a saga’s processor as it applies to any other processor. A pooled saga processor needs a TokenStore bean, as does any other pooled processor.

initial-segment-count is the exception. A saga’s processor starts with a single segment, and the property does not raise it; raise the count with a SagaProcessorDefinition, see Configuring a saga’s processor. Segments do not split the stream: every segment reads every event and keeps only the sagas it owns. A higher count lets several threads process sagas concurrently within an application instance.

mode=subscribing switches the saga to a subscribing processor, which has no segments and no token store. Every application instance then starts its own instance of the same saga. Keep sagas on a pooled streaming processor in a multi-instance deployment.

Processor names derived from a saga’s simple name are mixed-case (for example, SimpleSagaProcessor). Relaxed property binding lowercases a dotted key and silently fails to match, so target the processor with bracket notation instead:

axon.eventhandling.processors[SimpleSagaProcessor].mode=subscribing

@Namespace on the saga type replaces @ProcessingGroup: it overrides the derived processor name. Two saga classes carrying the same @Namespace value (or deriving the same default name) share one processor, reproducing the Axon Framework 4 behavior of co-locating sagas in one processing group.

Keep the original processor name, or in-flight sagas lose events.

@ProcessingGroup does not exist in Axon Framework 5, so a saga carrying it no longer compiles. Removing the annotation renames the processor to <SimpleName>Processor. Token stores are keyed on the processor name, so the existing token no longer matches, the renamed processor starts at the head of the stream, and every event published before that token was written is never delivered to the sagas still running.

Replace @ProcessingGroup("orders") with @Namespace("orders"), keeping the value, so the existing token is found:

// Axon Framework 4: @Saga @ProcessingGroup("orders")
@Saga
@Namespace("orders")
public class OrderSaga {

    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void on(OrderPlaced event) {
        // The processor stays named "orders", so its Axon Framework 4 token is found and resumed.
    }
}

This applies to any saga whose processor was not already named <SimpleName>Processor, including one named through assignProcessingGroup or a hand-registered SagaConfiguration.

Processor settings and EventProcessorDefinition beans apply to a saga’s processor as they apply to any other, and an EventProcessorDefinition selector can assign a saga to its named processor.

Configuring a saga’s processor

Declare a SagaProcessorDefinition bean. It runs after the saga defaults and the processor properties, so it has the last word on the processor’s configuration:

@Bean
SagaProcessorDefinition orderSagaSegments() {
    return SagaProcessorDefinition.forSaga(OrderSaga.class)
                                  .whenPooledStreaming(config -> config.initialSegmentCount(4));
}

Choose the processor in one of two ways:

forSaga(OrderSaga.class)

the processor carrying that saga, wherever it ended up, including a @Namespace-renamed one. The saga type picks the processor, it does not narrow the customization to that saga, so when sagas share a processor this configures all of them. A log message names the others.

forProcessor("orders")

the processor with that name. Use it for a processor several sagas share.

// Both OrderSaga and ShipmentSaga carry @Namespace("orders"), so they share one processor.
@Bean
SagaProcessorDefinition ordersProcessor() {
    return SagaProcessorDefinition.forProcessor("orders")
                                  .whenPooledStreaming(config -> config.batchSize(50));
}

Then choose how much of the configuration to reach:

customized(…​)

the settings both processor modes share: the error handler, the unit of work factory, and extensions. Applies whatever mode the processor runs in.

whenPooledStreaming(…​)

the PooledStreamingEventProcessorConfiguration, with the initial token and segment count. Applies only while the processor runs in pooled streaming mode.

whenSubscribing(…​)

the SubscribingEventProcessorConfiguration, with its eventSource and ignoredMessageHandler. Applies only while the processor runs in subscribing mode.

// Applies only when axon.eventhandling.processors[OrderSagaProcessor].mode=subscribing.
@Bean
SagaProcessorDefinition orderSagaEventSource(SubscribableEventSource eventSource) {
    return SagaProcessorDefinition.forSaga(OrderSaga.class)
                                  .whenSubscribing(config -> config.eventSource(eventSource));
}

Naming a mode does not switch the processor to it. The mode comes from axon.eventhandling.processors.<name>.mode or a matching EventProcessorDefinition; a definition written for the other mode is skipped and logged.

PooledStreamingEventProcessorModule.Customization beans do not apply to a saga’s processor. SagaProcessorDefinition replaces them.

An EventProcessorDefinition naming the processor does apply. Prefer SagaProcessorDefinition for a saga: an EventProcessorDefinition also fixes the processor’s mode, so one written to set a batch size overrules mode=subscribing on that processor.

A saga and an ordinary event handler cannot share a processor. When both resolve to the same name, startup fails with a DuplicateModuleRegistrationException. To put them on one processor on purpose, drop @Saga from the class and register a Module bean combining a declarative saga component with autodetected handlers.

Saga store selection

A saga’s store is resolved in this order:

  1. A user-defined SagaStore bean.

  2. A JpaSagaStore (bean name sagaStore), when an EntityManagerFactory bean is present. SagaEntry and AssociationValueEntry are registered with the persistence unit automatically.

  3. A JdbcSagaStore (bean name sagaStoreNoSchema or sagaStoreWithSchema), when a DataSource bean is present. A user-declared SagaSqlSchema bean selects the schema-aware variant.

  4. An InMemorySagaStore (bean name sagaStore), as the final fallback.

@Saga(sagaStore = "beanName") overrides the store for one saga type, resolving the named bean instead of the one in the chain above.

Suppressing autoconfiguration for one saga

Discovery registers a SpringSagaDescriptor bean named <sagaBeanName>$$Registrar for every @Saga bean. Declaring a bean under that name yourself keeps discovery from configuring that saga.

If you want to opt out a specific saga from @Saga discovery to manually configure it, drop the annotation and register it through a Module bean, see Behavior changes from Axon Framework 4.

If a <sagaBeanName>$$Registrar bean already exists when discovery reaches it, discovery aborts for all sagas processed after that point in bean-definition order. This is a known Axon Framework 4 behaviour that has been ported unchanged to the legacy module.

Behavior changes from Axon Framework 4

  • Pooled streaming replaces the tracking processor. A saga’s processor is pooled streaming, not a TrackingEventProcessor, and needs a TokenStore bean as any pooled processor does. A saga still only sees events published after its processor first started; replaying into one requires setting an initial token explicitly, see Configuring legacy sagas with Spring Boot.

  • No resource injection. ResourceInjector and SpringResourceInjector are not ported. A saga’s collaborators arrive as handler method parameters instead, resolved the same way as for any other event handler, including Spring beans.

  • No @ProcessingGroup. @Namespace on the saga type renames or co-locates a saga’s processor. It co-locates sagas with each other only: a saga and an ordinary event handler resolving to one processor name fail at startup. See Configuring legacy sagas with Spring Boot for the workaround.

  • Dead-letter queues are not supported for sagas. Enabling a dead letter queue for a saga’s processor by name has no effect.

  • Deadlines are not ported yet (see the note in Running existing sagas on Axon Framework 5).

  • The in-memory saga store is an explicit, overridable bean, not an internal default. An application that wants a default store alongside named per-saga stores declares the default store itself.

  • A @Saga class must be public with an accessible no-argument constructor.

  • Component-scan saga classes, or declare them with a @Scope("prototype") annotated @Bean method. A plain @Bean method ignores the @Scope("prototype") that @Saga declares and requires to avoid the saga being registered additionally as a plain singleton event handling component bean. Annotate the bean method with @Scope("prototype") if the saga cannot be component-scanned.

Mapping Axon Framework 4 saga concepts to Axon Framework 5

An Axon Framework 4 saga provides opinionated technical infrastructure to implement long-running business processes:

  • State is state-sourced: the saga instance is serialized into a SagaStore as an opaque blob

  • Correlation requires association values, maintained with SagaLifecycle.associateWith(…​).

  • Life cycle is framework-managed, through @StartSaga and @EndSaga.

Axon Framework 5 provides several ways to implement long-running processes, described in Sagas and Process Managers. The following table maps Axon Framework 4 saga concepts to their Axon Framework 5 equivalents.

Axon Framework 4 Axon Framework 5

@Saga

A @Workflow (see Workflows), or an ordinary component with @EventHandler methods.

@SagaEventHandler(associationProperty = "…​")

awaitEvent(…​) in combination with associate(…​) for a Workflow, or a plain @EventHandler resolving correlation by the entity identifier, a property, or your own EntityIdResolver.

@StartSaga

@Workflow(startOnEvent = "…​") for a Workflow. For a plain @EventHandler approach, the first event that concerns a process creates its state.

@EndSaga

The end of the workflow definition for a Workflow. For a plain @EventHandler, depending on the approach: delete the row, append a completion event, or do nothing when state is derived.

SagaLifecycle.associateWith(…​)

awaitEvent(…​) in combination with associate(…​) for a Workflow, a tag on the event, or a derived correlation value.

SagaLifecycle.end()

a return statement in the workflow definition for a Workflow, or whatever the chosen approach records as "finished".

SagaStore, SagaRepository

Your regular event store for a Workflow, a repository of your own, an event-sourced entity, or nothing.

DeadlineManager, @DeadlineHandler

sleep(…​) for a Workflow, or a projection of outstanding work plus a scheduled sweep that sends a command. See deadlines.

SagaTestFixture

The dedicated Workflows Test infrastructure, or AxonTestFixture publishing events in the given phase and asserting on dispatched commands for a plain @EventHandler approach.

Replacing a saga with a workflow

The recommended replacement for sagas is Workflows: a process is written as a single method that reads top to bottom, using the regular event store for event-sourced persistence.

Workflows are an Axoniq Framework feature and require an Axoniq license to run in production. Workflows are currently available as a preview and can be tried out without a license.

Saga concepts map onto Workflow concepts:

  • The association value becomes the workflow’s identifier and manual associate(…​) invocations when waiting on events.

  • Each @SagaEventHandler becomes a step.

  • @EndSaga becomes the end of the workflow definition method.

  • Deadlines are expressed as explicit wait or timeout on an event in the workflow.

Workflows differ from Axon Framework 4 sagas in how they hold state: they source it from dedicated workflow events in the regular event store instead of a persistent SagaStore blob. A running saga holding a serialized instance in a SagaStore cannot be handed to a workflow. It must drain as described in Migrating an application with running sagas, whichever replacement you pick.

Migrating deadlines

Axon Framework 4 scheduled deadlines to defer reactions for sagas. Axon Framework 5 has no built-in deadline scheduling. Use other capabilities to get the same effect.

In Axon Framework 4, DeadlineManager schedules a deadline to trigger in the future; @DeadlineHandler handles the trigger, typically by sending a command.

In Axon Framework 5, use a scheduling library of your choice: wire in the CommandGateway, and send the command through the gateway when the scheduled task triggers. An idempotent command handler ignores an action scheduled for a process that has already progressed.

See deadlines for details on deadlines for long-running processes in Axon Framework 5.

Choosing a replacement

Not every saga should become a workflow. Simple processes consisting of a limited set of independent reactions can use regular Axon Framework 5 event and command handlers. The guide compares the alternatives in detail:

  • State in a repository is the closest to how an Axon Framework 4 saga worked. Start here if you are porting an existing saga.

  • State from context events avoids persistent state in a repository. This is only possible if all involved contexts write to one event store and their events carry a tag you can select on.

  • State from process events suits integration with systems whose events are not yours, and gives an audit trail of the process.

  • Vertical slices break the process into independent reactions. This fits Vertical Slice Architecture and Event Modelling.

Migrating an application with running sagas

Whether or not you use the legacy module, migrate running saga instances by drainage: let saga instances created by Axon Framework 4 finish normally, while new process instances start on an Axon Framework 5 implementation.

Without the legacy module, drain old saga instances in an Axon Framework 4 deployment kept alive alongside the Axon Framework 5 application running the migrated process. The legacy module allows you to drain the old saga instances alongside the upgraded application in a single Axon Framework 5 application. Either way (using the legacy-module for drainage or not), new sagas must not be started from the Axon Framework 4 legacy code.

The main steps for migrating running sagas:

  1. Stop starting new sagas. Remove the @StartSaga annotation from the Axon Framework 4 saga. Existing instances keep receiving their events and run to completion; no new ones are created.

    @Saga
    public class PaymentSaga {
    
        // @StartSaga  (1)
        @SagaEventHandler(associationProperty = "bikeId")
        public void on(BikeRequestedEvent event) {
            // ...
        }
    }
    1 Removing this annotation stops new instances from starting; existing instances can still advance and end.
  2. Keep the old saga running. With the legacy module, add the dependency and let the existing class continue against its existing saga store in the upgraded application. Without it, keep the Axon Framework 4 deployment alive to drain existing saga instances without creating new ones.

  3. Implement the process in Axon Framework 5 using one of the approaches above, and let it handle the same trigger event.

  4. Wait for the old instances to drain. Monitor the SagaStore for active instances and watch the count fall to zero. How long that takes depends on your longest-running process.

  5. Retire the Axon Framework 4 saga once there are no more active instances in the SagaStore: remove the legacy saga code, the SagaStore table, its deadline scheduler, and the legacy dependency.

Scheduled deadlines drain the same way: keep the Axon Framework 4 DeadlineManager, or the legacy module’s port of it, online as long as saga instances still rely on it for deadlines.

Testing

A saga kept running on the legacy module keeps its existing tests. Write new tests for the process migrated to Axon Framework 5 with AxonTestFixture: publish events that drive the process and assert on the commands it sends.

The mapping below covers the common cases:

SagaTestFixture AxonTestFixture

givenNoPriorActivity()

given().noPriorActivity()

givenAPublished(event) / andThenAPublished(event)

given().events(event, …​)

whenPublishingA(event)

when().event(event), or include it in given().events(…​) and assert with await

expectDispatchedCommands(command)

then().await(result → result.commandsSatisfy(…​))

expectActiveSagas(n)

No equivalent. Assert on the observable outcome, or on your own state if the approach keeps any.

whenTimeElapses(duration)

No equivalent. Structure the timeout so it can be invoked directly; see deadlines.

A working example

examples/saga-recipes in the Axon Framework repository implements the bike-rental payment saga from an Axon Framework 4 sample application, using every approach described here.