Pavan Rangani

HomeBlogCQRS and Event Sourcing with Axon Framework: Production Implementation Guide

CQRS and Event Sourcing with Axon Framework: Production Implementation Guide

By Pavan Rangani · March 25, 2026 · Architecture

CQRS and Event Sourcing with Axon Framework: Production Implementation Guide

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.

CQRS event sourcing architecture diagram
CQRS separates command processing from query handling with events as the bridge

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();
    }
}
Event sourcing projections and read model optimization
Multiple projections from the same event stream enable optimized query patterns

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.

Architecture decision framework for CQRS adoption
Evaluate complexity trade-offs carefully before adopting CQRS with event sourcing

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

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.

← Back to all articles