CQRS and Event Sourcing with Axon Framework
CQRS event sourcing is an architectural pattern that separates read and write operations while storing state changes as an immutable sequence of events. When combined with Axon Framework, Java teams get a battle-tested implementation that handles command routing, event storage, saga orchestration, and read model projections out of the box. As a result, you spend your effort on domain logic rather than on plumbing.
This guide provides a complete production implementation covering domain modeling, event store configuration, read model projections, schema evolution, and deployment strategies. By the end, you will have a working understanding of how to build event-sourced applications that scale independently on the read and write sides. Moreover, you will know when the pattern earns its keep and when it simply adds friction.
Understanding the Architecture
In a traditional CRUD application, a single model handles both reads and writes. Consequently, this creates contention as read optimization (denormalization, caching) conflicts with write optimization (normalization, consistency). CQRS solves this by splitting into separate models. The command model focuses purely on protecting invariants, while the query model focuses purely on serving fast, shaped responses.
Event sourcing complements CQRS by storing every state change as an event rather than overwriting current state. Moreover, this gives you a complete audit trail, time-travel debugging, and the ability to rebuild read models from the event stream. Instead of asking “what is the current balance?”, you ask “what sequence of deposits and withdrawals produced this balance?” — and you can answer that question for any point in history.
CQRS + Event Sourcing Flow
Command Side: Query Side:
┌──────────┐ ┌──────────────┐
│ Command │──▶ Aggregate │ Read Model │
│ Handler │ (validates) │ (optimized) │
└──────────┘ │ └──────────────┘
▼ ▲
┌─────────┐ │
│ Event │──────────────┘
│ Store │ (projections update
└─────────┘ read models)
Setting Up Axon Framework
Axon Framework provides Spring Boot auto-configuration. Add the dependencies and you get command bus, event bus, and event store wired automatically. Subsequently, you point the application at Axon Server (the dedicated event store and message router) or, alternatively, configure a JPA/JDBC event store backed by your existing relational database:
<dependencies>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.9.3</version>
</dependency>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-server-connector</artifactId>
<version>4.9.3</version>
</dependency>
</dependencies>
Domain Modeling with Aggregates
An aggregate is the consistency boundary of your write model. Notably, the same instance methods serve two distinct phases: command handlers decide whether something is allowed and emit events, while event sourcing handlers apply those events to mutate in-memory state. Critically, you never mutate state directly inside a command handler — you apply an event, and the event sourcing handler reacts. This separation is what makes replay deterministic.
@Aggregate
public class OrderAggregate {
@AggregateIdentifier
private String orderId;
private OrderStatus status;
private List<OrderLine> orderLines;
private BigDecimal totalAmount;
// Required no-arg constructor for Axon
protected OrderAggregate() {}
@CommandHandler
public OrderAggregate(CreateOrderCommand cmd) {
// Validate business rules before producing events
if (cmd.getOrderLines().isEmpty()) {
throw new IllegalArgumentException("Order must have items");
}
BigDecimal total = cmd.getOrderLines().stream()
.map(line -> line.getPrice().multiply(
BigDecimal.valueOf(line.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
AggregateLifecycle.apply(new OrderCreatedEvent(
cmd.getOrderId(), cmd.getCustomerId(),
cmd.getOrderLines(), total
));
}
@CommandHandler
public void handle(ConfirmOrderCommand cmd) {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException(
"Can only confirm pending orders");
}
AggregateLifecycle.apply(
new OrderConfirmedEvent(orderId));
}
@CommandHandler
public void handle(CancelOrderCommand cmd) {
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel shipped orders");
}
AggregateLifecycle.apply(
new OrderCancelledEvent(orderId, cmd.getReason()));
}
@EventSourcingHandler
public void on(OrderCreatedEvent event) {
this.orderId = event.getOrderId();
this.status = OrderStatus.PENDING;
this.orderLines = event.getOrderLines();
this.totalAmount = event.getTotalAmount();
}
@EventSourcingHandler
public void on(OrderConfirmedEvent event) {
this.status = OrderStatus.CONFIRMED;
}
@EventSourcingHandler
public void on(OrderCancelledEvent event) {
this.status = OrderStatus.CANCELLED;
}
}
Optimistic Concurrency and Snapshots
Because an aggregate is reconstructed by replaying its entire event stream, long-lived aggregates with thousands of events can become slow to load. Therefore, Axon supports snapshotting: after a configurable threshold, the framework stores a serialized state snapshot and replays only the events that follow it. A common pattern is to trigger a snapshot every 100 or 250 events, balancing load time against snapshot storage cost.
Concurrency matters too. The event store enforces optimistic locking on the aggregate’s sequence number, so two commands racing on the same aggregate will produce a ConcurrencyException on the loser. Consequently, you should retry idempotent commands rather than surfacing the raw exception to callers. Axon’s CommandGateway can be wrapped with a retry scheduler for exactly this purpose.
@Bean
public SnapshotTriggerDefinition snapshotTrigger(Snapshotter snapshotter) {
// Snapshot every 250 events to bound aggregate load time
return new EventCountSnapshotTriggerDefinition(snapshotter, 250);
}
@Bean
public CommandGateway retryingGateway(CommandBus bus) {
return DefaultCommandGateway.builder()
.commandBus(bus)
// Retry transient ConcurrencyExceptions with backoff
.retryScheduler(IntervalRetryScheduler.builder()
.retryExecutor(Executors.newScheduledThreadPool(1))
.maxRetryCount(3)
.retryInterval(100)
.build())
.build();
}
Read Model Projections
Projections subscribe to events and build optimized read models. Therefore, you can create multiple projections from the same events, each optimized for a specific query pattern — a flat summary table for list views, a denormalized document for detail pages, and an analytics rollup for dashboards. Importantly, each projection lives in its own @ProcessingGroup, which means it can lag, fail, or be replayed independently of the others:
@Component
@ProcessingGroup("order-summary")
public class OrderSummaryProjection {
private final OrderSummaryRepository repository;
@EventHandler
public void on(OrderCreatedEvent event) {
OrderSummary summary = new OrderSummary(
event.getOrderId(),
event.getCustomerId(),
event.getTotalAmount(),
"PENDING",
Instant.now()
);
repository.save(summary);
}
@EventHandler
public void on(OrderConfirmedEvent event) {
repository.findById(event.getOrderId())
.ifPresent(summary -> {
summary.setStatus("CONFIRMED");
summary.setConfirmedAt(Instant.now());
repository.save(summary);
});
}
@QueryHandler
public List<OrderSummary> handle(
FindOrdersByCustomerQuery query) {
return repository.findByCustomerId(
query.getCustomerId());
}
// Reset handler for replay
@ResetHandler
public void reset() {
repository.deleteAll();
}
}
Embracing Eventual Consistency
Projections update asynchronously, which means a client that issues a command and immediately queries may not see its own write yet. This is the single most common source of confusion for teams new to the pattern. Fortunately, Axon’s subscription queries help: the client subscribes to a query, receives the current value, and then receives a live update the moment the projection catches up. In practice, teams pair an HTTP command endpoint with a subscription query so the UI reflects the change within milliseconds without polling.
// Issue command, then subscribe for the projection update
commandGateway.sendAndWait(new ConfirmOrderCommand(orderId));
SubscriptionQueryResult<OrderSummary, OrderSummary> result =
queryGateway.subscriptionQuery(
new FindOrderQuery(orderId),
ResponseTypes.instanceOf(OrderSummary.class),
ResponseTypes.instanceOf(OrderSummary.class));
// initialResult() gives current state; updates() streams changes
result.updates()
.filter(o -> "CONFIRMED".equals(o.getStatus()))
.next()
.block(Duration.ofSeconds(2));
Schema Evolution with Upcasters
Events are immutable and live forever, so your event schema will change while old events remain in the store. Axon solves this with upcasters: small transformers that rewrite an old event’s serialized form into the current shape at read time, without touching the stored data. For instance, when you add a currency field to OrderCreatedEvent, an upcaster injects a default for events written before the field existed. This is the discipline that keeps a years-old event store usable.
public class OrderCreatedUpcaster extends SingleEventUpcaster {
@Override
protected boolean canUpcast(IntermediateEventRepresentation rep) {
return rep.getType().getName().equals(
"com.shop.OrderCreatedEvent")
&& "1.0".equals(rep.getType().getRevision());
}
@Override
protected IntermediateEventRepresentation doUpcast(
IntermediateEventRepresentation rep) {
return rep.upcastPayload(
new SimpleSerializedType(
"com.shop.OrderCreatedEvent", "2.0"),
org.dom4j.Document.class,
doc -> {
// Add missing currency element, default to USD
doc.getRootElement().addElement("currency").setText("USD");
return doc;
});
}
}
Saga Orchestration
Sagas coordinate processes that span multiple aggregates. Additionally, Axon sagas handle timeouts, compensating actions, and complex business workflows. Unlike a database transaction, a saga cannot roll back; instead, it issues compensating commands to undo prior steps. The example below reserves inventory, charges payment, and ships — and crucially, it releases inventory and cancels the order if payment fails:
@Saga
public class OrderFulfillmentSaga {
@Autowired
private transient CommandGateway commandGateway;
private String orderId;
@StartSaga
@SagaEventHandler(associationProperty = "orderId")
public void on(OrderConfirmedEvent event) {
this.orderId = event.getOrderId();
// Reserve inventory
commandGateway.send(new ReserveInventoryCommand(
event.getOrderId(), event.getItems()));
// Set deadline for inventory reservation
SagaLifecycle.getDeadlineManager()
.schedule(Duration.ofMinutes(5),
"inventory-timeout");
}
@SagaEventHandler(associationProperty = "orderId")
public void on(InventoryReservedEvent event) {
// Proceed to payment
commandGateway.send(new ProcessPaymentCommand(
orderId, event.getTotalAmount()));
}
@SagaEventHandler(associationProperty = "orderId")
public void on(PaymentProcessedEvent event) {
// Schedule shipping
commandGateway.send(new ShipOrderCommand(orderId));
SagaLifecycle.end();
}
@SagaEventHandler(associationProperty = "orderId")
public void on(PaymentFailedEvent event) {
// Compensate: release inventory
commandGateway.send(
new ReleaseInventoryCommand(orderId));
commandGateway.send(new CancelOrderCommand(
orderId, "Payment failed"));
SagaLifecycle.end();
}
@DeadlineHandler(deadlineName = "inventory-timeout")
public void onTimeout() {
commandGateway.send(new CancelOrderCommand(
orderId, "Inventory reservation timeout"));
SagaLifecycle.end();
}
}
When NOT to Use CQRS Event Sourcing
This pattern adds significant complexity, and honesty about that cost is essential. Simple CRUD applications with straightforward data access patterns do not benefit from the overhead; for them, a plain Spring Data repository is faster to build and easier to operate. Furthermore, event sourcing requires careful schema evolution — changing event structures across versions demands the upcasting strategies shown above, and neglecting them eventually breaks replay. If your team lacks experience with eventual consistency, the debugging challenges of asynchronous projections can be frustrating, since a stale read often looks like a bug when it is actually expected behavior.
There are also operational considerations. Running Axon Server adds a stateful component to your infrastructure, and replaying a large event store to rebuild a projection can take minutes to hours depending on volume. Therefore, a pragmatic path is to start with CQRS without event sourcing when you only need read/write separation, and to adopt full event sourcing only in the bounded contexts — billing, ledgers, regulated workflows — where the audit trail and temporal queries genuinely pay for themselves. Benchmarks and the Axon docs both suggest reserving the heavyweight option for the parts of the domain that demand it, not the whole system.
Key Takeaways
- CQRS event sourcing separates reads from writes and stores every state change as an immutable event
- Axon Framework provides production-ready command handling, event stores, projections, and saga orchestration
- Read model projections can be rebuilt from the event stream, enabling new query patterns without data migration
- Snapshots bound aggregate load time, while optimistic locking and command retries handle concurrency
- Upcasters keep an evolving event schema readable, and subscription queries tame eventual consistency
- Sagas coordinate multi-aggregate processes with compensating actions and deadline management
- Reserve this pattern for domains with complex business rules, audit requirements, and independent scaling needs
Related Reading
- Saga Pattern Microservices Implementation
- Vertical Slice Architecture Guide
- Data Mesh Architecture Implementation
External Resources
In conclusion, Cqrs Event Sourcing Axon is an essential topic for modern software development. By applying the patterns and practices covered in this guide — aggregates, projections, snapshots, upcasters, and sagas — you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.