When it’s worth the cost

Event sourcing is not a default architecture. It carries real costs: a steeper learning curve than CRUD, eventual consistency in the read model, projection rebuild time when you add a new query view, and the operational overhead of managing a growing event stream. The question is whether the problem you are solving actually justifies those costs.

In banking payments, three properties tip the balance decisively in favour of event sourcing:

  • Audit trail completeness. SAMA’s Technology Risk Management framework requires a complete, tamper-evident record of every state change on a financial transaction. With CRUD, the audit trail is a side effect you bolt on — a trigger, a shadow table, a log line. With event sourcing, the audit trail is the data model. The events are the truth; the current state is a derived view.
  • Business event explicitness. A payment moves through distinct, named states: PaymentInitiated, FraudCheckPassed, LiquidityReserved, SettlementInstructed, Settled, Reversed. These are business concepts, not database column transitions. Event sourcing forces the team to name every state transition, which eliminates the implicit “what does status = 3 mean?” class of bugs.
  • Temporal queries. Regulators, auditors, and fraud teams all ask “what did the system know at time T?” With CRUD, that question requires a time-travel query against archive tables that may or may not exist. With event sourcing, the answer is replay up to the relevant event sequence number.

Where event sourcing is not worth the cost: reference data services (account master, product catalogue), reporting aggregates with no audit requirement, any domain where every query is against the current state with no temporal dimension, and greenfield teams without event-driven discipline. Use CRUD there and save event sourcing for the transaction ledger.

Start with the right bounded context

Event sourcing a single bounded context — the payment lifecycle — is tractable. Event sourcing your entire bank is a project that will outlive your tenure. Start with the context where the audit trail and temporal query requirements are hardest to meet with CRUD, deliver it, then expand. The pattern proves itself or fails fast on a bounded surface area.

Core model

Four concepts cover the whole model. Everything else is an implementation detail.

  • Command. An intent to change state. Commands are rejected if they violate a business rule. They contain all the data the aggregate needs to decide.
  • Aggregate. The consistency boundary. It holds the domain invariants, decides whether to accept or reject a command, and emits domain events. It never exposes its state directly to the outside world.
  • Event. A fact that happened. Immutable, past-tense, version-numbered. The event store is the only place events are persisted; you never update or delete them.
  • Projection. A read model built by replaying events. You can have as many projections as you have query shapes. Projections are disposable: if you need a new query, add a new projector and rebuild from the event history.

Payment aggregate

A payment in a KSA bank passes through a lifecycle governed by SAMA’s IPS rules and the bank’s internal fraud and liquidity controls. The aggregate encodes the lifecycle and enforces the invariants.

PaymentAggregate.javajava
@Aggregate
public class PaymentAggregate {

    @AggregateIdentifier
    private String paymentId;
    private PaymentStatus status;
    private BigDecimal amount;
    private Currency currency;
    private boolean fraudCleared;
    private boolean liquidityReserved;

    // Required by Axon
    protected PaymentAggregate() {}

    @CommandHandler
    public PaymentAggregate(InitiatePaymentCommand cmd) {
        // Validate: amount positive, currency supported
        Assert.isTrue(cmd.getAmount().compareTo(BigDecimal.ZERO) > 0,
            "Payment amount must be positive");
        AggregateLifecycle.apply(new PaymentInitiatedEvent(
            cmd.getPaymentId(), cmd.getDebtorIban(), cmd.getCreditorIban(),
            cmd.getAmount(), cmd.getCurrency(), cmd.getEndToEndId(),
            cmd.getInstructedDate()
        ));
    }

    @CommandHandler
    public void handle(ClearFraudCheckCommand cmd) {
        Assert.isTrue(status == PaymentStatus.INITIATED,
            "Fraud check only valid on INITIATED payment");
        AggregateLifecycle.apply(new FraudCheckPassedEvent(
            paymentId, cmd.getFraudScore(), cmd.getClearedBy()
        ));
    }

    @CommandHandler
    public void handle(ReserveOutboundLiquidityCommand cmd) {
        Assert.isTrue(fraudCleared,
            "Liquidity reservation requires fraud clearance");
        Assert.isTrue(!liquidityReserved,
            "Liquidity already reserved");
        AggregateLifecycle.apply(new LiquidityReservedEvent(
            paymentId, cmd.getReservationRef(), amount
        ));
    }

    @CommandHandler
    public void handle(InstructSettlementCommand cmd) {
        Assert.isTrue(liquidityReserved, "Liquidity must be reserved before settlement");
        Assert.isTrue(status != PaymentStatus.SETTLED, "Payment already settled");
        AggregateLifecycle.apply(new SettlementInstructedEvent(
            paymentId, cmd.getIpsReference(), cmd.getSettlementDate()
        ));
    }

    @EventSourcingHandler
    public void on(PaymentInitiatedEvent e) {
        paymentId = e.getPaymentId();
        status = PaymentStatus.INITIATED;
        amount = e.getAmount();
        currency = e.getCurrency();
        fraudCleared = false;
        liquidityReserved = false;
    }

    @EventSourcingHandler
    public void on(FraudCheckPassedEvent e)    { fraudCleared = true; }

    @EventSourcingHandler
    public void on(LiquidityReservedEvent e)   { liquidityReserved = true; status = PaymentStatus.LIQUIDITY_RESERVED; }

    @EventSourcingHandler
    public void on(SettlementInstructedEvent e) { status = PaymentStatus.SETTLEMENT_INSTRUCTED; }

    @EventSourcingHandler
    public void on(PaymentSettledEvent e)      { status = PaymentStatus.SETTLED; }

    @EventSourcingHandler
    public void on(PaymentReversedEvent e)      { status = PaymentStatus.REVERSED; }
}

The pattern that matters here: @CommandHandler validates and emits; @EventSourcingHandler mutates state. These two concerns must never mix. If you write this.status = SETTLED inside a command handler, you have coupled the state mutation to the command path and broken the ability to reconstruct state by replaying events.

Write side: commands & handlers

Commands flow through a command bus to the aggregate. Axon routes them by aggregate identifier. The critical constraint: every command handler either applies an event or throws — it never writes to a database, calls an external service, or makes a state mutation directly.

No side effects in command handlers

A command handler that calls a fraud-check API inline will make your aggregate non-deterministic during event replay. Move external calls to a saga or an event handler that runs outside the aggregate consistency boundary. The aggregate handles invariant enforcement; the saga handles process choreography.

InitiatePaymentCommand.javajava
public record InitiatePaymentCommand(
    @TargetAggregateIdentifier
    String     paymentId,       // UUID generated by the API gateway
    String     debtorIban,
    String     creditorIban,
    BigDecimal amount,
    String     currency,
    String     endToEndId,      // ISO 20022 end-to-end reference, caller-supplied
    LocalDate  instructedDate
) {}

// Command handler result: the event is stored and the future resolves
// Command validation failures throw CommandExecutionException (HTTP 422)

Projection patterns

A projection is a query view built by consuming the event stream. Three projection shapes cover most payment query needs.

ProjectionContentsRebuilt fromLag tolerance
Payment status viewCurrent status, latest timestamps per transitionAll events for aggregate IDSeconds (ops dashboard)
Account ledger viewBalance, pending debits/credits, settled entriesLiquidityReserved + Settled + ReversedSeconds (mobile banking)
SAMA audit viewFull event log per payment, immutable, WORMAll events, append-only insertZero: synchronous projection, before HTTP response
Fraud analytics viewFeature vector per payment attemptInitiated + FraudCheckPassed/FailedMinutes (batch scoring)
Settlement reconciliationIPS reference mapped to internal paymentSettlementInstructed + SettledMinutes (end-of-day)

The SAMA audit view is the only projection that must be synchronous with the command. All others are eventual.

PaymentStatusProjector.javajava
@Component
public class PaymentStatusProjector {

    @EventHandler
    public void on(PaymentInitiatedEvent e, @Timestamp Instant ts) {
        repo.save(new PaymentStatusRow(
            e.getPaymentId(), "INITIATED",
            e.getDebtorIban(), e.getCreditorIban(),
            e.getAmount(), e.getCurrency(), ts
        ));
    }

    @EventHandler
    public void on(FraudCheckPassedEvent e, @Timestamp Instant ts) {
        repo.updateStatus(e.getPaymentId(), "FRAUD_CLEARED", ts);
    }

    @EventHandler
    public void on(LiquidityReservedEvent e, @Timestamp Instant ts) {
        repo.updateStatus(e.getPaymentId(), "LIQUIDITY_RESERVED", ts);
    }

    @EventHandler
    public void on(PaymentSettledEvent e, @Timestamp Instant ts) {
        repo.updateStatus(e.getPaymentId(), "SETTLED", ts);
        repo.recordSettledAt(e.getPaymentId(), ts, e.getIpsReference());
    }
}

ISO 20022 schema alignment

IPS payments in Saudi Arabia use ISO 20022 message schemas — pacs.008 (credit transfer initiation), pacs.004 (payment return), camt.054 (settlement notification). The event store events do not need to be ISO 20022 payloads, but they must capture enough data to reconstruct any ISO 20022 message on demand.

The rule for schema alignment: capture the raw ISO 20022 identifiers (endToEndId, instrId, txId) as first-class fields in your events — not buried inside a serialised XML blob. This means you can reconstruct a pacs.008 from PaymentInitiatedEvent at any point without parsing an archive file, and your reconciliation queries can join on endToEndId against the IPS settlement report without an intermediate transform step.

Version your events from day one

Add a schemaVersion field to every event payload. When you add a field to PaymentInitiatedEvent six months from now, old events in the store will still deserialise correctly because your upcaster knows schemaVersion = 1 events need a default for the new field. Retrofitting versioning into an unversioned event store is painful — the cost of adding it on day one is near zero.

Snapshotting

A payment aggregate with a normal lifecycle — seven to twelve events — replays fast enough that snapshotting adds complexity without measurable benefit. Snapshot when replay latency becomes a problem, which typically means: aggregates with hundreds of events (reversals, partial payments, split flows), or latency-sensitive command paths where 200ms is the SLA and replaying 300 events on an Initiated aggregate is measurable.

  1. Configure a snapshot threshold

    Axon’s EventCountSnapshotTriggerDefinition takes a snapshot after N events. For a payment aggregate, N = 50 is a reasonable starting point. Below 50 events, the replay is cheap; above 50, the aggregate is unusual and snapshot coverage is worth the storage.

  2. Store snapshots separately

    Keep snapshots in the same PostgreSQL database as the event store, in a separate snapshots table. Never store snapshots in the event stream itself — mixing them makes replay logic conditional on event type, which is fragile.

  3. Snapshot as the full aggregate state

    The snapshot is the aggregate serialised at a given sequence number. Axon stores the aggregate class state as JSON or XStream-serialised bytes. Ensure the class is serialisable: no transient dependencies, no thread-local state.

  4. Replay still works without the snapshot

    The snapshot is a performance optimisation, not a replacement for the event history. The event log must always be complete and independently replayable. Snapshots can be deleted and rebuilt; the event log cannot.

  5. Don’t snapshot audit aggregates

    If you have a dedicated audit aggregate that captures every state transition for the SAMA trail, do not snapshot it. The entire value of that aggregate is its full event history; a snapshot collapses the history you are trying to preserve.

AxonConfiguration.java — snapshot triggerjava
@Bean
public SnapshotTriggerDefinition paymentSnapshotTrigger(
        Snapshotter snapshotter) {
    return new EventCountSnapshotTriggerDefinition(snapshotter, 50);
}

@Bean
public AggregateFactory<PaymentAggregate> paymentAggregateFactory(
        SnapshotTriggerDefinition trigger) {
    return GenericAggregateFactory
        .forType(PaymentAggregate.class)
        .withSnapshotTriggerDefinition(trigger);
}

Idempotency & exactly-once

Exactly-once command processing requires three things working together: an idempotency key on every command, a deduplication store, and a transaction boundary that spans the event-store append and the deduplication record creation.

The payment layer at a KSA bank processes IPS transactions where the IPS network may replay a pacs.008 on network timeout. Without idempotency, a replayed initiation creates a duplicate payment. The end-to-end reference (EndToEndId) from the ISO 20022 message is the natural idempotency key — it is set by the originator and must be unique per transaction per day.

The duplicate payment trap

Using paymentId (your internal UUID) as the idempotency key only works if the caller supplies the same UUID on retry. Most client libraries on network failure generate a new UUID and retry. The safe key is the caller-supplied endToEndId. Validate its uniqueness at the command handler entry point, before the aggregate is loaded.

IdempotencyInterceptor.javajava
@Component
public class IdempotencyInterceptor implements MessageHandlerInterceptor<CommandMessage<?>> {

    @Override
    public Object handle(UnitOfWork<? extends CommandMessage<?>> uow,
                         InterceptorChain chain) throws Exception {

        if (uow.getMessage().getPayload() instanceof InitiatePaymentCommand cmd) {
            String key = cmd.endToEndId();
            if (idempotencyStore.exists(key)) {
                // Return the stored result without re-processing
                return idempotencyStore.getResult(key);
            }
            Object result = chain.proceed();
            idempotencyStore.store(key, result);
            return result;
        }
        return chain.proceed();
    }
}

The idempotency store is a payment_idempotency table with a unique constraint on end_to_end_id. The insert and the event-store append must run in the same database transaction. If the event store is PostgreSQL, this is straightforward. If the event store is a separate service (EventStoreDB), you need a saga-based idempotency pattern or an outbox on the event store commit.

SAMA audit trail

SAMA’s Technology Risk Management framework requires financial institutions to maintain a tamper-evident record of all transactions with the ability to reconstruct any transaction state at any point in time, retained for at least ten years.

Event sourcing satisfies this requirement structurally if — and only if — the event store is configured as append-only with no delete or update path. Two implementation decisions matter:

  • Append-only at the database level. Revoke DELETE and UPDATE on the events table for the application user. The application can only INSERT. Even if a developer makes a mistake in application code, the database rejects the mutation.
  • Immutable timestamps. Event timestamps are set by the server at insert time, not by the application. The events table has a recorded_at TIMESTAMPTZ DEFAULT NOW() column that the application cannot set — this prevents backdating.
event_store DDL — PostgreSQLsql
CREATE TABLE domain_event_entry (
    global_index   BIGSERIAL    PRIMARY KEY,
    aggregate_id   VARCHAR(255) NOT NULL,
    type           VARCHAR(255) NOT NULL,
    sequence_number BIGINT      NOT NULL,
    event_type     VARCHAR(255) NOT NULL,
    payload        JSONB        NOT NULL,
    metadata       JSONB        NOT NULL,
    recorded_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    UNIQUE (aggregate_id, sequence_number)
);

-- Revoke mutation access from the application user
REVOKE UPDATE, DELETE, TRUNCATE ON domain_event_entry FROM payments_app;

-- Auditors and SIEM ingestion via a read-only role
GRANT SELECT ON domain_event_entry TO audit_reader;

For the SIEM forwarding requirement, a Change Data Capture connector (Debezium) on the events table feeds every insert to a Kafka topic. A Vector pipeline forwards the topic to Splunk via HEC. This means the SIEM has a near-real-time copy of the event log, independent of the application — even if the application is compromised, the audit copy in the SIEM pre-dates the compromise.

Kafka-backed projectors

For a single payment service with one projector, running projections in-process with Axon’s event bus is fine. When you have multiple services that need payment events — a fraud analytics engine, a notification service, a settlement reconciler — the pattern shifts: the event store publishes to Kafka, and each consumer service runs its own projector against the Kafka topic.

Partition the Kafka topic by aggregateId. This guarantees that all events for a given payment arrive at the same consumer instance in sequence — a requirement for stateful projectors that maintain per-payment running totals or enforce ordering invariants in the read model.

Log compaction is not the audit copy

Log compaction on the Kafka topic is useful for keeping the topic size manageable, but it discards intermediate events if the key appears again. Never use a compacted topic as the primary audit trail — the event store database is the audit copy. Kafka is the fan-out mechanism; PostgreSQL is the source of truth.

ES vs CRUD trade-offs

DimensionEvent SourcingCRUD
Audit trailBuilt-in, tamper-evident, freeSide-effect, bolt-on, fragile
Temporal queries (“state at T”)Replay to sequence N, exactRequires archive tables or CDC history
Write complexityHigh: aggregate, command, event modelLow: validate + insert/update
Read complexityHigh: eventual consistency, rebuild on schema changeLow: query current state directly
Schema migrationEvent upcasters, non-destructiveALTER TABLE, destructive if not careful
Storage growthUnbounded (append-only)Bounded (current state only)
Query flexibilityHigh: add projectors without touching the aggregateMedium: limited to current schema without migrations
Team learning curveSteep: new mental model for every developerShallow: SQL is universal
Regulator evidenceStrong: event log is self-describingWeak: requires additional tooling
Suitable domainsPayment ledger, account history, audit-intensiveProduct catalogue, reference data, session state

Anti-patterns

Aggregate too large

If your PaymentAggregate handles forty-plus event types and its @EventSourcingHandler methods span hundreds of lines, you have one aggregate doing work that belongs in several. Split by consistency boundary: the payment lifecycle is one aggregate; the settlement instruction is another; the fraud scoring result is a value object on the fraud check event, not a nested aggregate.

Mutable events

Allowing UPDATE on the event store table — even for “just a correction” — invalidates the audit trail and makes any projection that consumed the original event potentially inconsistent with the store. Corrections belong as new, explicit events: PaymentAmountCorrectedEvent with the correction reason and the correcting user ID, applied on top of the original event stream.

Projecting synchronously on the command path

With the exception of the SAMA audit projection, projecting to a read model synchronously on the command path couples the write latency to the read-model build latency. A slow projection (joining to an external service, building a complex denormalised view) makes every command slow. Project asynchronously, accept eventual consistency, and document the lag SLA for each query view.