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 axoniq-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 axoniq-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 axoniq-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. axoniq-legacy lets running saga instances continue while new instances start on an Axon Framework 5 native implementation of the business process.

The axoniq-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

  • SagaConfigurer, with its Axon Framework 4 package and fluent manager, repository, and store configuration hooks

An existing JpaSagaStore, JdbcSagaStore, or MongoSagaStore reads unchanged only when the Axon Framework 4 node’s Serializer was Jackson. Many applications never configured one, which then defaulted to XStreamSerializer, so the serialized saga column holds XStream XML instead. Configure such a store with an XStreamConverter to keep reading, and writing, that data; see XStreamConverter in the Conversion page.

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

Add axoniq-legacy as a dependency to the project. The legacy module preserves the Saga annotations, association values, and SagaStore contract. Saga classes require the following source changes:

  • Replace static SagaLifecycle calls with calls on a SagaLifecycle parameter of the @SagaEventHandler method.

  • Replace injected CommandGateway fields with a CommandDispatcher parameter of the @SagaEventHandler method.

  • Move dependencies stored in @Inject or @Autowired fields to the @SagaEventHandler methods that use them.

Axon Framework 5 does not inject dependencies into fields of legacy Saga instances. If a Saga currently keeps a service or another collaborator in an @Inject or @Autowired field, add that dependency as a parameter to each @SagaEventHandler method that needs it, then remove the field. Axon resolves the parameter when it invokes the handler.

Synchronous event handlers

An @SagaEventHandler in axoniq-legacy must complete on the thread that invoked it. Do not return CompletableFuture, Mono, Publisher, or another asynchronous result type from a legacy Saga event handler.

Axon Framework 4 invoked Saga event handlers synchronously and ignored their return values. The legacy module keeps that contract so the SagaStore update runs in the invoking thread’s transaction. A handler that returns incomplete asynchronous work fails with a SagaExecutionException.

Keep existing void handlers synchronous. Use CommandDispatcher.send(…​) for an existing fire-and-forget CommandGateway.send(…​) call. For an existing sendAndWait(…​) call, wait for the command result before the handler returns.

The axoniq-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 axoniq-legacy next to axon-spring-boot-starter as a dependency. Autoconfiguration for legacy sagas activates once axoniq-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 SagaConfigurer.forType(…​) 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

  • SagaConfigurer is an Axon Framework 5 component builder. forType, configureSagaManager, configureRepository, and configureSagaStore keep their Axon Framework 4 names, fluent return types, and override precedence, but their builder functions now receive the Axon Framework 5 org.axonframework.common.configuration.Configuration. Register the configurer as a declarative event handling component; where Axon Framework 4 called initialize(Configuration), the framework now calls build(Configuration), which returns the Saga manager itself. SagaConfiguration and its manager, repository, store, processing-group, processor, and error-handler accessors are not ported. Configure processor assignment and error handling on the EventProcessorModule instead. configureSagaFactory has no Axon Framework 4 equivalent; use it when the Saga has no accessible no-argument constructor.

  • 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.

  • A SagaStore is mandatory. Axon Framework 4 quietly fell back to an InMemorySagaStore when an application registered no SagaStore, so a misconfigured application started and stored its sagas in memory, losing them on restart. Axon Framework 5 refuses instead: with no SagaStore available, SagaConfigurer fails to build its Saga manager and throws an AxonConfigurationException naming the saga type. Spring Boot applications are unaffected in practice, because Saga store selection ends in an InMemorySagaStore bean; the change is visible to applications configured without Spring, which must now register a SagaStore component explicitly. The in-memory store is therefore an explicit, overridable bean rather than an internal default, and an application that wants a default store alongside named per-saga stores declares that default 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.

  • Saga deadlines need no wiring. The legacy module registers the ScopeAwareProvider a deadline manager delivers deadlines through, and every saga manager registers itself with it. A deadline manager you define yourself takes that provider: autowire the ScopeAwareProvider bean in Spring, or call configuration.getComponent(ScopeAwareProvider.class) otherwise. Axon Framework 4’s ConfigurationScopeAwareProvider is not ported.

  • A deadline that fires before the application has started waits for it. A scheduler that is already running fires overdue deadlines immediately, before the event processors have built the saga managers. Such a deadline waits until the application has started, at most 30 seconds, and fails afterwards so that its deadline manager retries it. Set axon.deadline.scope-aware-provider-readiness-timeout to change the timeout, or register a ScopeAwareProviderSettings component without Spring. To avoid the wait, start the scheduler after the application: with db-scheduler’s Spring Boot starter by setting db-scheduler.delay-startup-until-context-ready=true, and without Spring by starting it after configuration.start().

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.

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. Add the axoniq-legacy-test dependency, in test scope, for the Axon Framework 4 SagaTestFixture. Its deadline support works as before: a saga handler’s DeadlineManager parameter resolves to the fixture’s StubDeadlineManager, and givenCurrentTime sets the fixture’s time. andThenTimeElapses, andThenTimeAdvancesTo, whenTimeElapses and whenTimeAdvancesTo fire every deadline due by then in the saga’s @DeadlineHandler. The expectScheduledDeadline*, expectNoScheduledDeadline* and expectTriggeredDeadlines* assertions and the deadline interceptor registrations are available too. A suite needs the following changes:

  • Stop the fixture after each test with fixture.stop(), as it runs a started configuration.

  • Write deadline interceptors against the Axon Framework 5 interceptor signatures, which take a ProcessingContext and an interceptor chain.

  • Leave out the scheduled event assertions, such as expectNoScheduledEvents(). The event scheduler is not part of the legacy module, so these assertions fail with an UnsupportedOperationException.

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.