Event Sourcing (ES): don't store the current state of a thing and mutate it in place — store the full, append-only sequence of events that happened to it, and derive current state by replaying (folding) those events. The event log is the source of truth; state is a cached projection of it.
Everything else in this document is the consequences of that one idea: how you append safely, how you rebuild state, how you keep it fast, how you query it, where Kafka and Postgres each fit, and how you keep an immutable log alive for years without it turning into a liability.
Scope note: code is Kotlin + Spring Boot throughout (you asked for Kotlin; this matches your CQRS guide). Where a snippet is idiomatic in a specific version — e.g. JdbcClient needs Spring Boot 3.2+ — it's flagged. PostgreSQL is the event store (system of record); Kafka is the distribution backbone that carries events to other services and read models. That split is deliberate and defended in §8. This guide is the natural sequel to your CQRS, inbox/outbox, and CDC guides and cross-references them.
Table of contents#
- TL;DR cheat sheet
- The mental model: state is a left-fold over events
- Core vocabulary
- The write path:
decideandevolve - The event store on PostgreSQL
- Optimistic concurrency and the consistency boundary
- Snapshots: taming long streams
- Projections and read models (the CQRS pairing)
- Where Kafka fits — and where it doesn't
- Event versioning and schema evolution
- GDPR vs. the immutable log: crypto-shredding
- Problems and how to solve them
- When to use it — and when not to
- Frameworks and build-vs-buy
- Worked example, end to end
- How to sound senior
- Interview Q&A
- Cheat-sheet
- Glossary
- Self-test
- Further reading
TL;DR cheat sheet#
| Question | Short answer |
|---|---|
| What is it? | Persist an append-only log of events; derive current state by replaying them. The log is the source of truth. |
| One-line intuition | state = events.fold(empty) { s, e -> evolve(s, e) }. |
| Is it the same as CQRS? | No — orthogonal. ES is how you persist writes; CQRS is separating read/write models. They pair beautifully but are independent decisions. |
| Is Kafka the event store? | Usually no. Kafka is a poor system-of-record for aggregates (no per-aggregate optimistic concurrency, awkward single-stream loads). Store events in Postgres; publish them to Kafka. |
| What's the write-side invariant mechanism? | Optimistic concurrency on (stream_id, version) — append fails if someone else advanced the stream. |
| Why is replay not slow? | It can be — that's what snapshots fix (periodic serialized state so you replay only the tail). |
| Biggest long-term risk? | Event schema evolution. Events are immutable and live forever; you will need versioning/upcasting. |
| Can I delete personal data? | Not by deleting log rows. Use crypto-shredding (encrypt PII per-subject; throw away the key). |
| Killer follow-up topics | Optimistic concurrency & retries, snapshots, projection rebuilds, outbox/CDC to Kafka, upcasting, set-based uniqueness, GDPR. |
| When not to? | Simple CRUD with no audit/temporal need. ES is a serious, mostly-irreversible commitment. |
1. The mental model: state is a left-fold over events#
Traditional ("state-oriented") persistence stores where you are. Event sourcing stores how you got there and computes where you are on demand.
Think of a bank account. State-oriented persistence keeps one row: balance = 140. Event sourcing keeps the history:
AccountOpened(id=A1, currency=EUR)
MoneyDeposited(A1, 100)
MoneyDeposited(A1, 50)
MoneyWithdrawn(A1, 10)
Current balance isn't stored — it's derived by folding the events through a function that knows how each event changes state:
// The entire essence of event sourcing in one expression:
val balance = events.fold(0L) { acc, e ->
when (e) {
is MoneyDeposited -> acc + e.amount
is MoneyWithdrawn -> acc - e.amount
else -> acc
}
} // 0 → 100 → 150 → 140
That's it. evolve (a.k.a. apply) is a pure function (State, Event) -> State; current state is the left-fold of evolve over the stream. Every other concept in event sourcing is machinery to make that fold correct, fast, safe under concurrency, and queryable.
Why store the journey instead of the destination?#
| State-oriented (CRUD) | Event-sourced | |
|---|---|---|
| Source of truth | Current row(s) | The event log |
| History | Lost on update (unless you bolt on audit tables) | Intrinsic — the log is the history |
| "Why is it in this state?" | Unanswerable after the fact | Answerable — every change is a recorded fact with intent |
| Temporal queries ("balance last Tuesday?") | Need extra machinery | Fold events up to a timestamp |
| Read shape | The one you designed for | Any number of projections, rebuildable at will |
| New read model over old data | Backfill by hand | Replay the log into it |
| Delete/mutate a record | UPDATE/DELETE | Append a new event (the log is immutable) |
| Failure of an accidental overwrite | Data gone | Impossible — nothing is overwritten |
| Storage | Small | Larger (every change kept) |
| Cognitive & operational load | Low | High — this is the real cost |
The three things ES buys you that are genuinely hard to retrofit: a perfect audit trail (it's not a feature you add, it's the storage model), time travel (reconstruct any past state), and retroactive read models (invent a new query shape next year and replay five years of history into it). If your domain doesn't value at least one of those, ES is probably overkill — see §12.
What is an "event," precisely?#
A domain event is an immutable fact that already happened, named in the past tense, carrying the data that changed:
- ✅
MoneyDeposited,OrderPlaced,InvoiceApproved,AddressCorrected - ❌
DepositMoney(that's a command — an intent that can still be rejected),UpdateBalance(data-shaped, no intent),BalanceChangedEvent(anemic — hides why)
Key properties:
- Immutable. Once appended, an event is never updated or deleted. Corrections are new events (
AddressCorrected), never edits. - Past tense = already decided. A command can be refused; an event is a settled fact. Don't confuse the two (a very common interview slip).
- Carries intent, not just deltas.
PriceOverriddenByManagerandPriceReducedByCouponmight both change a number by the same amount — but they mean different things, and next year someone will want to query one and not the other. Model the why. - Belongs to exactly one aggregate/stream and has a monotonic version within that stream.
2. Core vocabulary#
Skim this once; the rest of the guide uses these terms precisely.
| Term | Meaning |
|---|---|
| Event | Immutable, past-tense fact that happened to one aggregate. |
| Stream | The ordered sequence of events for one aggregate instance (e.g. account-A1). The unit of consistency and of replay. |
| Aggregate | DDD consistency boundary — the object whose invariants must hold atomically. One aggregate ↔ one stream. |
| Version / sequence | Per-stream monotonically increasing number. The basis of optimistic concurrency. |
| Global position | A store-wide monotonic number across all streams, used to drive subscriptions/projections in order. |
| Command | A request to change state (Withdraw). Can be validated and rejected. Not stored (usually). |
decide / command handler | Pure function (State, Command) -> List<Event>: given current state, what new facts does this command produce (or which rule does it violate)? |
evolve / apply | Pure function (State, Event) -> State: how each event advances state. The thing you fold. |
| Rehydration / replay | Rebuilding an aggregate's current state by folding evolve over its stream. |
| Snapshot | A serialized copy of state at version N, so you replay only events after N. A disposable cache. |
| Projection / read model | A query-optimized structure built by reacting to events. The bridge to CQRS. |
| Subscription | A feed of events (per-stream or global) that projectors and integrations consume, tracking a checkpoint. |
| Upcasting | Transforming an old event version into the current shape at read time (schema evolution). |
| Domain event vs. integration event | Internal, fine-grained event in your store vs. the coarser, contract-stable event you publish to other services (e.g. via Kafka). |
3. The write path: decide and evolve (the functional core)#
The cleanest way to think about an event-sourced aggregate — and the one that reads as senior in an interview — is the functional "Decider": two pure functions plus a fold.
Command Event(s)
│ ▲
▼ │
decide(state, command) ── produces ──▶ List<Event> ┘
▲ │
│ ▼
current state ◀── fold(evolve) ── the event stream
decide(state, command): List<Event>— the business rules. It validates the command against current state and either returns the event(s) that should be recorded, or rejects (throws / returns a typed error). It does not mutate anything and does not do I/O.evolve(state, event): State— advances state for one event. Pure. Used both to rebuild state before deciding and to apply freshly-decided events.
Because both are pure, the aggregate is trivially unit-testable ("given these past events, when this command, then these new events") with no database in sight.
The bank account as a Decider#
// ---- Events (immutable facts) ----
sealed interface AccountEvent
data class AccountOpened(val accountId: UUID, val currency: String, val overdraftLimit: Long) : AccountEvent
data class MoneyDeposited(val amount: Long) : AccountEvent
data class MoneyWithdrawn(val amount: Long) : AccountEvent
data class AccountClosed(val reason: String) : AccountEvent
// ---- Commands (intents that may be rejected) ----
sealed interface AccountCommand
data class OpenAccount(val accountId: UUID, val currency: String, val overdraftLimit: Long = 0) : AccountCommand
data class Deposit(val amount: Long) : AccountCommand
data class Withdraw(val amount: Long) : AccountCommand
data class CloseAccount(val reason: String) : AccountCommand
// ---- State (the fold target) ----
sealed interface Account {
data object Empty : Account // no events yet
data class Open(
val id: UUID,
val currency: String,
val balance: Long,
val overdraftLimit: Long,
) : Account
data class Closed(val id: UUID) : Account
}
class AccountRejection(msg: String) : RuntimeException(msg)
// ---- evolve: (State, Event) -> State ---- pure, total, no I/O
fun evolve(state: Account, event: AccountEvent): Account = when (state) {
is Account.Empty -> when (event) {
is AccountOpened -> Account.Open(event.accountId, event.currency, 0, event.overdraftLimit)
else -> state // ignore anything before open
}
is Account.Open -> when (event) {
is MoneyDeposited -> state.copy(balance = state.balance + event.amount)
is MoneyWithdrawn -> state.copy(balance = state.balance - event.amount)
is AccountClosed -> Account.Closed(state.id)
is AccountOpened -> state // already open; ignore
}
is Account.Closed -> state // terminal
}
// ---- decide: (State, Command) -> List<Event> ---- the business rules
fun decide(state: Account, command: AccountCommand): List<AccountEvent> = when (command) {
is OpenAccount -> {
require(state is Account.Empty) { "already opened" }
require(command.amountsValid()) { "invalid overdraft" }
listOf(AccountOpened(command.accountId, command.currency, command.overdraftLimit))
}
is Deposit -> {
state.requireOpen() // must be open, else rejected
if (command.amount <= 0) throw AccountRejection("amount must be positive")
listOf(MoneyDeposited(command.amount))
}
is Withdraw -> {
val open = state.requireOpen()
if (command.amount <= 0) throw AccountRejection("amount must be positive")
// the invariant that makes ES concurrency real: don't overdraw
if (open.balance - command.amount < -open.overdraftLimit)
throw AccountRejection("INSUFFICIENT_FUNDS")
listOf(MoneyWithdrawn(command.amount))
}
is CloseAccount -> {
val open = state.requireOpen()
if (open.balance != 0L) throw AccountRejection("balance must be zero to close")
listOf(AccountClosed(command.reason))
}
}
private fun Account.requireOpen(): Account.Open =
this as? Account.Open ?: throw AccountRejection("account not open")
private fun OpenAccount.amountsValid() = overdraftLimit >= 0
// ---- rehydrate: fold evolve over the stream ----
fun rehydrate(history: List<AccountEvent>): Account =
history.fold(Account.Empty as Account, ::evolve)
Notice what's absent: no Spring, no JDBC, no Kafka. The domain is a pure function of (past events, command) → new events. That purity is the whole reason event sourcing composes so well — persistence is a thin shell around it, which we build next.
The application-service shell#
The service loads the stream, folds it, decides, and appends — nothing more:
class AccountCommandService(private val store: EventStore) {
fun handle(accountId: UUID, command: AccountCommand) {
val streamId = "account-$accountId"
val loaded = store.load(streamId) // events + current version
val state = rehydrate(loaded.events) // fold
val newEvents = decide(state, command) // business rules (may throw)
store.append( // append with expected version
streamId = streamId,
expectedVersion = loaded.version, // optimistic concurrency (see §5)
events = newEvents,
)
}
}
If two requests race, one of them appends against a stale expectedVersion and is rejected — no lost updates, no overdrawn account. That mechanism is the event store's job, next.
4. The event store on PostgreSQL#
You do not need a specialized product to do event sourcing. A single Postgres table is a perfectly good, production-grade event store — this is exactly how tools like Marten (.NET) work under the hood, and it keeps you in one familiar, transactional, backup-friendly system. Here's the whole thing.
The schema#
CREATE TABLE event_store (
global_position BIGINT GENERATED ALWAYS AS IDENTITY, -- store-wide ordering
stream_id TEXT NOT NULL, -- e.g. 'account-<uuid>'
version INT NOT NULL, -- per-stream, 1,2,3,...
event_type TEXT NOT NULL, -- 'MoneyDeposited' (+ version, see §9)
payload JSONB NOT NULL, -- the event data
metadata JSONB NOT NULL DEFAULT '{}', -- correlation id, causation id, user, etc.
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (global_position),
-- THE optimistic-concurrency guard: two writers cannot both create version N.
-- Bonus: this UNIQUE constraint's backing btree index IS (stream_id, version),
-- which is exactly the index needed to load a stream in order — no extra index required.
CONSTRAINT uq_stream_version UNIQUE (stream_id, version)
);
Three design decisions worth defending in an interview:
UNIQUE (stream_id, version)is the invariant enforcer. Concurrency control is free and correct because the database refuses a duplicate(stream, version). No locks, noSELECT ... FOR UPDATEon the hot path.JSONBpayload gives schema flexibility (events evolve — §9) while remaining queryable/indexable. The alternative, aBYTEAof Avro/Protobuf, buys you a compact binary contract at the cost of ad-hoc queryability; fine choice for high volume, and it pairs naturally with a schema registry.metadatacarriescorrelationId(ties together everything caused by one originating request) andcausationId(the id of the message that directly caused this one). These are what make a distributed system debuggable; add them from day one.
Append with optimistic concurrency#
data class StoredStream(val events: List<AccountEvent>, val version: Int)
interface EventStore {
fun load(streamId: String): StoredStream
fun append(streamId: String, expectedVersion: Int, events: List<AccountEvent>)
}
class ConcurrencyException(msg: String) : RuntimeException(msg)
class PostgresEventStore(
private val jdbc: JdbcClient, // Spring Boot 3.2+; use NamedParameterJdbcTemplate on older
private val mapper: ObjectMapper,
) : EventStore {
override fun load(streamId: String): StoredStream {
val rows = jdbc.sql(
"SELECT version, event_type, payload FROM event_store WHERE stream_id = :s ORDER BY version",
).param("s", streamId).query { rs, _ ->
val type = rs.getString("event_type")
rs.getInt("version") to deserialize(type, rs.getString("payload"))
}.list()
val version = rows.lastOrNull()?.first ?: 0
return StoredStream(rows.map { it.second }, version)
}
override fun append(streamId: String, expectedVersion: Int, events: List<AccountEvent>) {
if (events.isEmpty()) return
var version = expectedVersion
try {
for (event in events) {
version += 1
jdbc.sql(
"""
INSERT INTO event_store (stream_id, version, event_type, payload, metadata)
VALUES (:s, :v, :t, CAST(:p AS jsonb), CAST(:m AS jsonb))
""".trimIndent(),
).param("s", streamId)
.param("v", version)
.param("t", eventType(event)) // includes schema version, see §9
.param("p", mapper.writeValueAsString(event))
.param("m", currentMetadataJson())
.update()
}
} catch (e: DuplicateKeyException) {
// someone else advanced the stream between our load and append
throw ConcurrencyException("stream $streamId modified concurrently; expected v$expectedVersion")
}
}
// deserialize / eventType / currentMetadataJson elided
}
The DuplicateKeyException on uq_stream_version is the concurrency conflict. The whole append is one transaction, so either all events in the batch land or none do — the aggregate stays atomic.
The subtle part: global ordering and the "sequence gap" trap#
Projections and Kafka publishers consume events in order by polling WHERE global_position > :lastSeen ORDER BY global_position. There's a classic, senior-level gotcha here: IDENTITY/sequence values are assigned before commit, and transactions commit out of order.
Concretely: transaction T1 grabs global_position = 100, T2 grabs 101 and commits first. A poller running at that instant sees 101, advances its checkpoint past 100, and then T1 commits 100 — which the poller will never read. You silently drop an event. This bites teams that hand-roll polling and don't know to look for it.
Standard mitigations (know at least two):
- Don't poll the event table for global order at all — use a transactional outbox / CDC. Debezium tailing the WAL delivers changes in commit order, sidestepping the gap entirely. This is why ES + Kafka is usually wired through CDC or the outbox pattern (your existing guides) rather than a naive
SELECT. This is the recommended default. - Gap-aware polling: track seen positions and don't advance the low-water mark past a gap until it fills (or a timeout declares it permanently skipped by a rolled-back txn). More code, easy to get subtly wrong.
- Serialize position assignment to commit order using an
AFTERtrigger + a separate monotonic counter, or a dedicated sequencing step — adds contention.
Interview signal: volunteering the "sequence values commit out of order, so naive polling skips events" problem — and naming CDC/outbox as the clean fix — is a strong senior tell. Most candidates assume
bigserialgives them a safe global order. It doesn't.
5. Optimistic concurrency and the consistency boundary#
Two ideas that a staff interviewer will push on:
The aggregate is the transactional boundary. Everything inside one aggregate's stream is strongly consistent and updated in a single append transaction. Anything across aggregates is eventually consistent and must be coordinated with a saga (your saga guide) — not a distributed transaction. So aggregate design is consistency design: draw the boundary around exactly the invariants that must hold together, and no wider. Too big → contention and long streams; too small → invariants you can't actually enforce atomically.
Optimistic, not pessimistic, concurrency. You don't lock the stream while a user thinks. You load at version N, and your append says "insert version N+1 only if it doesn't already exist." If a concurrent writer got there first, the unique constraint rejects you and you retry the whole command against the fresh state:
fun handleWithRetry(accountId: UUID, command: AccountCommand, maxAttempts: Int = 3) {
repeat(maxAttempts) { attempt ->
try {
handle(accountId, command) // load → decide → append
return
} catch (e: ConcurrencyException) {
if (attempt == maxAttempts - 1) throw e
// loop: reload the now-newer stream, re-decide, re-append
}
}
}
Retrying is safe because decide is pure and re-evaluates the invariant against the latest state. A withdrawal that was fine at version 5 might correctly be rejected after reloading at version 6 (someone else drained the account first). That's the system working, not failing.
Trap: "just use
SELECT … FOR UPDATE." You can serialize writers pessimistically, but you lose throughput and gain deadlock risk, and you still need the version for subscriptions. Optimistic concurrency via the unique constraint is the idiomatic ES answer.
6. Snapshots: taming long streams#
Replaying works beautifully until a stream has 200,000 events (a busy account, a long-running device). Folding a huge history on every command gets slow. Snapshots are the fix: periodically persist the serialized state at a version, then to rehydrate you load the latest snapshot plus only the events after it.
CREATE TABLE snapshots (
stream_id TEXT NOT NULL,
version INT NOT NULL, -- the version this snapshot reflects
state JSONB NOT NULL, -- serialized aggregate state
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (stream_id, version)
);
// A refinement of §4: with snapshots it's natural for the store to fold internally
// and hand back ready state + version, instead of the raw event list.
data class LoadedAggregate(val state: Account, val version: Int)
fun load(streamId: String): LoadedAggregate {
val snap = latestSnapshot(streamId) // may be null
val fromVersion = snap?.version ?: 0
val tail = eventsAfter(streamId, fromVersion) // only the events after the snapshot
val start = snap?.state ?: Account.Empty // resume from snapshot, or empty
val state = tail.fold(start, ::evolve) // fold snapshot + tail only
val version = tail.lastOrNull()?.version ?: fromVersion
return LoadedAggregate(state, version)
}
// the command service now uses loaded.state directly (no separate rehydrate call):
// val loaded = store.load(streamId)
// val events = decide(loaded.state, command)
// store.append(streamId, loaded.version, events)
// write a snapshot every N events (out of band — a scheduled job, or right after append)
fun maybeSnapshot(streamId: String, state: Account, version: Int, every: Int = 100) {
if (version % every == 0) saveSnapshot(streamId, version, state)
}
Non-negotiable rules for snapshots:
- A snapshot is a cache, never the source of truth. It must be fully derivable by replaying events. You can delete every snapshot and lose nothing but speed.
- Snapshots are versioned by the state schema too. If the shape of
Accountchanges, old snapshots are invalid — the safe move is to ignore/discard snapshots you can't deserialize and rebuild from events. Never let a snapshot become un-reproducible. - Don't snapshot too eagerly. They cost storage and write I/O. Snapshot the few streams that are actually long, not everything. A common heuristic: every 50–200 events, or only when
tail length > thresholdat load time.
Interview framing: "Isn't replay slow?" → "For long streams, yes — so you snapshot: rehydrate from the latest snapshot plus the tail. But the snapshot is a disposable optimization; the events remain the only source of truth." That answer shows you understand both the problem and that you didn't compromise the model to fix it.
7. Projections and read models (the CQRS pairing)#
Here's the hard truth that makes CQRS almost mandatory alongside event sourcing: an event log is a terrible thing to query. "Show me all open accounts with a negative balance, sorted by owner" is trivial against a table and absurd against an append-only log — you'd replay every stream. So event-sourced systems build projections: query-optimized read models kept up to date by reacting to events.
This is the exact CQRS machinery from your CQRS guide, so I'll keep it tight and focus on what's ES-specific. If §7 feels familiar, that's the point — ES is the write side; CQRS is how you make it queryable.
A projector, and the three properties it must have#
class AccountSummaryProjector(
private val jdbc: JdbcClient,
private val checkpoints: CheckpointStore,
) {
// Driven by an ordered subscription: each event arrives with its global_position AND its
// streamId. Note the aggregate id comes from the STREAM, not repeated in every event payload.
fun on(position: Long, streamId: String, event: AccountEvent) {
val accountId = streamId.removePrefix("account-") // id lives on the envelope/stream
when (event) {
is AccountOpened ->
jdbc.sql(
"""
INSERT INTO account_summary (account_id, currency, balance, status)
VALUES (:id, :ccy, 0, 'OPEN')
ON CONFLICT (account_id) DO NOTHING -- idempotent: redelivery-safe
""".trimIndent(),
).param("id", accountId).param("ccy", event.currency).update()
is MoneyDeposited ->
jdbc.sql("UPDATE account_summary SET balance = balance + :a WHERE account_id = :id")
.param("a", event.amount).param("id", accountId).update()
is MoneyWithdrawn ->
jdbc.sql("UPDATE account_summary SET balance = balance - :a WHERE account_id = :id")
.param("a", event.amount).param("id", accountId).update()
is AccountClosed ->
jdbc.sql("UPDATE account_summary SET status = 'CLOSED' WHERE account_id = :id")
.param("id", accountId).update()
}
checkpoints.save("account_summary", position) // remember where we are
}
}
- Idempotent. Delivery is at-least-once; the same event will arrive twice after a crash/redeploy. Use upserts (
ON CONFLICT) and/or skip events at or below the saved checkpoint. A non-idempotent projector silently corrupts the read model. - Ordered (per stream at least). Balance math must apply deposits/withdrawals in stream order. A global-ordered subscription gives you this for free; a partitioned one (Kafka) needs partition-by-aggregate (§8).
- Checkpointed. Persist the last-applied
global_positionso a restart resumes instead of reprocessing from zero.
The superpower: rebuilds and brand-new read models#
Because a projection is a pure function of the event history, it is disposable:
- Bug in a projection? Fix the code, truncate the read table, reset the checkpoint to 0, replay. The source of truth never moved.
- Need a read model that didn't exist when the data was created? Build the table, replay five years of events into it, and you have a fully-populated view of history you never explicitly recorded for that purpose. Try doing that with CRUD.
This "reshape history retroactively" ability is one of the top three reasons to adopt ES at all (§1). See your CQRS guide for read-your-own-writes handling under the resulting eventual consistency.
How does the projector get its ordered feed?#
Two families, and picking correctly is a §8 topic:
- In-process catch-up subscription: the projector polls
event_store WHERE global_position > checkpoint ORDER BY global_position. Simple, strongly co-located with the store — but subject to the sequence-gap trap (§4). Fine for in-process read models if you handle gaps (or drive it from CDC). - Via a broker (Kafka): events are published to a topic and projectors are consumers. This decouples projections (even in other services/languages) but adds the publish step — which must be reliable. That's next.
8. Where Kafka fits — and where it doesn't#
This is the section that most cleanly separates a candidate who has run an event-sourced system from one who read a blog post. The single most common ES misconception is "Kafka is my event store." Usually it should not be.
Why Kafka is a poor system-of-record event store#
Event sourcing's write path needs things a log broker doesn't give you well:
| ES write-path need | Postgres event store | Kafka |
|---|---|---|
| Load one aggregate's stream to rehydrate | WHERE stream_id = ? ORDER BY version — indexed, cheap | No random access by key; you'd scan/replay a partition or keep a stream-per-topic (unbounded topics) |
| Optimistic concurrency ("append iff version = N") | UNIQUE (stream_id, version) — atomic, built-in | No conditional append by per-key version; can't reject a concurrent writer |
| Atomic multi-event append for one command | One SQL transaction | No multi-partition transaction semantics aligned to an aggregate |
| Strong consistency / read-your-writes on the write model | Yes | Consumer lag; asynchronous by nature |
| Query/inspect/repair events operationally | SQL | Awkward |
| Retention = forever (the log is truth) | Normal | Requires infinite retention or compaction; compaction drops superseded keys, which can eat history if misused |
Kafka can be bent into an event store (infinite retention, one partition-key per aggregate, enable.idempotence, transactions) and some shops do it — but you fight the tool for the two things ES needs most: per-aggregate optimistic concurrency and cheap single-stream loads. The pragmatic, widely-recommended architecture is:
Postgres is the system of record (the event store). Kafka is the nervous system (distribution/integration). Events are born in Postgres — appended transactionally with concurrency control — and then published to Kafka so the rest of the world can react.
Domain events vs. integration events — don't leak your internals#
A crucial distinction ES forces you to make explicit:
- Domain events are internal, fine-grained, and coupled to your aggregate's model (
MoneyWithdrawn,OverdraftLimitAdjusted). They live in the event store. They change as your model changes. - Integration events are the contract you publish to other bounded contexts over Kafka (
AccountBalanceChanged,AccountClosed). They're coarser, versioned deliberately, and decoupled from your internal event shapes.
If you publish raw domain events onto Kafka, every consumer becomes coupled to your internal model and you can never refactor it. Translate domain → integration events at the boundary. This is the ES-specific application of the same "don't share the database" rule that governs microservices.
// Translate internal domain events into a stable published contract.
fun toIntegrationEvent(e: AccountEvent, accountId: UUID): IntegrationEvent? = when (e) {
is MoneyDeposited -> AccountBalanceChanged(accountId, delta = e.amount)
is MoneyWithdrawn -> AccountBalanceChanged(accountId, delta = -e.amount)
is AccountClosed -> AccountClosedIntegration(accountId)
is AccountOpened -> AccountOpenedIntegration(accountId, e.currency)
}
Getting events onto Kafka reliably: outbox / CDC (not a naive publish)#
Appending to Postgres and then calling kafkaTemplate.send(...) is the dual-write problem — the append can commit and the publish can fail (or vice-versa), and now your event store and Kafka disagree forever. Your inbox/outbox and CDC guides cover the fix in depth; in an ES system the two standard wirings are:
- Transactional outbox: in the same transaction as the event append, insert a row into an
outboxtable. A relay publishes outbox rows to Kafka and marks them sent. Atomic because both writes share one commit. - CDC (Debezium) tailing the WAL: point Debezium at the
event_store(oroutbox) table; it streams committed inserts to Kafka in commit order — which, bonus, also dodges the §4 sequence-gap trap. This is often the cleanest ES→Kafka bridge and reuses exactly what your CDC guide describes.
// Outbox write, same transaction as the event append (schematic):
fun appendAndEnqueue(streamId: String, expectedVersion: Int, events: List<AccountEvent>, accountId: UUID) {
eventStore.append(streamId, expectedVersion, events) // (A) events → event_store
events.mapNotNull { toIntegrationEvent(it, accountId) }.forEach { ie ->
outbox.insert( // (B) same txn → outbox
topic = "account-events",
key = accountId.toString(), // partition key = aggregate id!
payload = mapper.writeValueAsString(ie),
)
}
// (A) and (B) commit together; a separate relay/Debezium publishes to Kafka at-least-once.
}
Partition by aggregate id — or lose ordering#
Kafka only guarantees ordering within a partition. Use the aggregate id as the message key so all events for one account land in one partition and arrive in order. Key by something coarser (or round-robin) and a consumer can see MoneyWithdrawn before AccountOpened. This is the Kafka-side echo of "the aggregate is the ordering/consistency unit" (§5), and it ties straight into your Kafka/EDA guide.
Interview gold: "How does Kafka relate to your event store?" → "Kafka isn't the event store — Postgres is, because I need per-aggregate optimistic concurrency and cheap stream loads. Kafka is how I publish integration events to other contexts, wired up through the outbox or Debezium/CDC so there's no dual-write, and keyed by aggregate id to preserve ordering." That one answer references four patterns correctly and is very hard to fake.
9. Event versioning and schema evolution#
The uncomfortable reality nobody mentions on day one: events are immutable and you keep them forever, so every event you write is a schema you must be able to read for the life of the system. Five years from now your code must still make sense of MoneyDeposited as it was written today. Schema evolution isn't an edge case in ES; it's a permanent, first-class concern.
The rules#
- Never mutate or delete a stored event. Not to fix a typo, not to "clean up." Corrections are new events.
- Prefer additive, backward-compatible changes. Adding an optional field with a sensible default is free if your deserializer is tolerant.
- Be a tolerant reader. Ignore unknown fields; supply defaults for missing ones. With Jackson:
@JsonIgnoreProperties(ignoreUnknown = true)and nullable/defaulted Kotlin properties. This alone handles the majority of real-world changes.
// Tolerant reader: new field added later, old events still deserialize.
data class MoneyDeposited(
val amount: Long,
val channel: String = "UNKNOWN", // added in v2; old events default cleanly
)
When additive isn't enough: upcasting#
For structural changes (rename a field, split one event into two, change a type, change units cents→millis), you use an upcaster: a function that transforms an old event version into the current shape at read time. The stored bytes never change; you translate on the way out of the store.
// Version the type name so you know which shape you're reading (event_type = 'MoneyWithdrawn.v1').
interface Upcaster {
fun canUpcast(type: String): Boolean
fun upcast(type: String, json: ObjectNode): Pair<String, ObjectNode> // -> (newType, newJson)
}
// v1 stored { "amount": 1000 } in cents; v2 wants minor-unit-agnostic { "amountMinor": 1000, "scale": 2 }
class MoneyWithdrawnV1toV2 : Upcaster {
override fun canUpcast(type: String) = type == "MoneyWithdrawn.v1"
override fun upcast(type: String, json: ObjectNode): Pair<String, ObjectNode> {
json.put("amountMinor", json.get("amount").asLong())
json.put("scale", 2)
json.remove("amount")
return "MoneyWithdrawn.v2" to json
}
}
// Deserialization pipeline: raw json -> apply upcaster chain (v1->v2->v3...) -> deserialize.
fun deserialize(type: String, raw: String): AccountEvent {
var node = mapper.readTree(raw) as ObjectNode
var t = type
// Re-scan after each step so a v1 event walks the whole chain to the current version.
while (true) {
val up = upcasters.firstOrNull { it.canUpcast(t) } ?: break
val (nextType, nextNode) = up.upcast(t, node)
t = nextType
node = nextNode
}
return mapper.treeToValue(node, classFor(t))
}
Upcasters chain: v1 → v2 → v3, each a small, well-tested transformation. This is exactly how Axon's upcaster chain and EventStoreDB-style migrations work. Keep the old event classes/mappings around forever, or keep the upcaster that skips past them.
The heavier hammers#
- Versioned event types (
OrderPlaced.v2as a distinct class) when a change is too big to upcast cleanly — handle both inevolve. - Copy-transform-replace (stream migration): write a new stream/store by transforming the old events with a migration program, then cut over. Expensive; reserved for major restructurings. You still keep the old store for audit.
- Weak schema by design: some teams store events as maps/JSON deliberately and never bind to rigid classes, trading compile-time safety for evolution freedom.
Interview trap: "How do you change an event's schema?" A junior says "add a migration / alter the events." The senior answer: "You don't change stored events — they're immutable. Additive changes ride a tolerant reader; structural changes get an upcaster applied at read time; genuinely breaking changes get a new versioned event type or a copy-and-transform migration. The old bytes are never touched." Version-dependent note: the exact upcaster API differs across Axon versions and frameworks — the concept is universal.
Bi-temporality: two clocks, not one#
Serious ES systems record two timestamps: when the fact occurred in the domain (occurredAt — possibly backdated) and when it was recorded in the store (recordedAt). "What did we believe the balance was on March 1st, given what we knew then?" needs both. Put them in event metadata from the start; you can't reconstruct recordedAt after the fact.
10. GDPR vs. the immutable log: crypto-shredding#
The most cited objection to event sourcing: "the log is immutable and kept forever — how do you honor a GDPR/CCPA 'right to erasure' request?" You can't just DELETE the events (you'd break replay, snapshots, and downstream projections, and possibly the hash chain if you have one). The standard answer is crypto-shredding:
- Generate a per-subject encryption key (one per person/customer), stored in a separate, mutable key store.
- Encrypt the personal data inside events with that subject's key before appending. Non-personal fields stay in clear so the domain still works.
- To "forget" the subject, delete their key. The events remain in the log (structure intact, replay still runs) but the PII fields are now permanently undecipherable ciphertext — effectively erased.
// PII fields are encrypted with the subject's key; deleting the key renders them unrecoverable.
data class CustomerRegistered(
val customerId: UUID,
val encryptedName: ByteArray, // AES-GCM with the subject key
val encryptedEmail: ByteArray,
val country: String, // non-PII stays clear for analytics/projections
)
fun forget(customerId: UUID) = keyStore.deleteKey(customerId) // crypto-shred: PII becomes noise
Points a senior candidate raises unprompted:
- Keep PII out of stream identifiers, partition keys, and un-encrypted metadata — you can't shred what's in the key.
- Projections/read models derived from that data must also be rebuilt/purged (they're separate copies).
- Crypto-shredding is the common answer; alternatives exist (a mutable "personal data vault" referenced by id from events, so events hold only a pointer you can null out). Both keep the event structure immutable while making the content erasable.
This "immutable store vs. legal deletion" tension is a favorite senior/staff question precisely because it forces you to reconcile an architectural absolute (append-only) with a legal absolute (erasure). Naming crypto-shredding immediately signals you've dealt with real ES in a regulated setting.
11. Problems and how to solve them#
The failure modes that separate a whiteboard understanding from a production one. Format: Problem → Root cause → Fix.
| Problem | Root cause | Fix |
|---|---|---|
| Replaying a huge stream is slow | Every command re-folds the entire history | Snapshots (§6): rehydrate from latest snapshot + tail. Treat snapshots as a disposable cache. |
| Lost update / two writers overdraw an account | Concurrent commands both loaded version N | Optimistic concurrency on UNIQUE(stream_id, version) (§5); on conflict, reload and re-decide. |
| Enforce uniqueness across aggregates (e.g. unique email) | An aggregate can only guard its own invariants; "no other account has this email" spans all streams | This is the hardest ES problem. Options: a reservation pattern (a UniqueEmail aggregate/row you claim first), a synchronously-updated unique lookup table written in the same txn, or accept eventual detection + compensation. Don't pretend one aggregate can enforce a set-wide rule. |
| Read model shows stale data right after a write | Projections are updated asynchronously (eventual consistency) | Read-your-writes techniques from your CQRS guide: return state from the command, version/poll the read model, or read the writer's own follow-up from the write side. |
| Event store and Kafka disagree | Dual write: append committed, publish failed (or vice-versa) | Outbox or CDC/Debezium (§8) — never a bare append(); kafka.send(). |
| Poller silently skips events | bigserial/identity positions commit out of order → gap (§4) | Drive subscriptions from CDC (commit order), or use gap-aware polling; don't naively WHERE position > last. |
| Projector double-applies an event | At-least-once delivery redelivers after crash/redeploy | Idempotent upserts + a persisted checkpoint; skip events at/below the checkpoint. |
| Can't read a 5-year-old event | Its schema changed; class no longer matches | Tolerant reader for additive changes; upcasters for structural ones (§9). Never rewrite stored events. |
| GDPR erasure request | Append-only log can't DELETE PII | Crypto-shredding (§10): encrypt PII per subject, delete the key. Also purge/rebuild derived read models. |
| A "stuck" or poison event halts a projector | One bad event throws on every apply | Dead-letter it, or make the projector skip-and-log while alerting; never let one event wedge the whole projection. Fix + replay. |
| Cross-aggregate business process (order → payment → shipping) | No distributed transaction across streams | A saga (your saga guide) reacting to events and issuing commands; ES emits the events the saga listens to. |
| Command applied twice (client retried) | At-least-once ingress, not just egress | Deduplicate on a command id (idempotency key): record processed command ids, or make the resulting event carry it so a replay is a no-op. |
| Storage grows without bound | You keep everything by design | Usually fine (storage is cheap; history is the value). If a stream is truly unbounded and old detail is worthless, consider closing the aggregate and archiving cold events — carefully, because it weakens the audit guarantee. |
| Snapshot won't deserialize after a state refactor | Snapshot schema drifted | Version snapshots; on failure discard and rebuild from events. A snapshot must never be un-reproducible. |
12. When to use it — and when not to#
Event sourcing is one of the highest-leverage and highest-commitment patterns in the catalog. Choosing it is close to irreversible (migrating out means reconstructing state and abandoning history). Choose deliberately.
Strong fits#
- Audit is a first-class requirement — finance, ledgers, healthcare, trading, anything regulated. The audit log isn't bolted on; it is the storage.
- The "why" matters, not just the "what" — you need to know how state got here (dispute resolution, fraud, compliance).
- Temporal / historical queries — "what was the state at time T," retroactive analytics, replaying history into new read models you didn't foresee.
- Complex domains with rich behavior where modeling intent as events clarifies the design (DDD-heavy contexts).
- You already need CQRS with many divergent read models — ES makes those read models trivially (re)buildable.
Poor fits (reach for CRUD)#
- Simple CRUD where you only care about current state and nobody will ever ask "why." ES is pure overhead here.
- No audit/temporal need + a team new to the pattern — the operational cost (concurrency, snapshots, projections, versioning, eventual consistency, GDPR) will dwarf the benefit.
- System-wide adoption. Like CQRS, ES belongs to specific bounded contexts, not the whole app. Event-sourcing the entire system is a classic over-engineering disaster.
- Ultra-high-throughput, low-value events where storing every one forever is cost without payoff (though this is often a "coarsen the events" problem, not a "don't use ES" one).
Decision heuristic#
Interview framing: the people who popularized ES (Greg Young, Martin Fowler) are loud that it's overused. Volunteering "I'd event-source the ledger context but leave the catalog as CRUD" shows judgment. Reciting mechanics without scoping shows you've only read about it.
13. Frameworks and build-vs-buy#
You have a real spectrum from "a Postgres table" to "a full ES/CQRS framework." All are legitimate; the trade-off is control vs. batteries-included.
| Option | What it is | When it fits |
|---|---|---|
| Hand-rolled on PostgreSQL | The event_store table + decide/evolve from this guide | You want full control, minimal magic, one familiar datastore, and your volume is moderate. The most common pragmatic choice on the JVM. |
| Axon Framework | Full JVM CQRS+ES: aggregates, command/event buses, event store, upcasters, sagas, projections | You want the whole opinionated stack and are happy adopting its programming model. Deepest ES support in the Spring world. |
| EventStoreDB | Purpose-built event-store database (streams, subscriptions, projections) by Greg Young's team | You want a dedicated event store with first-class stream subscriptions rather than building on Postgres. |
| Spring Modulith | Lightweight event publication registry (outbox-style) + module events | You want reliable in-process event publication and modular monolith boundaries, not a full ES engine. A gentle on-ramp. |
| Marten (.NET) | Event store + document DB on Postgres | Not JVM, but worth knowing: it's proof that "Postgres as an event store" is a mainstream, production-grade choice. |
A minimal Axon aggregate, so you recognize it in an interview (it inverts the plumbing: you apply() events and annotate handlers, and Axon does the load/fold/append):
class AccountAggregate {
private lateinit var id: String
private var balance: Long = 0
private var overdraft: Long = 0
constructor() // required no-arg
constructor(cmd: OpenAccount) {
// validate, then emit an event; Axon persists it to its event store
AggregateLifecycle.apply(AccountOpened(cmd.accountId, cmd.currency, cmd.overdraftLimit))
}
fun handle(cmd: Withdraw) {
if (balance - cmd.amount < -overdraft) throw AccountRejection("INSUFFICIENT_FUNDS")
AggregateLifecycle.apply(MoneyWithdrawn(cmd.amount))
}
// this is 'evolve' — Axon folds it for you
fun on(e: AccountOpened) { id = e.accountId.toString(); overdraft = e.overdraftLimit }
fun on(e: MoneyWithdrawn) { balance -= e.amount }
}
Same concepts (decide = @CommandHandler, evolve = @EventSourcingHandler, optimistic concurrency, upcasters, snapshots) — just provided rather than hand-built. Being able to map the hand-rolled version to the framework version is exactly the fluency interviewers probe for. (Framework APIs are version-dependent; treat the annotations as illustrative of the shape.)
14. Worked example, end to end#
Pulling every section together for the account context: write path in Postgres, projections for queries, integration events onto Kafka via the outbox, other contexts reacting.
The end-to-end story in words: a Withdraw command loads the account (latest snapshot + the events since), folds to current state, and decide checks the overdraft invariant. If it passes, the new MoneyWithdrawn event is appended at the expected version — atomically, in the same transaction as an outbox row carrying the integration event AccountBalanceChanged, keyed by account id. Optimistic concurrency guarantees no two withdrawals race past the balance check. A relay (or Debezium tailing the WAL in commit order) publishes the outbox row to Kafka, where the fraud, notification, and ledger contexts react — each fully decoupled from the account's internal event schema. Meanwhile in-process projectors keep account_summary and monthly_statement current for queries. If a projection has a bug, you fix it and replay the log; if you invent a new read model next quarter, you replay history into it; and the whole log remains the audit trail you never had to build separately.
That is event sourcing paying rent: one append-only source of truth, many derived and disposable views, an audit trail for free, and a clean, decoupled integration boundary.
15. How to sound senior (interview framing)#
The one-liners and instincts that read as experience rather than reading:
- Lead with the fold. "Event sourcing stores state as an append-only log of events and derives current state by replaying them —
state = fold(evolve, events). The log is the source of truth." Crisp, correct, done. - Separate ES from CQRS immediately. They're constantly conflated. "ES is how I persist the write side; CQRS is separating read and write models. Orthogonal — they just pair well because event logs are awful to query, which pushes you toward projections."
- Refuse 'Kafka is the event store.' Name why: no per-aggregate optimistic concurrency, no cheap single-stream loads. "Postgres is the system of record; Kafka distributes integration events, wired via outbox/CDC."
- Volunteer the failure modes. Optimistic-concurrency retries, at-least-once → idempotent projectors, the sequence-gap trap, dual-write → outbox/CDC. Naming these unprompted is the strongest signal you've run this in production.
- Know the two "immutability hurts" answers: schema evolution → upcasting; GDPR erasure → crypto-shredding. These are the questions that filter blog-readers from practitioners.
- Name the hardest problem honestly: set-wide uniqueness (unique email) can't be enforced by a single aggregate — reach for a reservation/lookup pattern. Admitting the sharp edge builds credibility.
- Scope it. "I'd event-source the ledger context, not the catalog." Judgment beats enthusiasm; the pattern's own popularizers call it overused.
- Get the vocabulary exactly right. Events are past-tense facts; commands are rejectable intents. Aggregate = consistency boundary = one stream. Mixing these up is an instant tell.
Traps to avoid saying#
- ❌ "Event sourcing means using Kafka." (It doesn't; Kafka is optional and usually not the store.)
- ❌ "You update the event when data changes." (You never mutate an event; you append a new one.)
- ❌ "It requires CQRS / two databases." (Related, not required — though projections are nearly mandatory in practice.)
- ❌ "Replaying is always slow." (Snapshots; and most streams are short.)
- ❌ "Just
SELECT ... FOR UPDATE." (Optimistic concurrency via the unique constraint is idiomatic.) - ❌ "We'll delete events for GDPR." (Crypto-shredding; deleting breaks replay and audit.)
16. Interview Q&A#
Q: What is event sourcing in one sentence? Persisting state as an append-only sequence of immutable events and deriving current state by replaying (folding) them, so the event log — not a mutable row — is the source of truth.
Q: How is it different from a plain audit/history table? An audit table is a secondary record derived from (and able to drift from) the mutable primary state. In ES the event log is the primary state; there's nothing else to drift from. Audit is intrinsic, not bolted on.
Q: Is event sourcing the same as CQRS? No — orthogonal. ES is how you persist writes; CQRS separates read and write models. They combine constantly because an event log is hard to query, so you build read-model projections (which is CQRS). But each is an independent decision.
Q: How do you get the current state of an aggregate?
Load its stream and fold a pure evolve(state, event) function over it from an empty state. For long streams, start from the latest snapshot and fold only the events after it.
Q: How do you enforce invariants and handle concurrency?
The aggregate is the consistency boundary — one command → load + fold → decide checks the invariant → append new events in one transaction. Concurrency is optimistic: append version N+1 guarded by UNIQUE(stream_id, version); on conflict, reload and re-decide. Purity of decide makes the retry correct.
Q: Why not use Kafka as the event store? Kafka lacks the two things ES's write path needs most: conditional append by per-aggregate version (optimistic concurrency) and cheap random access to a single aggregate's stream for rehydration. It also complicates "keep forever" (compaction can drop history). Store events in Postgres; publish them to Kafka for integration.
Q: Then what is Kafka for in an ES system? Distribution. You translate internal domain events into stable integration events and publish them to Kafka so other bounded contexts react — keyed by aggregate id to preserve ordering. The event store stays the source of truth.
Q: How do you publish to Kafka without a dual-write problem?
Transactional outbox (insert an outbox row in the same transaction as the event append; a relay publishes it) or CDC/Debezium (tail the WAL and stream committed events in commit order). Never append() then kafka.send() as two separate operations.
Q: What's the sequence-gap problem?
bigserial/identity global positions are assigned before commit, and transactions commit out of order, so a naive WHERE position > last ORDER BY position poller can advance past a position that hasn't committed yet and skip it. Fix by driving subscriptions from CDC (commit order) or using gap-aware polling.
Q: How do projections stay correct under at-least-once delivery? They must be idempotent (upserts, or skip events at/below a persisted checkpoint) and process events in order per aggregate. Because a projection is a pure function of history, you can also rebuild it from scratch by replaying.
Q: How do you change an event's schema over time? You never rewrite stored events. Additive changes ride a tolerant reader (ignore unknown fields, default missing ones). Structural changes use upcasters that transform old versions to the current shape at read time. Genuinely breaking changes get a new versioned event type or a copy-and-transform stream migration.
Q: How do you satisfy a GDPR "right to be forgotten" against an immutable log? Crypto-shredding: encrypt each subject's PII with a per-subject key kept in a separate mutable store; to erase, delete the key, rendering the PII permanently undecipherable while the event structure (and replay) stays intact. Also purge/rebuild derived read models, and keep PII out of stream ids and metadata.
Q: How do you enforce a rule that spans aggregates, like a globally unique email?
A single aggregate can't guarantee a set-wide property. Use a reservation pattern (claim a UniqueEmail row/aggregate first, in its own transaction) or a synchronously-maintained unique lookup table, or accept eventual detection plus compensation. This is the classic hard edge of ES; naming it is a plus.
Q: What are snapshots, and are they part of the source of truth? A snapshot is serialized aggregate state at a version, so you replay only the tail after it. It is strictly a performance cache — fully derivable from events and safe to delete. If a snapshot can't be deserialized after a refactor, discard it and rebuild from events.
Q: How do commands relate to events?
A command is a request to change state and can be rejected (Withdraw); an event is a fact that already happened and cannot (MoneyWithdrawn). One command may produce zero, one, or several events. Commands are typically not stored; events always are.
Q: How does ES interact with sagas? Cross-aggregate/cross-service processes can't use one transaction. A saga listens to events emitted by event-sourced aggregates and issues follow-up commands (with compensations on failure). ES naturally produces the event stream sagas coordinate on. (See your saga guide.)
Q: What's the difference between event sourcing and event-driven architecture? EDA is about services communicating via events over a broker (integration). ES is a persistence strategy inside a service (the store is a log). You can do EDA without ES (just publish notifications) and ES without EDA (a single service, no broker). They combine, but they're different layers. (See your Kafka/EDA guide.)
Q: How do you make command handling idempotent when clients retry? Attach an idempotency key / command id; record processed ids (or stamp the resulting event with it) so a replayed command produces no new events. At-least-once ingress needs this just as at-least-once egress needs idempotent projectors.
Q: When would you not event-source? Simple CRUD with no audit/temporal need; teams not ready for the operational cost; and never system-wide — scope it to the one or two contexts (ledgers, orders) whose history and invariants justify it.
17. Cheat-sheet#
| Concept | Nail it in one line |
|---|---|
| Core idea | state = events.fold(empty, ::evolve); the log is truth, state is derived. |
| Event | Immutable, past-tense fact for one aggregate; carries intent, never mutated. |
| Command vs event | Command = rejectable intent; event = settled fact. |
decide | (State, Command) -> List<Event> — pure business rules. |
evolve | (State, Event) -> State — pure fold step. |
| Aggregate | Consistency boundary = one stream = one append transaction. |
| Optimistic concurrency | UNIQUE(stream_id, version); conflict → reload + re-decide. |
| Snapshot | Disposable state cache at version N; replay only the tail. |
| Projection | Query read model from events; idempotent + checkpointed + rebuildable. |
| Global order trap | Serial positions commit out of order → naive polling skips; use CDC/commit order. |
| Kafka's role | Not the store — distributes integration events via outbox/CDC, keyed by aggregate id. |
| Store | Postgres event_store (jsonb payload, per-stream version, global position). |
| Schema evolution | Tolerant reader (additive) → upcaster (structural) → versioned type / migration (breaking). |
| GDPR | Crypto-shredding: encrypt PII per subject, delete the key. |
| Cross-aggregate rule | Reservation / unique lookup; one aggregate can't enforce set-wide invariants. |
| Cross-service process | Saga reacting to events + compensations. |
| When not to | Simple CRUD, no audit/temporal need, whole-system adoption. |
18. Glossary#
- Event — immutable, past-tense fact recorded for one aggregate.
- Stream — the ordered event sequence for one aggregate instance; unit of consistency and replay.
- Aggregate — DDD consistency boundary whose invariants hold atomically; maps to one stream.
- Version / sequence — per-stream monotonic counter; basis of optimistic concurrency.
- Global position — store-wide monotonic ordering across all streams for subscriptions.
- Command — a request to change state; can be rejected; usually not persisted.
decide— pure function producing events from(state, command).evolve/ apply — pure function advancing state for one event; the thing you fold.- Rehydration / replay — rebuilding state by folding
evolveover a stream. - Snapshot — serialized state at a version; a disposable performance cache.
- Projection / read model — query-optimized structure built by reacting to events (CQRS).
- Checkpoint — the last position a subscriber processed; enables resume and idempotency.
- Upcasting — transforming an old event version to the current shape at read time.
- Domain event — internal, fine-grained event in the store.
- Integration event — coarser, contract-stable event published to other contexts (e.g. via Kafka).
- Outbox — table written in the same transaction as events, relayed to a broker (avoids dual write).
- CDC — Change Data Capture; deriving events by tailing the DB transaction log (e.g. Debezium).
- Crypto-shredding — encrypting PII per subject and deleting the key to satisfy erasure.
- Bi-temporality — recording both when a fact occurred and when it was stored.
19. Self-test#
If you can answer these cold, you're interview-ready:
- Write the one-expression definition of current state in terms of events. What are the two pure functions involved and their signatures?
- Why is
UNIQUE(stream_id, version)sufficient for concurrency control? Walk through what happens when twoWithdrawcommands race. - Your naive projector polls
WHERE global_position > :last ORDER BY global_positionand occasionally misses events. Explain exactly why, and give two fixes. - Give three concrete reasons Kafka is a poor system-of-record event store. What is Kafka's correct role in an ES system, and how do you get events onto it safely?
- Distinguish a domain event from an integration event. What goes wrong if you publish domain events directly to other services?
- An event added a field last year and changed a field's units this year. Which technique handles each, and what must you never do to the stored events?
- A regulator demands you erase a customer's personal data from an append-only log. What's your approach, and what must you have done in advance for it to work?
- Why can't a single aggregate enforce "email must be unique across all accounts," and what patterns solve it?
- When is a snapshot allowed to be the source of truth? (Trick question — answer and justify.)
- Name two bounded contexts in a system you'd event-source and two you'd leave as CRUD, and defend the split.
20. Further reading#
- Greg Young — the originator's talks/writings on event sourcing and CQRS (and his cautions about overuse); EventStoreDB as the reference purpose-built store.
- Martin Fowler — Event Sourcing and CQRS bliki entries; clear definitions plus explicit "when not to" warnings.
- Chris Richardson, Microservices Patterns — Event Sourcing, Saga, Transactional Outbox, and CQRS as a connected set (mirrors your existing guides).
- Axon Framework Reference Guide — the canonical JVM CQRS+ES stack:
@Aggregate,@CommandHandler,@EventSourcingHandler, upcasters, snapshots, tracking processors. - Debezium documentation — CDC from Postgres to Kafka in commit order; the clean ES→Kafka bridge (see your CDC guide).
- Spring Modulith — Event Publication Registry — an outbox-style, in-process reliable publication mechanism for a gentler on-ramp.
- Marten (.NET) — a mature event store built on PostgreSQL; validation that hand-rolling on Postgres is a mainstream choice.
- "Versioning in an Event Sourced System" (Greg Young) — the definitive treatment of upcasting, weak schema, and copy-transform migrations.
Companion guides in this folder#
This guide is the write-side persistence piece; it interlocks with:
- CQRS — the read/write split; read models, projections, read-your-own-writes. (ES is the write side; CQRS makes it queryable.)
- Inbox/Outbox — the reliable bridge from the event store to Kafka without dual-write.
- CDC (Debezium) — tailing Postgres's WAL to publish events in commit order (dodges the sequence-gap trap).
- Saga pattern — coordinating processes across event-sourced aggregates with compensations.
- Kafka & EDA — the broker mechanics and partitioning that carry integration events.
Scope note: event-source the bounded contexts whose history, audit, and invariants justify the cost; keep Postgres as the source of truth, treat snapshots and projections as disposable, wire Kafka through outbox/CDC — and remember the pattern's own inventors think it's overused.