| name | event-sourcing |
| description | Persist state as an append-only sequence of domain events with snapshots and projections. Use when audit, temporality, or replayability is a first-class requirement. |
Event Sourcing
Store every state change as an immutable, ordered event. Rebuild current state by replay, derive read models via projections, and enforce idempotency at every consumer boundary.
Stack Baseline (2026)
| Concern | Recommended |
|---|
| Event store | EventStoreDB 24, Axon Server 2024.1, Marten on Postgres 17, KurrentDB |
| Streaming bus | Kafka 3.7 (KRaft), Pulsar 3.3, Redpanda |
| Schema | Avro / Protobuf in Confluent Schema Registry |
| Projection runtime | Flink 2.0, Kafka Streams, Materialize |
| Read model store | Postgres 17, ClickHouse, OpenSearch, Redis |
| CQRS framework | Axon, EventFlow, Wolverine, Marten |
| Outbox | Debezium 2.6 outbox SMT |
| Observability | OpenTelemetry traces on commandId+correlationId |
When to Use
- Domains with regulatory audit (finance, healthcare, supply chain).
- Need to project the same facts into many shapes.
- Temporal queries: "what did we know at time T?".
- High-write domains where append-only beats in-place updates.
Do not use when
- Simple CRUD with no audit need.
- Team unwilling to own schema evolution and replay tooling.
Prerequisites
- Bounded contexts identified via EventStorming.
- Event schemas in a registry with backward compatibility.
- Idempotency keys on every command and consumer.
- Snapshot policy defined per aggregate.
Instructions
1. Define event types per aggregate
{
"type": "record",
"name": "OrderPlaced",
"namespace": "com.acme.orders.v1",
"fields": [
{ "name": "orderId", "type": "string" },
{ "name": "customerId", "type": "string" },
{ "name": "lineItems", "type": { "type": "array", "items": "LineItem" } },
{ "name": "totalCents", "type": "long" },
{ "name": "occurredAt", "type": { "type": "long", "logicalType": "timestamp-micros" } },
{ "name": "correlationId", "type": "string" },
{ "name": "causationId", "type": ["null","string"], "default": null }
]
}
Events are facts in past tense, named after the business outcome.
2. Apply the command -> event -> projection flow
sequenceDiagram
actor User
participant API
participant Aggregate
participant Store as EventStore
participant Bus as Kafka
participant Proj as Projection
participant Read as ReadModel
User->>API: PlaceOrder(cmd, idemKey)
API->>Aggregate: load(streamId)
Aggregate->>Store: append [OrderPlaced] expectedVersion
Store-->>Bus: publish via outbox
Bus->>Proj: OrderPlaced
Proj->>Read: upsert order_summary
3. Persist with optimistic concurrency
CREATE TABLE events (
stream_id uuid NOT NULL,
version bigint NOT NULL,
type text NOT NULL,
data jsonb NOT NULL,
metadata jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (stream_id, version)
);
4. Snapshot to bound replay cost
Snapshot every N events or when load latency exceeds SLO. Store snapshot version, hash of event schema set, and aggregate state.
5. Build idempotent projections
def handle(event, ctx):
if ctx.already_processed(event.id):
return
upsert_read_model(event)
ctx.commit_checkpoint(event.offset)
Always upsert by deterministic key. Never side-effect outside the transaction.
6. Plan replays and corrections
- Versioned projections deploy alongside the old; switch reads when caught up.
- Use compensating events (
OrderRefunded) instead of mutating history.
- Keep an event upcaster registry for v1 -> v2 transformations.
7. Publish externally via outbox
Use Debezium outbox SMT to emit to Kafka so the event store and the bus stay consistent without 2PC.
Common Pitfalls
| Pitfall | Why it hurts | Mitigation |
|---|
CRUD events (OrderUpdated) | No business meaning, bad replay | Name events as outcomes |
| Mutating past events | Breaks audit and projections | Append corrective events |
| No snapshots | Replay grows unbounded | Snapshot policy from day 1 |
| Non-idempotent consumers | Duplicate side effects | Idempotency key + dedupe table |
| Sharing the event store across contexts | Coupling, ownership chaos | One stream namespace per context |
| 2PC between DB and Kafka | Fragile | Outbox + CDC |
| Treating ES as a silver bullet | Cognitive overhead for CRUD | Use only where audit/replay pays off |
Output Format
Deliver: aggregate map, event catalog with Avro/Protobuf schemas, event-store DDL, snapshot policy, projection list with checkpoint strategy, outbox + Debezium config, and a replay runbook.
Authoritative References