Migrating from Axon Framework 4 to 5: What We Learned

We run a CQRS/Event Sourcing backend — now happily on Java 25, Spring Boot 4, and Axon Framework 5. But getting here from Axon 4.12 was a journey. The codebase has dozens of aggregates, multiple sagas, over a hundred event types, and hundreds of annotated handler methods.
Here’s what we learned along the way.
Why We Wanted Axon 5
The motivation was simple: native reactive support in event handlers.
Axon 4.x has a well-known issue with subscribing event processors. When an @EventHandler returns a Mono<Void>, the framework never subscribes to it. The reactive chain simply doesn’t execute. We discovered this the hard way when projection handlers silently stopped writing to the database.
The workaround? Add .block() to every single event handler:
// Axon 4.x — the framework ignores the returned Mono
@EventHandler
public void on(ClimbLoggedEvent event) {
dlqService.withDLQ(...)
.subscribeOn(Schedulers.boundedElastic())
.timeout(Duration.ofSeconds(30))
.block(); // Without this, nothing happens
}We had hundreds of these .block() calls scattered across the codebase. Each one pins a thread in a reactive application. Axon 5.0 promised to fix this with an async-native architecture where the framework subscribes to returned reactive types automatically:
// Axon 5.0 — the framework subscribes for you
@EventHandler
public Mono<Void> on(ClimbLoggedEvent event) {
return dlqService.withDLQ(...);
}Beyond reactive support, Axon 5 also removes ThreadLocal usage (which conflicts with Project Reactor and virtual threads), simplifies configuration, and introduces Dynamic Consistency Boundaries.
The First Attempt: Thousands of Compilation Errors
We created a feature branch and bumped the dependencies. The first surprise: Axon 5 uses a different module structure.
// Axon 4.x
implementation(platform("org.axonframework:axon-bom:4.12.2"))
implementation("org.axonframework:axon-spring-boot-starter")
// Axon 5.0
implementation(platform("org.axonframework:axon-framework-bom:5.0.1"))
implementation("org.axonframework.extensions.spring:axon-spring-boot-starter")
implementation("org.axonframework.extensions.tracing:axon-tracing-opentelemetry")The Spring modules moved to org.axonframework.extensions.spring, tracing to org.axonframework.extensions.tracing. Fair enough.
Then we compiled. Thousands of errors.
This isn’t a typical major-version bump with a few deprecations. The entire API has been redesigned. Every package you import from Axon has moved or been removed:
| What you used in Axon 4 | Status in Axon 5 |
|---|---|
org.axonframework.modelling.command.* | Removed |
org.axonframework.commandhandling.* | Removed |
org.axonframework.queryhandling.* | Removed |
org.axonframework.eventhandling.* | Removed |
@Aggregate / @AggregateIdentifier | Replaced by @EventSourced(idType=...) |
@Revision("1.0") | Replaced by @Event(version = "1.0") |
AggregateLifecycle.apply(event) | Replaced by EventAppender.append(event) |
Constructor @CommandHandler | Replaced by static method pattern |
UnitOfWork | Replaced by ProcessingContext |
MetaData | Renamed to Metadata (lowercase ’d') |
ResponseType<R> | Removed — use Class<R> directly |
ReactorQueryGateway (extension) | Native async in core |
This isn’t incremental. It’s a rewrite of the developer-facing API.
The Migration: Phase by Phase
We tracked progress in phases and wrote shell scripts to automate the mechanical changes. Here’s how it went.
Phase 1: Package Relocations
The bulk of the compilation errors were import statement changes. Command handling, query handling, and event handling classes all moved under org.axonframework.messaging.*. This is tedious but automatable:
# Example from our migration scripts
find . -name "*.java" -exec sed -i '' \
's/org.axonframework.commandhandling/org.axonframework.messaging.commandhandling/g' {} +Phase 2: Annotation Changes
@Aggregate became @EventSourced with a required idType attribute. Every aggregate class needs updating, and you may need to create typed ID classes that didn’t exist before:
// Axon 4
@Aggregate
public class ClubAggregate {
@AggregateIdentifier
private String id;
}
// Axon 5
@EventSourced(idType = String.class)
@EventSourcedEntity(tagKey = "clubId")
public class ClubAggregate {
private String id;
@EntityCreator
protected ClubAggregate() {
this.members = new HashMap<>(); // Must init collections!
}
}A subtle gotcha: if your aggregate has Map or Collection fields with @Builder.Default, they must also be initialized in the @EntityCreator no-args constructor. Axon uses reflection to create instances during event replay, bypassing Lombok’s builder.
Phase 3: Command Handler Pattern
Constructor-based command handlers are gone. Aggregate creation now uses static factory methods with an explicit EventAppender:
// Axon 4
@CommandHandler
public ClubAggregate(CreateClubCommand cmd) {
AggregateLifecycle.apply(new ClubCreatedEvent(...));
}
// Axon 5
@CommandHandler
public static void create(CreateClubCommand cmd, EventAppender appender) {
appender.append(new ClubCreatedEvent(...));
}This is a cleaner pattern — no more magic through the static AggregateLifecycle threadlocal — but it touches every aggregate’s creation path.
Phase 4: Event Versioning
@Revision is gone, replaced by @Event:
// Axon 4
@Revision("1.0")
public record ClimbLoggedEvent(...) {}
// Axon 5
@Event(version = "1.0")
public record ClimbLoggedEvent(...) {}Worth noting: upcasters are not available in Axon 5.0 — they’re planned for 5.2.0. If you rely on event upcasting, plan accordingly.
Phase 5: Sagas to Stateful Event Handlers
This was the biggest architectural shift. Axon 5 moves away from the traditional saga model. While the @Saga annotations technically still exist in a moved package, the recommended pattern is to replace sagas with stateful event handlers backed by database persistence.
We deprecated all of our sagas and rewrote them as plain Spring @Component classes with @EventHandler methods. State that was previously managed by Axon’s saga infrastructure is now explicitly persisted in the database via a ProcessStateService:
@Slf4j
@Component
@RequiredArgsConstructor
public class SiteDataImportEventHandler {
private static final String PROCESS_TYPE = "SITE_DATA_IMPORT";
private final SiteBatchImportService siteBatchImportService;
private final ProcessStateService processStateService;
@EventHandler
public Mono<Void> on(SiteDataImportRequestedEvent event) {
Map<String, Object> metadata = Map.of(
"siteId", event.siteId().value(),
"format", event.format()
);
return processStateService
.createProcess(PROCESS_TYPE, event.importId(), event.requestedBy().value(), 1, metadata)
.doOnSuccess(process ->
siteBatchImportService.processSiteImportAsync(
event.importId(), event.siteId().value(),
event.requestedBy().value(), event.fileContent(), event.format()))
.then();
}
@EventHandler
public Mono<Void> on(SiteBatchProcessedEvent event) {
return processStateService.getProcess(PROCESS_TYPE, event.importId())
.flatMap(process -> processStateService.markItemCompleted(PROCESS_TYPE, event.importId()))
.then();
}
@EventHandler
public Mono<Void> on(SiteDataImportCompletedEvent event) {
return processStateService.getProcess(PROCESS_TYPE, event.importId())
.flatMap(process -> processStateService.deleteProcess(PROCESS_TYPE, event.importId()))
.then();
}
}Why this is better than sagas:
- State is explicit and queryable. It lives in your database, not buried in Axon’s serialized saga store. You can query process state, build admin dashboards, and debug issues directly.
- Fully reactive. Handlers return
Mono<Void>and the framework subscribes natively — no.block()hacks. - Simpler testing. No need for saga test fixtures. It’s a regular Spring component that you can unit test with mocked dependencies.
- No saga serialization issues. Sagas serialize their entire state into Axon’s token store. Change a field type and you’re dealing with deserialization failures on replay. Database-backed state doesn’t have this problem.
The migration pattern is straightforward: for each saga, create a @Component event handler class, move the logic from @SagaEventHandler methods to @EventHandler methods, and replace SagaLifecycle.associateWith() / end() with explicit database state management.
Phase 6: Event Tagging for State Reconstruction
This was the most surprising required change. Axon 5 needs @EventTag annotations on events for the framework to route events to the correct entity during replay:
public record ClubCreatedEvent(
@EventTag(key = "clubId") String clubId,
String name
) {}And the aggregate must declare which tag key it uses:
@EventSourced(idType = String.class)
@EventSourcedEntity(tagKey = "clubId")
public class ClubAggregate { ... }Without this, given().events(...) in tests silently fails to reconstruct state. It took us a while to figure out why test fixtures weren’t replaying events.
Phase 7: Test Fixture Overhaul
The test API changed significantly:
// Axon 4
fixture.givenNoPriorActivity()
.when(command)
.expectEvents(expectedEvent)
// Axon 5
fixture.given().noPriorActivity()
.when().command(command)
.then().success().events(expectedEvent)Other test changes: CommandGateway.send() now returns CommandResult instead of CompletableFuture, EventGateway.publish() takes a List<?>, and queryMany() replaces ResponseTypes.multipleInstancesOf().
The new AxonTestFixture also requires explicit fixture.stop() in cleanup — without it you’ll leak resources across tests.
The Result
We completed the migration and now run Axon 5 in production. The .block() calls are gone, event handlers are truly reactive, and the saga-to-stateful-handler refactoring actually improved our architecture by making process state explicit and queryable.
The migration was substantial — we wrote shell scripts to automate the mechanical parts (package renames, annotation swaps) and tackled the architectural changes (sagas, command handler pattern, event tagging) one module at a time.
What We’d Recommend
If you’re considering the same migration:
- Automate the mechanical work. Package relocations and annotation renames account for the majority of compilation errors but are trivially scriptable. Don’t do these by hand.
- Tackle sagas early. The move from sagas to stateful event handlers is the biggest architectural change. It’s also the most valuable — you’ll end up with a cleaner, more testable design. Start with a simple saga as a proof of concept.
- Don’t forget
@EventTag. This is underdocumented and will silently break your aggregate test fixtures. Every event needs the tag on its entity ID field, and every aggregate needs@EventSourcedEntity(tagKey = ...). - Plan for the upcaster gap. Upcasters are not available until Axon 5.2.0. If your event store has events that require upcasting, you’ll need a strategy for this gap.
- Consider waiting for OpenRewrite recipes if your codebase is large. Axon 5.1 is expected to ship with OpenRewrite recipes that automate the bulk of the transformation. For smaller codebases, manual migration with scripts is manageable.
Quick Reference: Key API Changes
| Concept | Axon 4 | Axon 5 |
|---|---|---|
| Aggregate annotation | @Aggregate | @EventSourced(idType = X.class) |
| Aggregate ID | @AggregateIdentifier | Set in @EventSourcingHandler |
| Creation handler | Constructor @CommandHandler | Static method + EventAppender |
| Apply events | AggregateLifecycle.apply() | EventAppender.append() |
| Event versioning | @Revision("1.0") | @Event(version = "1.0") |
| Event routing | Automatic | @EventTag + @EventSourcedEntity(tagKey) |
| Metadata | MetaData | Metadata (lowercase ’d') |
| Response types | ResponseType<R> | Class<R> |
| Query for lists | ResponseTypes.multipleInstancesOf() | queryMany() |
| Message context | UnitOfWork | ProcessingContext |
| Reactive gateway | Extension module | Native in core |
| Sagas | @Saga + @SagaEventHandler | Stateful @Component + @EventHandler |
| Reconstitution | Default constructor | @EntityCreator no-args constructor |
| Test fixture setup | new AggregateTestFixture<>(X.class) | AxonTestFixture.with(configurer, ...) |
| Test given/when/then | fixture.given().when().expect() | fixture.given().when().command().then() |
