Disentangles Event-Driven Architecture (a communication pattern) from Event Sourcing (a persistence pattern) — the most commonly confused pair in system-design interviews.
See also: kafka-eda-study-guide.md, event-sourcing-study-guide.md, CQRS-comprehensive-guide.md, inbox-outbox-pattern.md
The mental model#
Everything derives from one idea: EDA and Event Sourcing live at different layers and answer different questions.
- EDA answers "how do services communicate?" → events as notifications flowing between systems.
- Event Sourcing answers "how does a service store state?" → events as the record inside one system.
An EDA event is a transient message: consumed, acted on, done. An event-sourced event is a permanent fact: it is the database. Same word — different lifetime, different audience, different contract, different failure modes. They are orthogonal: you can have either, both, or neither.
1. EDA in brief#
An architectural style where producers emit events to a broker (Kafka, RabbitMQ, SNS/SQS, EventBridge) and consumers react asynchronously. The producer doesn't know who consumes — that's the point.
- Buys you: loose coupling, independent deploy/scale, extensibility (add a consumer without touching the producer).
- Costs you: eventual consistency, at-least-once delivery (duplicates!), out-of-order hazards, harder debugging of choreographed flows.
- Sub-patterns to name: event notification (thin event, consumer calls back for data) vs event-carried state transfer (fat event, no callback); choreography vs orchestration (see saga-pattern-study-guide.md).
- Event type: integration events — coarse-grained, public contracts other teams depend on; version them like an API.
2. Event Sourcing in brief#
A persistence pattern: every state change of an aggregate is stored as an immutable domain event in an append-only event store. Current state is derived by replaying (folding) the events; it is disposable and rebuildable.
- Mechanics: one stream per aggregate; append with optimistic concurrency (
expectedVersion); snapshots to shortcut long streams; projections build read models — which is why ES almost always pairs with CQRS. (CQRS does not require ES.) - Buys you: complete audit trail, temporal queries ("state as of March 3"), debugging by replay, new read models from history you didn't know you'd need.
- Costs you: schema evolution forever (you must read events written years ago), GDPR/deletion pain, unfamiliar tooling, eventual consistency on the read side.
3. The differences that matter#
Source of truth. EDA: each service's own DB is truth; broker events are signals, gone (logically) once handled. ES: the event log is truth; lose it and you've lost the data.
Audience & granularity. EDA events are public and coarse (OrderPlaced) — stability matters more than detail. ES events are private and fine-grained (ItemAddedToCart, DiscountApplied) — detail matters because they must reconstruct exact state. Publishing your domain events directly to other teams couples them to your internals — a classic trap (fix: translate to integration events at the boundary).
Ordering & completeness. ES needs strict per-aggregate ordering and a complete history — one missing event corrupts replayed state. EDA typically tolerates duplicates and gaps; consumers are built idempotent and order-insensitive where possible.
Replay semantics. ES replay = rebuild state/projections from the authoritative log. EDA "replay" (re-reading a Kafka topic) = reprocessing notifications; it is not state reconstruction.
Delivery contract. EDA: at-least-once, dedupe on the consumer side (see inbox-outbox-pattern.md). ES: exactly-once append enforced by optimistic concurrency inside one transaction — a much stronger, much more local guarantee.
4. The Kafka trap (interview favorite)#
"We use Kafka, so we do event sourcing" — wrong, and interviewers probe it deliberately. Kafka with long retention is still not an event store:
- No per-aggregate streams. Partitions ≠ streams. Reading one aggregate's full history means scanning a partition; an event store reads
stream = order-123directly. - No conditional append. Event stores reject an append if
expectedVersiondoesn't match (optimistic concurrency). Kafka has no compare-and-append per key, so it can't guard aggregate invariants. - Retention/compaction semantics. Compaction keeps the latest record per key — that's a snapshot store, the opposite of full history.
- No efficient point reads. Event stores answer "give me events 0..n of this stream"; Kafka answers "give me offsets of this partition."
Kafka is the backbone of EDA and a fine transport for feeding projections. KurrentDB (formerly EventStoreDB), Axon Server, or a well-designed SQL table are event stores. Knowing why the distinction holds is the senior signal.
4.1 Choosing an event store: what actually matters#
Evaluate candidates against capabilities, not marketing. Ranked by how often getting it wrong hurts:
- Conditional append (optimistic concurrency). The non-negotiable. Ask: can I append "at expectedVersion N, or fail"? Purpose-built stores do this natively; in SQL it's a unique key on
(stream_id, seq); in DynamoDB aConditionExpression. No conditional append → no invariant protection → not an event store. - Cheap, ordered per-stream reads. The rehydration path runs on every command; it must be an indexed read of one stream, not a scan.
- A global, ordered, gap-free feed. Projections and catch-up subscriptions read everything in a stable order. Purpose-built stores give it for free (KurrentDB's
$all). In SQL, abigserialglobal sequence has gaps and out-of-order commit visibility under concurrency — a classic silent-data-loss bug in homegrown stores. Fixes: transactional sequence table, single-writer, or reading via CDC/logical decoding. - Subscriptions. Push (persistent subscriptions with server-side checkpoints) beats polling for projection lag; know which you're getting.
- Transactional locality. A Postgres event store lets append + outbox (+ sometimes read model) commit in one ACID transaction. A separate store means you own the outbox/relay glue across two systems.
- Ops profile. Replication, backup/restore, managed offering, and — bluntly — who on the team can run it at 3 a.m. A boring database you know beats a better one you don't.
- Scale shape. Streams count, events/sec, total retained bytes, partitioning story. Most systems never exceed what a single beefy Postgres handles; don't pay distributed-systems tax speculatively.
- GDPR/redaction. Crypto-shredding support, scavenging/tombstones (immutability meets the law — see §6).
- Ecosystem fit. Java/Spring: Axon Framework is native; KurrentDB has a gRPC Java client; Postgres works with everything you already use.
The minimal viable SQL event store, showing items 1–3 in ~10 lines:
CREATE TABLE events (
stream_id uuid NOT NULL,
seq bigint NOT NULL, -- per-stream version
global_seq bigserial, -- projection feed (mind commit-order gaps!)
type text NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (stream_id, seq) -- concurrent append at same seq → unique violation → retry command
);
4.2 Top 4 event stores compared#
(Naming/licensing verified July 2026 — flag in interviews that this shifts: Event Store rebranded to Kurrent in Dec 2024; Axon Server now ships one artifact with license-unlocked features.)
| KurrentDB (ex-EventStoreDB) | Axon Server | PostgreSQL (custom schema) | DynamoDB (custom) | |
|---|---|---|---|---|
| What it is | Purpose-built event-native DB | Event store + message router for Axon Framework | RDBMS wearing an event-store schema | Managed NoSQL bent into shape |
| Optimistic concurrency | Native expectedVersion | Native (aggregate sequence) | Unique (stream_id, seq) | ConditionExpression on version |
| Per-stream reads | Native streams | Native | Indexed read (fast) | Query on partition key |
| Global feed / subs | $all + catch-up & persistent subs | Tracking event processors | Poll global_seq (gap hazard) or CDC | DynamoDB Streams — 24 h window only |
| Tx with your other data | No — separate store, outbox glue on you | No — framework unit-of-work helps | Yes — one ACID tx with outbox/read model | TransactWriteItems, same-table only |
| Ops | Self-host or Kurrent Cloud; new skillset | Single artifact; free single-node dev, licensed clustering (Professional/Enterprise) | Your existing Postgres ops — boring, known | Fully managed, serverless, pay-per-request |
| Java/Spring fit | gRPC Java client | First-class (Axon Framework + Spring Boot) | First-class (JDBC/JPA/jOOQ) | AWS SDK |
| Pick when | You want event-native tooling & temporal features as product | You're all-in on Axon/CQRS in Java | Default. Small/medium scale, team knows Postgres | AWS-native, very high scale, serverless |
| Watch out | Rebrand-era docs split (EventStoreDB ↔ Kurrent) | Framework coupling; license for clustered prod | You own gap-handling, snapshots, subs | Streams retention, hot partitions, painful full rebuilds |
Interview verdict: default to Postgres and say why ("conditional append via unique key, one ACID tx with the outbox, ops we already have — purpose-built stores when subscriptions/scale demand it"). Reaching for exotic infrastructure without a driving requirement reads as junior. Honorable mentions if asked: MongoDB (doc-per-event, weaker global feed), Cassandra (scale, but you build everything), Marten (excellent, but .NET-only).
5. Composing them (the typical senior design)#
Event-source internally; publish integration events externally via an outbox:
Command → rehydrate aggregate from event store → append new domain events
→ write outbox row (same TX) → relay → Kafka → other services
The aggregate: rebuilt from events, buffering new ones#
Kotlin
sealed interface AccountEvent { val accountId: UUID }
data class AccountOpened(override val accountId: UUID, val owner: String) : AccountEvent
data class MoneyWithdrawn(override val accountId: UUID, val amount: BigDecimal) : AccountEvent
class Account private constructor(val id: UUID) {
var balance: BigDecimal = BigDecimal.ZERO; private set
var version: Long = 0; private set
val pendingEvents = mutableListOf<AccountEvent>()
fun withdraw(amount: BigDecimal) {
check(balance >= amount) { "insufficient funds" } // invariant checked BEFORE emitting
applyEvent(MoneyWithdrawn(id, amount), isNew = true)
}
private fun applyEvent(e: AccountEvent, isNew: Boolean = false) {
when (e) {
is AccountOpened -> Unit
is MoneyWithdrawn -> balance -= e.amount
}
version++
if (isNew) pendingEvents += e
}
companion object {
fun rehydrate(id: UUID, history: List<AccountEvent>): Account =
Account(id).also { acc -> history.forEach(acc::applyEvent) }
}
}
These two methods are the pattern (the Java version below has the same shape):
applyEventis the single state-transition function — the only place state ever changes (state × event → new state). It deliberately contains no validation: rules were checked in the command method (withdraw) before the event was emitted. Events are facts that already happened; ifapplyEventcould reject one, the aggregate would refuse to read its own history on replay.version++counts every applied event; that count becomes theexpectedVersionsent on append — the optimistic-concurrency hook from §4.1.- The
isNewflag routes two callers through one code path. During replay (isNew = false) it only mutates state. During a command (isNew = true) it also buffers the event inpendingEventsfor the service to persist. Forcing new events through the same transition logic as historical ones guarantees today's state change replays identically forever — split those paths and the state silently diverges from the log. rehydrateis a left fold over history: start from a blank aggregate, apply each event in order, end at current state. This is why ES demands strict per-aggregate ordering and completeness (§3) — fold a reordered or gappy list and you get a different, wrong state.
Interview soundbite: "Commands validate then emit; apply mutates and never validates; current state is a left fold of the stream."
Java
public class Account {
private final UUID id;
private BigDecimal balance = BigDecimal.ZERO;
private long version = 0;
private final List<AccountEvent> pendingEvents = new ArrayList<>();
private Account(UUID id) { this.id = id; }
public void withdraw(BigDecimal amount) {
if (balance.compareTo(amount) < 0) throw new IllegalStateException("insufficient funds");
applyEvent(new MoneyWithdrawn(id, amount), true);
}
private void applyEvent(AccountEvent e, boolean isNew) {
if (e instanceof MoneyWithdrawn w) balance = balance.subtract(w.amount());
version++;
if (isNew) pendingEvents.add(e);
}
public static Account rehydrate(UUID id, List<AccountEvent> history) {
var acc = new Account(id);
history.forEach(e -> acc.applyEvent(e, false));
return acc;
}
public List<AccountEvent> pendingEvents() { return pendingEvents; }
public long version() { return version; }
}
The service: append + outbox in ONE transaction#
Kotlin
class AccountService(
private val eventStore: EventStore, // append-only table: (stream_id, seq, type, payload)
private val outbox: OutboxRepository, // same database → same transaction
) {
fun withdraw(cmd: WithdrawCommand) {
val history = eventStore.load(cmd.accountId)
val account = Account.rehydrate(cmd.accountId, history)
account.withdraw(cmd.amount)
// rejects if someone appended concurrently (optimistic concurrency)
eventStore.append(cmd.accountId, expectedVersion = history.size.toLong(), account.pendingEvents)
// coarse PUBLIC contract — not the raw domain events
outbox.save(FundsWithdrawnIntegrationEvent(cmd.accountId, cmd.amount))
}
}
Java
public class AccountService {
private final EventStore eventStore;
private final OutboxRepository outbox;
public AccountService(EventStore eventStore, OutboxRepository outbox) {
this.eventStore = eventStore;
this.outbox = outbox;
}
public void withdraw(WithdrawCommand cmd) {
List<AccountEvent> history = eventStore.load(cmd.accountId());
Account account = Account.rehydrate(cmd.accountId(), history);
account.withdraw(cmd.amount());
eventStore.append(cmd.accountId(), history.size(), account.pendingEvents());
outbox.save(new FundsWithdrawnIntegrationEvent(cmd.accountId(), cmd.amount()));
}
}
A relay (Debezium CDC or a poller — see cdc-change-data-capture-study-guide.md) ships outbox rows to Kafka; downstream consumers dedupe by event id. Note the boundary translation: MoneyWithdrawn (domain, private) → FundsWithdrawnIntegrationEvent (integration, public).
6. Problems & how to solve them#
Problem: consumer handled OrderPlaced twice; two boxes shipped.
Root cause: at-least-once delivery + non-idempotent consumer (EDA).
Fix: idempotency key per event id + dedupe/inbox table; make handlers upsert-style.
Problem: replay of old streams crashes after a refactor. Root cause: domain event schema changed, but the store holds v1 events forever (ES). Fix: upcasters (v1→v2 transforms on read), tolerant reader, additive-only changes, explicit event versions. Never rename/reuse fields.
Problem: GDPR "delete my data" vs immutable log. Root cause: ES immutability is the feature and the bug. Fix: crypto-shredding — encrypt PII per user, delete the key on erasure; or keep PII out of events entirely (store a reference to a mutable PII table).
Problem: two concurrent commands on one aggregate break an invariant (double-spend).
Root cause: no concurrency control on append.
Fix: optimistic concurrency (expectedVersion); on conflict, reload and retry the command.
Problem: consumers see events out of order. Root cause: events for one entity spread across Kafka partitions (EDA). Fix: partition by aggregate id (ordering holds only within a partition); or design order-insensitive handlers.
Problem: rebuilding a projection takes hours and would block reads. Root cause: replaying the whole store into the live read model. Fix: blue/green projections — build the new one alongside, cut over when caught up. Snapshots shorten aggregate rehydration (orthogonal to projections).
Problem: other teams broke when you refactored internal events. Root cause: domain events published directly to the broker as the public contract. Fix: translate domain → integration events at the boundary (outbox); treat integration events as a versioned API.
7. Interview framing — how to sound senior#
- Open with the layer distinction: "EDA is how services communicate; Event Sourcing is how a service persists state. Orthogonal — often combined via an outbox: event-sourced inside, integration events outside."
- Volunteer the Kafka trap before being asked — then explain why (no per-stream reads, no conditional append).
- State the costs unprompted: recommend ES only where audit/temporal value is real (ledgers, payments, compliance); "CRUD + outbox" is the honest default. Blanket "event source everything" reads as junior.
- Be precise on CQRS: ES practically implies CQRS for reads; CQRS doesn't require ES; EDA requires neither.
- Distinguish event notification vs event-carried state transfer when asked "what goes in the event?"
- Wrong answers to avoid: "Kafka = event sourcing"; "events get deleted after consumption in ES"; "EDA means services have no databases"; "event sourcing removes the need for transactions."
8. Cheat sheet#
| Dimension | EDA | Event Sourcing |
|---|---|---|
| Layer | Inter-service communication | Intra-service persistence |
| Question answered | How do services talk? | How is state stored? |
| Event role | Notification / trigger | System of record |
| Event type | Integration event (public, coarse) | Domain event (private, fine-grained) |
| Lifetime | Transient (logically, once handled) | Permanent, immutable |
| Source of truth | Each service's own DB | The event log itself |
| Ordering needs | Best-effort; per-partition | Strict, complete, per-aggregate |
| Delivery | At-least-once → idempotent consumers | Transactional append w/ expectedVersion |
| "Replay" means | Reprocess notifications | Rebuild state/projections |
| Typical tech | Kafka, RabbitMQ, SNS/SQS, EventBridge | KurrentDB (ex-EventStoreDB), Axon, custom SQL store |
| Pairs with | Pub/sub, sagas, outbox | CQRS, projections, snapshots |
| Hard problems | Duplicates, ordering, debugging flows | Schema evolution, GDPR, rebuilds |
| Use when | Decoupling services, fan-out | Audit/temporal requirements are real |
9. Self-test#
- Name two technical reasons a Kafka topic with infinite retention is still not an event store.
- A service publishes
OrderPlacedto Kafka but stores orders as Postgres rows. Is this EDA, ES, both, or neither? - How do you implement "right to be forgotten" in an event-sourced system? Two approaches.
- Domain event vs integration event — and what breaks when you expose domain events to other teams?
- What failure does
expectedVersionon append prevent? What do you do on conflict? - Rebuild a corrupted read model with zero downtime — walk through it.
- Make the case against event sourcing for a standard e-commerce catalog service.
- Your consumer must process events for one order in order. What do you configure in Kafka, and what's the limit of that guarantee?
(Answers are all derivable from sections 3–7.)