Skip to content
Event Sourcing and CQRS with Axon Framework: Building Banking Systems with Eventual Consistency and Saga Orchestration

Event Sourcing and CQRS with Axon Framework: Building Banking Systems with Eventual Consistency and Saga Orchestration

1 The New Imperative for Banking: Why Auditability and Scale Demand Event Sourcing

Modern banking systems are expected to handle millions of transactions daily, maintain complete audit trails for compliance, and deliver real-time customer experiences across multiple channels. Achieving all three—speed, scalability, and auditability—is impossible with traditional CRUD-based architectures. This is where Event Sourcing, CQRS, and DDD—brought together under the Axon Framework—fundamentally change how we think about data, consistency, and system design in financial systems.

1.1 The Banking Challenge

The classical relational model, with its focus on current state persistence and immediate consistency, was never designed for the complexity of modern financial systems. Consider a simple money transfer: debiting one account and crediting another. In a CRUD world, this looks like two UPDATE statements wrapped in a database transaction. When the system scales horizontally, when multiple microservices touch the same logical entities, or when audit trails become mandatory, this approach starts to collapse.

Let’s analyze the key pain points:

  1. Loss of history. A CRUD table shows what the balance is now, but not how it got there. If an auditor asks, “How was the account balance reduced from $5,000 to $4,000?” you can only provide indirect logs or manually reconstructed data.

  2. Poor scalability. In monolithic systems, every operation competes for the same table locks. Even if you shard the data, maintaining cross-account transactions is complex and error-prone.

  3. Inadequate auditability. Regulatory compliance frameworks (e.g., PSD2, SOX, Basel III) require provable, immutable histories of every financial action. CRUD operations overwrite data, destroying that proof.

  4. Difficult recovery. When data corruption or an outage occurs, you can’t “rebuild” a consistent state because the intermediate transitions are lost. You can restore backups, but they only represent snapshots, not the story of the system.

  5. Slow evolution. Adding new reporting, machine learning, or risk models means performing costly ETL jobs. Systems that record only current state can’t easily replay historical behaviors.

In short, the CRUD model’s focus on “what is” rather than “what happened” makes it brittle for the dynamic, auditable, and distributed world of financial systems.

1.2 The Core Solution: The Immutable Log as Source of Truth

The banking world is fundamentally event-driven. Every deposit, withdrawal, or transfer is an event that has meaning in time. By designing systems around immutable sequences of these domain events, we can achieve scalability, traceability, and reliability simultaneously.

1.2.1 Event Sourcing (ES)

Event Sourcing replaces the traditional database record with an append-only log of facts. Instead of persisting the current state of an entity, we store every change as an immutable event.

For example:

Traditional (CRUD)
Account table: { id: 123, balance: 50 }

Event-Sourced
Event Stream for Account 123:
  1. AccountCreated (initialBalance = 0)
  2. MoneyDeposited (amount = 100)
  3. MoneyWithdrawn (amount = 50)

When we need the current balance, we replay the events:

balance = 0 + 100 - 50 = 50

This model has several benefits:

  • Perfect auditability: Every state change is recorded forever.
  • Reproducibility: You can rebuild the system state at any point in time.
  • Debuggability: You can replay history to understand past behavior.
  • Evolution: Adding new read models or analytics doesn’t affect the write path.

However, Event Sourcing also introduces eventual consistency and new operational challenges. The system must be able to rebuild aggregates, store large event histories efficiently, and manage evolving event schemas—all of which are first-class citizens in Axon.

1.2.2 CQRS (Command Query Responsibility Segregation)

In Event Sourcing, write operations and read operations have fundamentally different requirements. CQRS formalizes this separation.

  • Command side (Write model): Handles intent—validates business rules and produces events.
  • Query side (Read model): Consumes events and projects them into fast, query-optimized views (like account balances or transaction histories).

This separation allows independent scaling:

  • The command model ensures strong consistency for writes.
  • The query model is eventually consistent but can be highly optimized for read performance using databases like MongoDB, Elasticsearch, or Postgres.

A financial dashboard, for instance, doesn’t need to query the aggregate directly—it simply reads from a projection built by replaying the events.

1.2.3 Domain-Driven Design (DDD)

Event Sourcing and CQRS are architectural patterns. Domain-Driven Design (DDD) provides the modeling principles that make them useful. DDD helps you express complex business rules in an object model that aligns with your domain experts’ language.

Key DDD concepts in our context:

  • Aggregate: A transactional consistency boundary (e.g., a single bank account).
  • Entity and Value Objects: Core domain objects encapsulated within the aggregate.
  • Bounded Context: A logical domain boundary (e.g., “Accounts,” “Payments,” “Transfers”) where models and terminology remain consistent.

Event Sourcing and CQRS are most effective when applied within well-defined bounded contexts modeled using DDD.

1.3 Why Axon Framework?

Event Sourcing and CQRS can be implemented from scratch, but doing so correctly is complex. You need to handle concurrency, event versioning, distributed command routing, idempotency, and message reliability. Axon Framework (and its companion, Axon Server) provides a cohesive, production-ready ecosystem to manage these concerns.

Axon Framework offers:

  • Aggregate lifecycle management: Annotate classes with @Aggregate and use @CommandHandler and @EventSourcingHandler.
  • Event storage and replay: Built-in support for event stores, including Axon Server and JDBC.
  • Command and event buses: Transparent message routing between components or microservices.
  • Sagas: Distributed process management for multi-step transactions.
  • Query and subscription handling: Integrated CQRS support with tracking and subscribing processors.
  • Tooling: Monitoring dashboards, replay capabilities, and snapshots.

With Axon, you don’t reinvent the wheel—you focus on modeling the domain, while Axon manages the plumbing.

1.4 Article Goal

This article takes you through the end-to-end design and implementation of a banking system using Event Sourcing, CQRS, and Sagas in Axon Framework. We’ll start from modeling a single account, evolve to projections, and end with a distributed fund transfer flow orchestrated via a Saga.

You’ll learn how to:

  • Build Aggregates using commands and events.
  • Implement projections with eventual consistency.
  • Handle distributed transactions with compensating actions.
  • Ensure idempotency and fault tolerance.
  • Scale and evolve event-sourced systems safely.

By the end, you’ll have a clear blueprint for designing resilient, auditable, and scalable financial systems using Axon.


2 Foundations: Modeling a Bank Account with Axon Aggregates

Every event-sourced system begins with a robust aggregate model. In banking, the core aggregate is the BankAccount. It enforces business rules, generates events, and ensures invariants such as “balance must not go negative.”

2.1 The “Write” Model: Defining the Language of Our Domain

In Axon, we communicate with the aggregate using Commands and Events.

2.1.1 Commands – Expressing Intent

Commands represent user or system intent. They are immutable, meaning they describe what should happen, not how.

Examples:

public class CreateAccountCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final String owner;
    private final BigDecimal initialBalance;
}

public class DepositMoneyCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final BigDecimal amount;
}

public class WithdrawMoneyCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final BigDecimal amount;
}

Each command is directed to a specific aggregate instance using the @TargetAggregateIdentifier field.

2.1.2 Events – Recording Facts

Events capture what has happened in the domain, after validation succeeds.

public class AccountCreatedEvent {
    private final String accountId;
    private final String owner;
    private final BigDecimal initialBalance;
}

public class MoneyDepositedEvent {
    private final String accountId;
    private final BigDecimal amount;
}

public class MoneyWithdrawnEvent {
    private final String accountId;
    private final BigDecimal amount;
}

Events are immutable, timestamped, and stored in the event store. They represent the true source of truth in the system.

2.2 The Aggregate: The Heart of Business Logic

The aggregate in Axon enforces domain invariants and produces domain events in response to commands. It’s annotated with @Aggregate and maintains internal state reconstructed from event history.

2.2.1 Creating the BankAccount Aggregate

@Aggregate
public class BankAccount {

    @AggregateIdentifier
    private String accountId;
    private BigDecimal balance;

    protected BankAccount() {
        // Required by Axon
    }

    @CommandHandler
    public BankAccount(CreateAccountCommand cmd) {
        if (cmd.getInitialBalance().compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Initial balance must be non-negative");
        AggregateLifecycle.apply(new AccountCreatedEvent(
                cmd.getAccountId(),
                cmd.getOwner(),
                cmd.getInitialBalance()
        ));
    }

    @CommandHandler
    public void handle(DepositMoneyCommand cmd) {
        if (cmd.getAmount().compareTo(BigDecimal.ZERO) <= 0)
            throw new IllegalArgumentException("Deposit must be positive");
        AggregateLifecycle.apply(new MoneyDepositedEvent(cmd.getAccountId(), cmd.getAmount()));
    }

    @CommandHandler
    public void handle(WithdrawMoneyCommand cmd) {
        if (balance.compareTo(cmd.getAmount()) < 0)
            throw new IllegalStateException("Insufficient funds");
        AggregateLifecycle.apply(new MoneyWithdrawnEvent(cmd.getAccountId(), cmd.getAmount()));
    }

    @EventSourcingHandler
    public void on(AccountCreatedEvent evt) {
        this.accountId = evt.getAccountId();
        this.balance = evt.getInitialBalance();
    }

    @EventSourcingHandler
    public void on(MoneyDepositedEvent evt) {
        this.balance = this.balance.add(evt.getAmount());
    }

    @EventSourcingHandler
    public void on(MoneyWithdrawnEvent evt) {
        this.balance = this.balance.subtract(evt.getAmount());
    }
}

2.2.2 Command Handlers – The Gatekeepers

Each @CommandHandler validates intent and applies one or more domain events. The AggregateLifecycle.apply() method doesn’t change state directly—it publishes an event, which is then persisted and replayed into the aggregate.

2.2.3 Event Sourcing Handlers – Rebuilding State

@EventSourcingHandler methods modify the internal state based on past events. When Axon loads an aggregate from the event store, it replays all its events, restoring its current state. This is the mechanism that allows full system recovery and time travel.

2.3 Practical Java Example: End-to-End Flow

A command might flow like this:

  1. The user calls a REST endpoint: POST /accounts/123/deposit { "amount": 100 }
  2. The controller sends a DepositMoneyCommand to the command bus.
  3. The aggregate handles it, applies a MoneyDepositedEvent.
  4. The event is stored in Axon Server’s event store and published.
  5. The projection (read model) consumes the event and updates the visible account balance.

Axon manages the command routing and event publication behind the scenes, making this flow entirely asynchronous and resilient.

2.4 The Event Store: Where Does It Go?

Every event generated by the system must be stored durably. In Axon, this is the Event Store, and the default, most optimized implementation is Axon Server.

2.4.1 Axon Server Overview

Axon Server acts as:

  • Event Store: Stores and streams immutable events efficiently.
  • Message Router: Routes commands, events, and queries across nodes and microservices.
  • Coordinator: Manages distributed consistency, tracking processors, and subscriptions.

Key advantages:

  • Zero configuration (start and connect).
  • Native support for snapshots and replay.
  • High-throughput streaming protocol (gRPC-based).
  • Integrated dashboards for monitoring aggregates, processors, and message flow.

2.4.2 Alternatives and Trade-offs

You can use other backends like:

  • JDBC Event Store: Simple, works for small systems, but lacks advanced scalability.
  • Kafka-based event distribution: Great for event-driven integration but not an event store—Kafka isn’t designed for long-term event replay and aggregate reconstruction.

For production-grade financial systems, Axon Server Enterprise offers clustering, replication, and multi-context isolation—critical for handling millions of events safely.


3 Building the “Read” Side: Projections and Eventual Consistency

The write model ensures correctness; the read model ensures usability. Projections turn domain events into queryable, fast data views. In banking, projections power balance dashboards, transaction histories, and audit reports.

3.1 What is a Projection?

A projection (or read model) is a materialized view of the system’s current state, built from events. It doesn’t own the data; it derives it.

For example, a projection might maintain an accounts table:

account_idbalancelast_updated
ACC-0011500.002025-11-10 14:33:00

When a new MoneyDepositedEvent arrives, the projection updates this row.

Unlike the command side, projections can use any persistence technology—relational, NoSQL, or in-memory—depending on performance needs.

3.2 Practical Java Example: AccountBalanceProjection

@Component
public class AccountBalanceProjection {

    private final AccountRepository accountRepository;

    public AccountBalanceProjection(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @EventHandler
    public void on(AccountCreatedEvent evt) {
        AccountView account = new AccountView(evt.getAccountId(), evt.getInitialBalance());
        accountRepository.save(account);
    }

    @EventHandler
    public void on(MoneyDepositedEvent evt) {
        accountRepository.findById(evt.getAccountId()).ifPresent(account -> {
            account.setBalance(account.getBalance().add(evt.getAmount()));
            accountRepository.save(account);
        });
    }

    @EventHandler
    public void on(MoneyWithdrawnEvent evt) {
        accountRepository.findById(evt.getAccountId()).ifPresent(account -> {
            account.setBalance(account.getBalance().subtract(evt.getAmount()));
            accountRepository.save(account);
        });
    }
}

Repository example:

public interface AccountRepository extends JpaRepository<AccountView, String> {}

Entity example:

@Entity
public class AccountView {
    @Id
    private String accountId;
    private BigDecimal balance;

    // getters/setters
}

This projection continuously reflects the latest account balances, catching up automatically as new events are published.

3.3 The Elephant in the Room: Eventual Consistency

Eventual consistency means that projections might temporarily lag behind the write model. If a deposit event is committed, the projection may take milliseconds—or even seconds under load—to reflect it.

In high-frequency financial systems, this is not a bug—it’s a scalability feature. Writes don’t block reads, allowing each subsystem to scale independently.

Managing the UX Lag

  1. Return pending responses. After a command succeeds, return a transaction ID, not the new balance.
  2. Polling or subscriptions. Clients can subscribe to real-time updates (via WebSockets or SSE) or poll for status.
  3. Consistent domain language. Expose “pending,” “confirmed,” and “settled” statuses to users to reflect the asynchronous nature of the system.

3.4 Processors: The Engine of Your Projections

Axon Framework processes events through Event Processors—the components that consume event streams and invoke your event handlers.

3.4.1 Tracking Event Processors (TEP)

Tracking processors are the most common and powerful. They:

  • Maintain tokens representing the last processed event.
  • Can be paused, replayed, or scaled horizontally.
  • Provide resilience via token store persistence.

This allows you to reset a projection, replay all events, and rebuild state—an essential feature for correcting projection logic or fulfilling new BI requirements.

3.4.2 Subscribing Event Processors

These react to events synchronously within the same JVM. They’re simple and low-latency but unsuitable for scaling or replay. Use them for lightweight tasks like sending notifications.

3.4.3 Persistent Streams (Axon 4.10+)

Persistent Streams provide a new, high-performance backbone for event handling. Instead of pulling events, processors receive an event stream directly from Axon Server, improving throughput and reducing latency—particularly beneficial in financial systems where projections process millions of events per day.


4 Orchestrating Complex Workflows: The Saga Pattern

As soon as we move beyond single aggregates, new consistency challenges appear. Transferring funds between two accounts, for example, requires coordinating multiple aggregates and sometimes external systems. A failure halfway through the process—like debiting one account successfully but failing to credit the other—can leave data inconsistent. Distributed systems can’t rely on a single ACID transaction to fix that. This is where Sagas enter the picture in Axon Framework.

4.1 The Problem

Imagine a customer transferring funds from their account at Bank A to another account at Bank B. The process might involve:

  1. Debiting the source account in Bank A
  2. Calling a third-party API to initiate an external transfer
  3. Crediting the destination account at Bank B

Now consider what happens if step 1 succeeds but step 2 fails due to a timeout or API error. The debit is already recorded in the event store. You can’t simply “rollback” that event—it’s immutable by design. The only valid recovery mechanism is to issue new compensating actions, such as refunding the amount to the source account.

A typical CRUD architecture might attempt a distributed transaction with two-phase commit (2PC), but that’s brittle at scale and impossible across independent services. In an event-sourced architecture, each aggregate’s state is local and isolated, and global transactions are replaced by sagas—long-running process managers that coordinate multiple participants via asynchronous messages.

4.2 Saga Pattern Explained

A Saga represents a business process that spans multiple aggregates or services. It reacts to domain events, issues new commands, and persists its own state to track progress. Sagas make distributed transactions deterministic and auditable by explicitly modeling each step as part of an event-driven workflow.

There are two primary ways to structure Sagas: Choreography and Orchestration.

4.2.1 Choreography (Event-Driven)

In a choreographed Saga, each service reacts to events published by others. There’s no central coordinator. For example:

  • TransferRequestedEvent → triggers the Accounts service to debit the source.
  • After success, SourceAccountDebitedEvent → triggers Payments service to initiate the external transfer.
  • Finally, TransferCompletedEvent → triggers Notifications service to alert the user.

This approach is simple and decoupled, but it can quickly become difficult to reason about. Dependencies and timing issues spread across multiple microservices, and debugging requires tracing a chain of events across the system. In large financial domains, this leads to “event spaghetti.”

4.2.2 Orchestration (Command-Driven)

Orchestration introduces a central Saga class that coordinates the steps explicitly. It listens for events, maintains state, and sends commands to other aggregates or services. This makes workflows easier to trace and modify, at the cost of a slightly more centralized architecture.

Axon Framework supports orchestration natively using @Saga, @StartSaga, and @SagaEventHandler annotations. Each Saga instance is uniquely identified (often by a correlation ID like transferId) and automatically persisted by Axon to maintain its lifecycle across restarts or failures.

4.3 Deep Practical Example: “Inter-Bank Fund Transfer” Saga (Orchestration)

Let’s walk through a real-world example: transferring funds between two banks. The flow involves multiple steps and partial failures, making it an ideal candidate for Saga orchestration.

4.3.1 Domain Events and Commands

We define events and commands to capture each step.

// Commands
public class RequestFundTransferCommand {
    @TargetAggregateIdentifier
    private final String transferId;
    private final String sourceAccountId;
    private final String destinationAccountId;
    private final BigDecimal amount;
}

public class DebitSourceAccountCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final String transferId;
    private final BigDecimal amount;
}

public class CreditDestinationAccountCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final String transferId;
    private final BigDecimal amount;
}

public class InitiateExternalBankTransferCommand {
    private final String transferId;
    private final BigDecimal amount;
    private final String destinationBankCode;
}
// Events
public class FundTransferRequestedEvent {
    private final String transferId;
    private final String sourceAccountId;
    private final String destinationAccountId;
    private final BigDecimal amount;
}

public class SourceAccountDebitedEvent {
    private final String transferId;
    private final String accountId;
    private final BigDecimal amount;
}

public class ExternalTransferSucceededEvent {
    private final String transferId;
    private final String referenceNumber;
}

public class ExternalTransferFailedEvent {
    private final String transferId;
    private final String reason;
}

4.3.2 Saga Lifecycle with Annotations

Now we orchestrate the transfer in a Saga class. This Saga listens for events and issues commands, keeping its own state for correlation.

@Saga
public class FundTransferSaga {

    @SagaEventHandler(associationProperty = "transferId")
    @StartSaga
    public void on(FundTransferRequestedEvent event, CommandGateway commandGateway) {
        SagaLifecycle.associateWith("transferId", event.getTransferId());
        commandGateway.send(new DebitSourceAccountCommand(
                event.getSourceAccountId(),
                event.getTransferId(),
                event.getAmount()
        ));
    }

    @SagaEventHandler(associationProperty = "transferId")
    public void on(SourceAccountDebitedEvent event, CommandGateway commandGateway) {
        commandGateway.send(new InitiateExternalBankTransferCommand(
                event.getTransferId(),
                event.getAmount(),
                "BANK-B"
        ));
    }

    @SagaEventHandler(associationProperty = "transferId")
    public void on(ExternalTransferSucceededEvent event, CommandGateway commandGateway) {
        commandGateway.send(new CreditDestinationAccountCommand(
                "BANK-B-ACC-567",
                event.getTransferId(),
                new BigDecimal("500.00")
        ));
        SagaLifecycle.end();
    }
}

4.3.3 Execution Flow

The step-by-step process looks like this:

  1. Start the Saga: A FundTransferRequestedEvent starts the Saga. It associates the Saga instance with transferId and sends a DebitSourceAccountCommand.

  2. Debit Source Account: The BankAccount aggregate processes the command, emits a SourceAccountDebitedEvent.

  3. Initiate External Transfer: The Saga reacts to this event and issues an InitiateExternalBankTransferCommand to the external transfer gateway.

  4. Credit Destination Account: Once the ExternalTransferSucceededEvent arrives, the Saga sends a CreditDestinationAccountCommand to finalize the transfer.

  5. End the Saga: The Saga ends its lifecycle after successful crediting. Its internal state is cleared, but all events remain in the event store for audit.

This process is asynchronous, resilient to partial failures, and completely traceable. If the application crashes mid-transfer, Axon will reload the Saga state from its persistence store and resume from the last processed event.

4.4 Handling Failure: Compensating Transactions

In distributed workflows, failures are inevitable. Suppose the external transfer step fails due to a network issue. The source account has already been debited, so we can’t just ignore the failure or delete the event. The proper response is a compensating transaction—issuing a new command that reverses the effect.

4.4.1 Compensating Logic in the Saga

@Saga
public class FundTransferSaga {

    @SagaEventHandler(associationProperty = "transferId")
    public void on(ExternalTransferFailedEvent event, CommandGateway commandGateway) {
        commandGateway.send(new CreditSourceAccountCommand(
                "BANK-A-ACC-123",
                event.getTransferId(),
                new BigDecimal("500.00")
        ));
        SagaLifecycle.end();
    }
}

4.4.2 Why Compensation, Not Rollback

In Event Sourcing, nothing is undone. Every action—successful or failed—becomes part of the immutable history. The compensating command (CreditSourceAccountCommand) generates a new event (SourceAccountCreditedEvent), which accurately reflects the refund. This maintains perfect auditability while restoring financial consistency.

The event log might now look like this:

1. FundTransferRequestedEvent
2. SourceAccountDebitedEvent
3. ExternalTransferFailedEvent
4. SourceAccountCreditedEvent (Compensation)

From an auditor’s point of view, this sequence tells a complete, truthful story: the system attempted a transfer, it failed, and it refunded the amount. No hidden rollbacks, no data inconsistencies.


5 Production Readiness Part 1: Handling “At-Least-Once” Delivery

Event-driven systems rely on message delivery over networks, and networks are unreliable by nature. Messages can be delayed, duplicated, or delivered out of order. To achieve reliability, most distributed message brokers—including Axon Server—use at-least-once delivery. This means a message may be delivered multiple times, but never lost. Handling these duplicates safely is critical to maintaining correctness in a financial system.

5.1 The “Duplicate Message” Problem

Imagine a WithdrawMoneyCommand sent over the command bus. The network briefly disconnects after the aggregate processes it but before the acknowledgment reaches the sender. The sender retries, resulting in two identical commands processed by the same aggregate. Without safeguards, this could lead to double withdrawal and inconsistent balances.

Similarly, event processors might replay or redeliver the same event after a node restart or partition recovery. If projections or external integrations process these events multiple times, side effects (like sending multiple notifications or creating duplicate records) can occur.

The fix is idempotency—ensuring that reapplying the same operation produces no additional effect.

5.2 Idempotency: The Solution

An operation is idempotent if executing it multiple times yields the same outcome as executing it once. In practice, we can achieve idempotency at different levels:

  • Command level (in aggregates): Prevent duplicate domain events from being applied.
  • Projection level: Prevent duplicate database writes or external API calls.

Idempotency must be built explicitly because the event store and processors guarantee delivery, not uniqueness of execution.

5.3 Pattern 1: Idempotency in the Aggregate

The safest place to enforce idempotency is inside the aggregate itself. We attach a transactionId or similar unique identifier to each command and maintain a record of processed IDs within the aggregate’s event-sourced state.

public class WithdrawMoneyCommand {
    @TargetAggregateIdentifier
    private final String accountId;
    private final String transactionId;
    private final BigDecimal amount;
}

In the aggregate:

@Aggregate
public class BankAccount {

    @AggregateIdentifier
    private String accountId;
    private BigDecimal balance;
    private Set<String> processedTransactionIds = new HashSet<>();

    @CommandHandler
    public void handle(WithdrawMoneyCommand cmd) {
        if (processedTransactionIds.contains(cmd.getTransactionId())) {
            return; // Duplicate command, ignore
        }

        if (balance.compareTo(cmd.getAmount()) < 0)
            throw new IllegalStateException("Insufficient funds");

        AggregateLifecycle.apply(new MoneyWithdrawnEvent(
                cmd.getAccountId(),
                cmd.getTransactionId(),
                cmd.getAmount()
        ));
    }

    @EventSourcingHandler
    public void on(MoneyWithdrawnEvent evt) {
        balance = balance.subtract(evt.getAmount());
        processedTransactionIds.add(evt.getTransactionId());
    }
}

When Axon replays events, the aggregate’s state (including processed transaction IDs) is rebuilt from history, so even after restarts, duplicate prevention remains intact.

For long-lived aggregates, you might periodically snapshot state or limit the processedTransactionIds set size to prevent memory growth. In practice, storing the last N transaction IDs (e.g., 100 or 1000) covers realistic retry scenarios.

5.4 Pattern 2: Idempotency in Projections (Event Handlers)

Even if the aggregate prevents duplicate events, projections and downstream consumers may still receive the same event multiple times—especially during replays or processor restarts. These handlers must also be idempotent.

A naive handler might reinsert data blindly:

@EventHandler
public void on(MoneyWithdrawnEvent evt) {
    jdbcTemplate.update("INSERT INTO transactions (id, account_id, amount) VALUES (?, ?, ?)",
            evt.getTransactionId(), evt.getAccountId(), evt.getAmount());
}

If this event replays, the database throws a duplicate key error. Instead, you design the handler to handle replays gracefully using upserts or merge semantics.

5.4.1 Using Database Upserts

For PostgreSQL:

INSERT INTO transactions (id, account_id, amount)
VALUES (:id, :accountId, :amount)
ON CONFLICT (id) DO NOTHING;

For MySQL:

INSERT IGNORE INTO transactions (id, account_id, amount)
VALUES (:id, :accountId, :amount);

These statements ensure that if a transaction with the same ID already exists, it won’t be inserted again. Updates to existing projections can similarly use ON CONFLICT DO UPDATE to safely handle reprocessing.

5.4.2 Using Application-Level Deduplication

For more complex projections, maintain a small table or cache of processed event identifiers.

@EventHandler
public void on(MoneyWithdrawnEvent evt) {
    if (eventRepository.existsById(evt.getTransactionId())) return;
    eventRepository.save(new ProcessedEvent(evt.getTransactionId()));
    balanceViewRepository.updateBalance(evt.getAccountId(), evt.getAmount().negate());
}

5.4.3 Benefits in Practice

In production banking systems, idempotent design turns replay from a risky operation into a routine one. You can rebuild projections, recover from partial failures, or perform historical analytics—all without fear of side effects. It’s the cornerstone of reliable event-driven processing.


6 Production Readiness Part 2: Scaling, Rebuilding, and Evolving

As your event-sourced banking system grows, the architecture must handle larger event volumes, continuous projection rebuilds, and evolving domain models without downtime. Production-readiness isn’t only about surviving traffic spikes—it’s about maintaining correctness and adaptability as the system scales to billions of transactions.

6.1 Scaling to Millions of Transactions

When banks scale event-sourced systems, the bottleneck often moves from compute to coordination. Event routing, replay management, and consistency enforcement become far more complex than raw data throughput. While developers can run a prototype using PostgreSQL or Kafka as an event store, those solutions quickly become limiting at enterprise scale.

6.1.1 Why Axon Server Enterprise

Axon Server Enterprise is purpose-built for event-sourced and CQRS systems. Unlike generic message brokers (Kafka, RabbitMQ), it provides both durable event storage and distributed message routing out of the box. Its architecture eliminates the need for multiple external services (e.g., broker, schema registry, offset manager) that you’d otherwise have to manage manually.

Key features for high-scale environments:

  1. Contexts: Logical partitions for separating domains or tenants. Each bounded context in your banking domain—Accounts, Payments, Transfers—can have its own isolated event stream while still participating in cross-context queries or sagas.

  2. Replication and Clustering: Axon Server Enterprise supports active-active replication. Events are written once but available across multiple nodes. This ensures durability without the complexity of maintaining distributed consensus in your own stack.

  3. Distributed Command and Query Routing: Axon Server automatically routes commands to the right aggregate instance, regardless of where it resides. This is critical when scaling horizontally across hundreds of microservices.

  4. Persistent Subscriptions: Subscribers can resume exactly where they left off after restarts, even across version upgrades.

  5. Performance and Monitoring: With gRPC-based communication and advanced buffering, Axon Server Enterprise can handle millions of events per second. The built-in dashboard shows command latency, event processor lag, and node health in real time.

6.1.2 Comparison with Self-Managed Alternatives

CapabilityAxon Server EnterpriseKafka / RabbitMQ + Custom Event Store
Event storageBuilt-in, optimized for event sourcingMust manage schema and storage manually
Command routingNativeNot supported; requires custom layer
Replay supportFirst-class, token-basedManual replay logic
Message orderingGuaranteed per aggregateMust enforce through partitioning
SnapshotsNativeManual implementation
Operational overheadMinimalHigh—requires multiple components

In practice, enterprises like iManage and Rabobank have reported handling billions of domain events daily with Axon Server Enterprise (as presented at Axoniq Conference 2025). The predictable performance profile and native DDD support are what make it viable for regulated financial domains where traceability and consistency can’t be compromised.

6.2 The “Big Replay”: Rebuilding Projections from Scratch

Even with perfect code, projections will eventually need rebuilding. Perhaps your BI team introduces new analytics dimensions, or a bug corrupts a view model. In an event-sourced system, rebuilding is a feature—not a failure mode.

6.2.1 Why Rebuild?

  • New reporting requirements: You may need to compute average transaction velocity per region, which wasn’t stored originally.
  • Projection schema evolution: You’ve added a currency column that depends on past events.
  • Bug fix: A handler logic error caused incorrect balances for certain accounts.

Rebuilding a projection means replaying all events from the event store to recompute the read model.

6.2.2 Resetting a Tracking Event Processor

Axon’s Tracking Event Processors keep track of their last processed event using a token. Resetting this token to zero triggers a complete replay.

@Autowired
private EventProcessingConfiguration eventProcessingConfiguration;

public void rebuildProjection(String processorName) {
    eventProcessingConfiguration.eventProcessor(processorName, TrackingEventProcessor.class)
            .ifPresent(trackingProcessor -> {
                trackingProcessor.shutDown();
                trackingProcessor.resetTokens();
                trackingProcessor.start();
            });
}

When replaying, Axon reads all historical events, invoking your event handlers in the same order they originally occurred. The projection database is rebuilt deterministically.

For small datasets, this can complete in minutes. For large-scale systems—tens of millions of events—it can take hours, which is where zero-downtime replay patterns come into play.

6.2.3 The Blue-Green Projection Pattern

To rebuild projections while keeping the system online, use a Blue-Green strategy.

  1. Create a new projection (v2): Define a new entity and projection handler (e.g., AccountBalance_v2).

    @Component
    public class AccountBalanceProjectionV2 {
        @EventHandler
        public void on(MoneyDepositedEvent event) {
            accountRepoV2.updateBalance(event.getAccountId(), event.getAmount());
        }
    }
  2. Start a new processor group: Give it a unique name (account-balance-v2-processor). Axon treats it as independent from the old one and starts processing from event 0.

  3. Let it catch up in the background: The v2 projection replays historical events while the v1 projection continues serving live traffic.

  4. Switch traffic: Once caught up, reconfigure your application to read from v2 views. This can be done atomically at the database or API layer.

  5. Decommission old projection: Stop and remove v1 once v2 is verified.

This approach ensures continuous availability while safely iterating projection logic.

6.2.4 Practical Tips

  • Use asynchronous replays with throttling to avoid overwhelming the event store.
  • Store processor lag metrics to monitor catch-up progress.
  • During replay, mark projections as “read-only” if required to avoid confusing intermediate states.

The ability to replay is one of the strongest operational advantages of Event Sourcing. In traditional databases, data corruption or reporting changes often require downtime or migration scripts. Here, the system simply replays truth from the log.

6.3 Event Schema Evolution: The Upcaster

Event immutability means you can’t retroactively change past events, yet your domain model evolves over time. Maybe your AccountCreatedEvent gains a new field accountType, or your UserRegisteredEvent adds lastName. Without a strategy, your old events will fail deserialization.

6.3.1 The Problem

Let’s say early events looked like this:

{
  "eventType": "UserRegisteredEvent",
  "data": {
    "userId": "U123",
    "firstName": "Alex"
  }
}

But your updated code expects:

{
  "eventType": "UserRegisteredEvent",
  "data": {
    "userId": "U123",
    "firstName": "Alex",
    "lastName": "Johnson"
  }
}

Old events lack lastName. Without an intermediate transformation, they will break when Axon tries to deserialize them into the new class.

6.3.2 Axon’s Upcaster Solution

An Upcaster intercepts and transforms older event payloads during reading. It lets you progressively evolve event formats while keeping your domain logic unaware of older versions.

public class UserRegisteredEventUpcaster extends SingleEventUpcaster {

    @Override
    protected boolean canUpcast(IntermediateEventRepresentation intermediateEventRepresentation) {
        return intermediateEventRepresentation.getType().getName().equals("UserRegisteredEvent")
                && intermediateEventRepresentation.getType().getRevision().equals("1");
    }

    @Override
    protected IntermediateEventRepresentation doUpcast(IntermediateEventRepresentation intermediate) {
        Document originalDoc = intermediate.getData(Document.class).orElseThrow();
        Document newDoc = Document.parse(originalDoc.toJson());
        newDoc.put("lastName", "UNKNOWN");

        return intermediate.upcastPayload(
                intermediate.getType().withRevision("2"),
                Document.class,
                doc -> newDoc
        );
    }
}

6.3.3 Upcaster Chains and Versioning

Upcasters can be chained to evolve an event across multiple versions (v1 → v2 → v3). Each upcaster handles a specific revision. Axon applies them sequentially until the event matches the current version expected by your code.

This design ensures your aggregates and projections always operate on the latest, normalized event model. You never have to handle legacy logic inside business code—keeping it clean, testable, and future-proof.


7 The New Frontier (2025+): Dynamic Consistency and Analytics

As Axon Framework evolves, it’s tackling a more nuanced challenge: not all consistency is equal. Some contexts demand strong guarantees (e.g., debiting funds), while others can tolerate relaxed eventual consistency (e.g., analytics or notifications). Modern architectures also need better alignment between design artifacts and code, and new ways to extract insights directly from event logs.

7.1 Beyond “Eventual”: Dynamic Consistency Boundaries (DCB)

The latest release—Axon Server 2025.2.0—introduces Dynamic Consistency Boundaries (DCB), a new way to define and enforce consistency levels across bounded contexts.

7.1.1 Concept

Traditional CQRS treats consistency as binary: either immediate (within an aggregate) or eventual (across projections). In reality, different bounded contexts in banking have different requirements:

  • Accounts: Must enforce strict sequential consistency (no overdrafts).
  • CustomerProfile: Can be eventually consistent—it’s acceptable if updates appear seconds later.
  • Notifications: Can be best-effort, non-critical consistency.

DCB allows architects to declare these guarantees explicitly.

consistency-boundaries:
  - context: Accounts
    level: STRONG
  - context: CustomerProfile
    level: EVENTUAL
  - context: Notifications
    level: ASYNC

Axon Server enforces message ordering, durability, and replication policies automatically based on these definitions.

7.1.2 Why It Matters

By defining consistency contracts, you eliminate ambiguity between teams. Developers know exactly which operations require synchronous coordination and which can rely on background propagation. For multi-context systems, this helps balance performance and correctness without manual tuning.

DCB is becoming especially useful in large-scale architectures where you may operate dozens of microservices, each with its own consistency profile.

7.2 From Event Model to Code: Event Modeling Notation (EMN)

One major theme at Axoniq Conference 2025 was Event Modeling Notation (EMN)—a standardized, machine-readable way to capture event models and generate code directly from them.

7.2.1 What EMN Is

EMN acts like a schema language for event-driven systems. It describes commands, events, aggregates, and projections in a formal syntax (similar to JSON or XML). Here’s a simplified example:

{
  "aggregate": "BankAccount",
  "commands": [
    { "name": "CreateAccount", "fields": ["accountId", "owner", "initialBalance"] },
    { "name": "WithdrawMoney", "fields": ["accountId", "amount"] }
  ],
  "events": [
    { "name": "AccountCreated", "fields": ["accountId", "owner", "initialBalance"] },
    { "name": "MoneyWithdrawn", "fields": ["accountId", "amount"] }
  ]
}

7.2.2 The Payoff

With Axon Framework 5, EMN definitions can generate boilerplate code:

  • Aggregate skeletons with @CommandHandler and @EventSourcingHandler methods.
  • Corresponding command and event classes.
  • Initial projection definitions.

The advantage is twofold:

  1. Design and implementation stay in sync. When architects change the model, developers regenerate code instead of manually updating classes.
  2. Traceability: EMN files serve as living documentation, eliminating the gap between whiteboard design and runtime code.

For large teams in regulated sectors, this becomes invaluable for audits and compliance reviews—there’s a single source of truth for how the domain behaves.

7.3 The “Why” of Event Sourcing: Analytics and AI

Event Sourcing doesn’t just improve architecture—it builds a perfect dataset for analytics and AI. Each event is a timestamped, immutable fact representing business intent. Over time, this forms a historical ledger of customer behavior and system decisions.

7.3.1 Axon Server Analytics Nodes

As of Axon Server 2025.2.0, Analytics Nodes allow direct querying of the event store using SQL or even natural language.

Examples:

SELECT AVG(amount)
FROM events
WHERE eventType = 'MoneyWithdrawnEvent'
  AND timestamp BETWEEN '2025-07-01' AND '2025-09-30'
  AND metadata.region = 'EMEA';

Or using the built-in LLM interface:

"Show total number of external transfers failed in the last 24 hours"

Axon translates the query into SQL over the event index and returns structured results. This avoids ETL jobs or data duplication, letting business analysts work directly on the immutable event log.

7.3.2 Why This Changes the Game

  • Real-time BI: Stream aggregates as they evolve, not after nightly ETL.
  • AI-readiness: Events form ideal training data for anomaly detection, fraud prevention, and recommendation models.
  • Zero data loss: You analyze the actual business events, not derived tables.

In essence, an event-sourced system is an analytics-first system. You can ask questions of your history at any time without modifying the operational layer.


8 Conclusion: Your Blueprint for a Resilient Banking System

8.1 Recap

We’ve moved from designing a single BankAccount aggregate to building a distributed, fault-tolerant banking system. We explored how Event Sourcing and CQRS bring scalability, how Sagas orchestrate cross-service consistency, and how idempotency and upcasters ensure reliability and evolution. Along the way, we leveraged Axon’s ecosystem—command routing, projections, event replay—to achieve auditability without sacrificing performance.

8.2 The Shift in Mindset

Event Sourcing is not merely a data pattern—it’s a shift in thinking. Instead of storing static state, you capture decisions. Instead of hiding domain logic in transactional updates, you make it explicit in events. This architectural style forces clarity and yields systems that are easier to scale, reason about, and audit—qualities that traditional banking systems often struggle to achieve.

8.3 Other Tools in the Ecosystem

While Axon provides a full-stack approach, it’s worth knowing your ecosystem:

  • Saga Orchestration Alternatives: For ultra-complex workflows that span hundreds of steps, evaluate Temporal.io or Camunda, which complement Axon for long-running orchestration.
  • Monitoring and Observability: Axon Server integrates natively with Grafana dashboards, allowing you to track event processor health, lag, and throughput.
  • Tracing: Combine Axon’s message correlation metadata with OpenTelemetry for end-to-end tracing across distributed components.

8.4 Final Call to Action

The journey from CRUD to Event Sourcing may feel steep at first, but the payoff is lasting resilience. Start small: pick one bounded context, model its commands and events, and let Axon manage the rest. As your system grows, you’ll find that this approach scales naturally—both technically and conceptually.

In the era of real-time, regulated, and data-driven banking, Event Sourcing with Axon Framework isn’t just a pattern—it’s a foundation for building systems that don’t just work, but endure.

Advertisement