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 doesstatus = 3mean?” 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.
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.
@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.
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.
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.
| Projection | Contents | Rebuilt from | Lag tolerance |
|---|---|---|---|
| Payment status view | Current status, latest timestamps per transition | All events for aggregate ID | Seconds (ops dashboard) |
| Account ledger view | Balance, pending debits/credits, settled entries | LiquidityReserved + Settled + Reversed | Seconds (mobile banking) |
| SAMA audit view | Full event log per payment, immutable, WORM | All events, append-only insert | Zero: synchronous projection, before HTTP response |
| Fraud analytics view | Feature vector per payment attempt | Initiated + FraudCheckPassed/Failed | Minutes (batch scoring) |
| Settlement reconciliation | IPS reference mapped to internal payment | SettlementInstructed + Settled | Minutes (end-of-day) |
The SAMA audit view is the only projection that must be synchronous with the command. All others are eventual.
@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.
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.
-
Configure a snapshot threshold
Axon’s
EventCountSnapshotTriggerDefinitiontakes 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. -
Store snapshots separately
Keep snapshots in the same PostgreSQL database as the event store, in a separate
snapshotstable. Never store snapshots in the event stream itself — mixing them makes replay logic conditional on event type, which is fragile. -
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.
-
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.
-
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.
@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.
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.
@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
DELETEandUPDATEon the events table for the application user. The application can onlyINSERT. 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.
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 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
| Dimension | Event Sourcing | CRUD |
|---|---|---|
| Audit trail | Built-in, tamper-evident, free | Side-effect, bolt-on, fragile |
| Temporal queries (“state at T”) | Replay to sequence N, exact | Requires archive tables or CDC history |
| Write complexity | High: aggregate, command, event model | Low: validate + insert/update |
| Read complexity | High: eventual consistency, rebuild on schema change | Low: query current state directly |
| Schema migration | Event upcasters, non-destructive | ALTER TABLE, destructive if not careful |
| Storage growth | Unbounded (append-only) | Bounded (current state only) |
| Query flexibility | High: add projectors without touching the aggregate | Medium: limited to current schema without migrations |
| Team learning curve | Steep: new mental model for every developer | Shallow: SQL is universal |
| Regulator evidence | Strong: event log is self-describing | Weak: requires additional tooling |
| Suitable domains | Payment ledger, account history, audit-intensive | Product catalogue, reference data, session state |
Anti-patterns
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.
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.
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.