11 min read
Event-Driven Systems2,308 words

EDA vs Event Sourcing

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-123 directly.
  • No conditional append. Event stores reject an append if expectedVersion doesn'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:

  1. 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 a ConditionExpression. No conditional append → no invariant protection → not an event store.
  2. Cheap, ordered per-stream reads. The rehydration path runs on every command; it must be an indexed read of one stream, not a scan.
  3. 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, a bigserial global 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.
  4. Subscriptions. Push (persistent subscriptions with server-side checkpoints) beats polling for projection lag; know which you're getting.
  5. 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.
  6. 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.
  7. 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.
  8. GDPR/redaction. Crypto-shredding support, scavenging/tombstones (immutability meets the law — see §6).
  9. 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:

sql
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 ServerPostgreSQL (custom schema)DynamoDB (custom)
What it isPurpose-built event-native DBEvent store + message router for Axon FrameworkRDBMS wearing an event-store schemaManaged NoSQL bent into shape
Optimistic concurrencyNative expectedVersionNative (aggregate sequence)Unique (stream_id, seq)ConditionExpression on version
Per-stream readsNative streamsNativeIndexed read (fast)Query on partition key
Global feed / subs$all + catch-up & persistent subsTracking event processorsPoll global_seq (gap hazard) or CDCDynamoDB Streams — 24 h window only
Tx with your other dataNo — separate store, outbox glue on youNo — framework unit-of-work helpsYes — one ACID tx with outbox/read modelTransactWriteItems, same-table only
OpsSelf-host or Kurrent Cloud; new skillsetSingle artifact; free single-node dev, licensed clustering (Professional/Enterprise)Your existing Postgres ops — boring, knownFully managed, serverless, pay-per-request
Java/Spring fitgRPC Java clientFirst-class (Axon Framework + Spring Boot)First-class (JDBC/JPA/jOOQ)AWS SDK
Pick whenYou want event-native tooling & temporal features as productYou're all-in on Axon/CQRS in JavaDefault. Small/medium scale, team knows PostgresAWS-native, very high scale, serverless
Watch outRebrand-era docs split (EventStoreDB ↔ Kurrent)Framework coupling; license for clustered prodYou own gap-handling, snapshots, subsStreams 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

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):

  • applyEvent is 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; if applyEvent could reject one, the aggregate would refuse to read its own history on replay.
  • version++ counts every applied event; that count becomes the expectedVersion sent on append — the optimistic-concurrency hook from §4.1.
  • The isNew flag 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 in pendingEvents for 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.
  • rehydrate is 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

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

kotlin
@Service class AccountService( private val eventStore: EventStore, // append-only table: (stream_id, seq, type, payload) private val outbox: OutboxRepository, // same database → same transaction ) { @Transactional 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

java
@Service public class AccountService { private final EventStore eventStore; private final OutboxRepository outbox; public AccountService(EventStore eventStore, OutboxRepository outbox) { this.eventStore = eventStore; this.outbox = outbox; } @Transactional 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#

DimensionEDAEvent Sourcing
LayerInter-service communicationIntra-service persistence
Question answeredHow do services talk?How is state stored?
Event roleNotification / triggerSystem of record
Event typeIntegration event (public, coarse)Domain event (private, fine-grained)
LifetimeTransient (logically, once handled)Permanent, immutable
Source of truthEach service's own DBThe event log itself
Ordering needsBest-effort; per-partitionStrict, complete, per-aggregate
DeliveryAt-least-once → idempotent consumersTransactional append w/ expectedVersion
"Replay" meansReprocess notificationsRebuild state/projections
Typical techKafka, RabbitMQ, SNS/SQS, EventBridgeKurrentDB (ex-EventStoreDB), Axon, custom SQL store
Pairs withPub/sub, sagas, outboxCQRS, projections, snapshots
Hard problemsDuplicates, ordering, debugging flowsSchema evolution, GDPR, rebuilds
Use whenDecoupling services, fan-outAudit/temporal requirements are real

9. Self-test#

  1. Name two technical reasons a Kafka topic with infinite retention is still not an event store.
  2. A service publishes OrderPlaced to Kafka but stores orders as Postgres rows. Is this EDA, ES, both, or neither?
  3. How do you implement "right to be forgotten" in an event-sourced system? Two approaches.
  4. Domain event vs integration event — and what breaks when you expose domain events to other teams?
  5. What failure does expectedVersion on append prevent? What do you do on conflict?
  6. Rebuild a corrupted read model with zero downtime — walk through it.
  7. Make the case against event sourcing for a standard e-commerce catalog service.
  8. 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.)