Module 31 / 46 · Phase F — Event-Driven at Scale · ✦ Phase Finale · 50 min

Exactly-once.
(effectively.)

Impossible in the strict theoretical sense — Two Generals proved that in 1975. Achievable in the pragmatic sense — at-least-once delivery plus idempotent consumers plus the transactional outbox. The stack of patterns that has been quietly holding up every guarantee Phase F promised.

// What you'll know by the end

  • Why true exactly-once is provably impossible
  • Idempotency: keys, natural dedup, state machines
  • The transactional outbox pattern
  • Kafka's EOS: what it delivers, where it leaks
§ 01 — The Two Generals Problem

A protocol that
cannot exist.

Two armies on hilltops on either side of a valley. The enemy is in the middle. If both armies attack at dawn, they win. If only one attacks, that one is destroyed. The generals can only communicate via messengers who must cross the valley — and messengers can be captured. General A sends: "attack at dawn." Did General B receive it? B must send an acknowledgment. Did A receive the acknowledgment? A must ack the ack. Did B receive that? The regress is infinite. No finite exchange of messages can give both generals certainty that the other will attack.

This is a 1975 result by Jim Gray at IBM, formalized by others in the years after. It's an impossibility proof, and it's what every distributed system runs into the moment a network is involved. Substitute "General A" for "producer" and "General B" for "consumer" and the picture is exact. The producer sends a message; the consumer processes it; the consumer sends an ack; the producer receives it. Any of these steps can fail. No protocol using an unreliable network can guarantee both "the message was delivered" AND "the message was delivered exactly once."

// THE THREE ATTEMPTS TO GET DELIVERY RIGHT · AND WHY EACH FAILS
PRODUCER (General A) CONSUMER (General B) MSG: "process order" ACK: "got it" ACK-ACK: "got your ack" ✗ dropped by network consumer: "did they get my ack? do I need to resend?"
At-most-once.fire and forget
Send the message. Don't retry. Don't check. If the network eats it, oh well. Guarantees no duplicates because you never resend. But messages evaporate silently. Useful only for telemetry where losing 0.1% doesn't matter.
✗ LOSS
At-least-once.retry until acked
Send. Wait for ack. If no ack in T seconds, resend. Guarantees the message eventually arrives. But if the ack was just slow (not lost), your resend causes a duplicate. Consumers might process the same message twice. This is Kafka's default.
≈ DUPLICATES
Exactly-once.strictly, always, never
Both properties at once: never lost AND never duplicated. Requires the producer to know whether the previous send was successful — which requires an ack — which itself can be lost. The regress is infinite; no protocol can win. Jim Gray proved this in 1975.
∅ IMPOSSIBLE

If exactly-once is impossible, why does every message broker vendor claim to support it? Because "exactly-once" has been quietly redefined. The pragmatic definition — the one that actually ships in production systems — is "at-least-once delivery + idempotent consumers = effectively-once effects." Duplicates still happen in the network layer; the consumer detects and discards them; the observable business effect is exactly-once even though the delivery mechanism is at-least-once. This is not a workaround. It's the only correct engineering response to the impossibility result.

Everything in Phase F has been quietly assuming this stack works. Kafka's at-least-once delivery (M.26) plus consumer group offsets — assumes consumers are idempotent. Event sourcing (M.28) replaying events into projections — assumes projections are idempotent. CQRS command handlers (M.29) receiving retried commands — assume idempotent handlers. Saga compensations (M.30) that might run twice on network blips — assume idempotent compensations, or they corrupt state. This module makes explicit what has been implicit: every event-driven pattern rests on idempotency. Once you understand this layer, everything else in Phase F snaps into a coherent picture.

Exactly-once is impossible. Effectively-once is achievable. The distinction is where the entire pattern lives.
§ 02 — Delivery semantics + the idempotency escape hatch

Three promises.
One equation.

The three delivery semantics — introduced in M.26 as "the three promises brokers make" — deserve a second look now that we've named the impossibility. Only two of them are real; the third is the sleight of hand that makes production systems work.

// THE THREE DELIVERY GUARANTEES · REVISITED

// AT-MOST-ONCE
Fire & forget
"Send it. Move on. Don't check."

Producer sends and doesn't wait for ack. Zero retries. Message might be lost if the network drops it. Never duplicated because there's no resend logic. UDP-style delivery for the messaging layer.

Simplest, fastest, no dedup logic
Silent message loss on any failure
// AT-LEAST-ONCE
Retry until
acked
"Send. Wait for ack. If none, resend."

Producer resends on ack timeout. Consumer might process the same message twice if the ack was slow rather than lost. This is Kafka's default and the default of every serious broker. Never lost, sometimes duplicated.

Never lose a message under normal ops
Consumers must handle duplicates
// EXACTLY-ONCE
Effectively-once
"At-least-once + idempotent consumer."

The broker still delivers at-least-once; the consumer detects duplicates via an idempotency check and discards them. Observable business effect is exactly-once; delivery mechanism is at-least-once. This is what "exactly-once" means in every production system.

Business correctness under all failures
Consumer complexity + dedup storage

The equation that runs the industry: at-least-once delivery + idempotent consumer = effectively-once effect. This is not a compromise or a workaround; it's the mathematically-correct engineering response to the Two Generals impossibility. Duplicates will happen in the delivery layer — the network is unreliable, retries are necessary, so duplicates are unavoidable. Rather than trying to prevent them (impossible), we make them harmless. If applying an operation N times produces the same result as applying it once, duplicates stop mattering.

// FOUR WAYS TO ACHIEVE IDEMPOTENCY IN A HANDLER

Naturally idempotent// no work needed

Some operations are already safe to repeat. SET x = 5 can run a thousand times; the state is the same. UPDATE users SET last_seen = '2024-...' where the value is deterministic. Recognizing which operations are naturally idempotent is free correctness; the trap is assuming they're rarer than they actually are.

Idempotency keys// dedup by request ID

Every request carries a unique ID — client-generated UUID, or a hash of the request payload. The handler stores processed IDs in a processed_requests table and checks it before acting. Stripe's API uses this pattern openly via the Idempotency-Key header. Storage grows unbounded unless you TTL it; the TTL must exceed max realistic retry window (usually 24h+).

Natural-key dedup// business ID as key

Use the business primary key as the idempotency key. INSERT INTO orders (id, ...) VALUES (?, ...) ON CONFLICT (id) DO NOTHING. If the same order_id arrives twice, the second insert is a no-op. Cheapest possible dedup; works only when you have a natural unique identifier that's assigned before the message is sent (not database-generated).

State-machine transitions// only allowed moves

Model the domain as a state machine with explicit transitions. An order goes pending → paid → shipped → delivered. Only pending → paid is allowed; a duplicate "mark paid" message on an already-paid order is a no-op (or a check that raises "already paid, ignoring"). The state machine itself enforces idempotency; belongs naturally with event sourcing (M.28).

One implementation trap worth calling out: the idempotency check and the state write must be atomic. If the check ("have I seen this key?") and the effect ("apply this operation") are separate operations, two duplicate messages can arrive concurrently, both fail the check, and both apply their effect. This is a classic race condition that turns your "idempotent" handler into a duplicated-effect one under load. The fix: do both in one transaction (SQL upserts with unique constraints; Redis SETNX; DynamoDB conditional writes). Idempotency is a property of the atomic operation, not just the intent.

At-least-once + idempotency = effectively-once. That equation is the whole industry.
§ 03 — The transactional outbox

Atomic across two
storage systems.

Here's the problem that plagued event-driven systems for a decade: you need to atomically (1) update your database AND (2) publish an event about the update. In a monolith, both happen inside one SQL transaction. In a service that owns a database and publishes to Kafka, they're two independent systems with no shared transaction manager. Two things must both happen — but they can't happen together. Every naive resolution corrupts state in a specific failure mode.

// THE TWO NAIVE APPROACHES · AND WHY BOTH FAIL

DB first, then publish// most common

Write to database; then send to Kafka. If the service crashes between the two, or Kafka is unreachable during publish, the DB has the state change but no event is emitted. Downstream services never learn. The system is silently inconsistent — this order exists in the orders table, but the shipping service was never notified. Data loss disguised as a happy DB write.

Publish first, then DB// worse

Send to Kafka; then write to database. If Kafka acks but the DB write fails, an event has been emitted about state that doesn't exist. Downstream services react to a ghost. Bills get generated for orders that were never actually created. Data corruption is worse than data loss.

The transactional outbox pattern solves this with a startlingly simple idea: use only one system to write to, then have a separate process forward the events. Inside a single database transaction, write both the business state (to the orders table) AND the event you want to publish (to an outbox table). Because both writes are in one transaction, they're atomic — either both commit or neither does. A separate poller then reads new rows from the outbox table and publishes them to Kafka, marking each row as sent after successful publish. If the poller crashes mid-work, the outbox row remains unsent and gets retried on the next poll. Delivery becomes at-least-once (retries can duplicate), so consumers must be idempotent — but atomicity between state and event is now guaranteed.

// THE OUTBOX PATTERN · ARCHITECTURALLY

SERVICE handles command write once, atomically SINGLE DB TRANSACTION both writes atomic, or neither orders id · status customer · items total · timestamp business state outbox event_id · type payload · sent_at null=pending event to emit write OUTBOX POLLER reads & publishes separate process read pending KAFKA at-least-once delivery consumers dedup by event_id publish · mark sent retry on failure
// PROPERTY 1
Atomicity is real. Business write + event write are in one DB transaction. Either both commit or neither does. No possibility of state without event or event without state.
// PROPERTY 2
Poller is at-least-once. Crashes during publish are safe — the outbox row stays sent_at=NULL, gets picked up on next poll. Retries can produce duplicates in Kafka.
// PROPERTY 3
Consumers must dedup. Every outbox row has a unique event_id that flows through Kafka into the consumer. Consumers use it as the idempotency key. Duplicates from retries get discarded.
// PROPERTY 4
Ordering is preserved. Poller reads outbox rows in commit order (by row ID or timestamp). Kafka partition by aggregate ID preserves per-aggregate ordering downstream.

The outbox pattern is the single most-implemented pattern in modern event-driven services — and it should be. It solves a genuinely hard problem with a trick that seems almost too simple: use the ACID guarantees you already have (one DB, one transaction) to bootstrap consistency across systems you don't control (Kafka, downstream consumers). The poller is separate infrastructure — a cron job, a change-data-capture stream (Debezium reading the outbox table as WAL entries), or a dedicated goroutine — but its correctness requirements are minimal: read pending rows, publish, mark sent, repeat. Frameworks that implement this pattern out of the box: Debezium's outbox event router, MassTransit's transactional outbox, Eventuous, and the Temporal SDK's activity model all essentially adopt it.

The outbox is not a design pattern. It is the design pattern for atomically writing state and events.
§ 04 — Naive vs Outbox · failure comparator

Four scenarios.
Two architectures.

Pick a failure scenario. See how the naive "write DB then publish to Kafka" pattern handles it (usually badly) versus how the transactional outbox pattern handles it (usually correctly). The first scenario is a tie — both work on the happy path. The other three are where the outbox pattern earns its complexity.

DELIVERY.SIM // m.31 lab
// NAIVE PATTERN DB write, then publish
Loading...
// OUTBOX PATTERN atomic + async poll
Loading...
// HEAD-TO-HEAD VERDICT
Loading...
...
§ 05 — Kafka's exactly-once semantics · what it delivers, where it leaks

The fine print
on "EOS."

Since Kafka 0.11 (2017), the broker has claimed to support exactly-once semantics — EOS. The claim is technically true, but heavily qualified. Understanding what EOS delivers and where it stops applying is essential — because most teams that adopt Kafka EOS without reading the fine print end up with a system that appears to work but silently fails in specific scenarios where EOS was never designed to help.

// KAFKA EOS · THE THREE MECHANISMS

Idempotent producerenable.idempotence=true

Every producer gets a unique Producer ID (PID) and a per-partition sequence number. The broker tracks the highest sequence number seen per PID/partition; if a producer retries and sends a duplicate, the broker sees the same sequence number and discards it. Duplicate detection at the broker layer, within a single producer session, for retries of the same message. Prevents producer-retry duplicates but not app-level duplicates.

Transactional producertransactional.id=...

Producers can wrap multiple writes across multiple partitions in a "transaction." Either all writes commit atomically or none do. Consumers with isolation.level=read_committed only see committed writes; aborted transactions are invisible. Enables atomic multi-partition writes — useful for read-process-write patterns that update several output topics. Doesn't extend beyond Kafka — a Kafka transaction can't include a write to your external DB.

Kafka Streams EOSprocessing.guarantee=exactly_once_v2

Kafka Streams applications get "exactly-once processing" of the read-process-write cycle within Kafka. Consumer offsets and output writes are committed in the same transaction. If the app crashes mid-processing, the offset isn't advanced and no partial output is visible. Full EOS — for stream processing that reads from Kafka, transforms, and writes back to Kafka. The scope is everything-inside-Kafka; nothing else.

// WHERE EOS STOPS APPLYING
EOS ends at Kafka's boundary.

The moment your consumer writes to an external system — your PostgreSQL, your Redis, a third-party API, an email sender — Kafka's EOS guarantees stop cold. That external write isn't part of the Kafka transaction. If your consumer crashes between "receive event from Kafka" and "write to Postgres," EOS doesn't help you; the offset might get advanced (event skipped) or not advanced (event processed twice). Kafka's fine print says "exactly-once processing" — and the print gets finer if you keep reading: within Kafka. For anything outside Kafka, you're back to at-least-once delivery + idempotent consumer. Which is what the industry runs on anyway.

The pragmatic advice that emerges from a decade of Kafka in production: use idempotent producers unconditionally (they cost nothing and prevent a real class of bugs); use transactional producers when you have Kafka-only read-process-write pipelines (Kafka Streams applications benefit hugely); don't rely on transactional producers for consistency between Kafka and your database — that's what the outbox pattern is for. "Kafka EOS" is a real feature. It's just not the feature most people think it is.

The other lesson: every Phase F pattern converges on the same requirement. Brokers deliver at-least-once (M.26); consumers must be idempotent. Pub/sub can duplicate under redelivery (M.27); subscribers must be idempotent. Event sourcing replays events (M.28); projections must be idempotent. CQRS handlers get retried commands (M.29); handlers must be idempotent. Saga compensations can re-run (M.30); compensations must be idempotent. Six modules of distinct patterns, one underlying obligation. Now you can see why this module was the finale: everything in Phase F needs the ideas in this module to work.

Kafka calls it exactly-once. The fine print calls it exactly-once within Kafka. The distinction matters the moment messages leave Kafka — which is always.
§ 06 — Eight words for the delivery conversation

Vocabulary,
for the guarantees.

The terms every Kafka doc, every "we implemented the outbox pattern" post-mortem, every Two Generals citation assumes you already know.

Two Generals Problem
/tuː ˈdʒɛn.ər.əlz/
A 1975 impossibility proof (Jim Gray) that no finite protocol can achieve certain coordination over an unreliable network. The theoretical basis for why strict exactly-once delivery is impossible.
Idempotency
/ˌaɪ.dɛmˈpoʊ.tən.si/
The property that applying an operation N times has the same effect as applying it once. The escape hatch that makes at-least-once delivery correct in practice.
Effectively-Once
/ɪˈfɛk.tɪv.li/
The pragmatic definition of "exactly-once": at-least-once delivery + idempotent consumer = one observable business effect. What every production system means by "exactly-once."
Idempotency Key
/ˌaɪ.dɛmˈpoʊ.tən.si kiː/
A unique identifier attached to each request. Handler checks "have I seen this key?" before acting. Client-generated UUIDs or hashes; Stripe's Idempotency-Key header is the canonical example.
Transactional Outbox
/trænˈzæk.ʃən.əl/
A pattern where business writes and events-to-publish are inserted into the same DB transaction; a separate poller reads the outbox table and publishes to the broker. The go-to solution for atomic state-and-event updates.
CDC · Change Data Capture
/siː diː siː/
A technique for tailing a database's WAL and emitting changes as events. Debezium is the canonical open-source implementation; often used to implement outbox forwarding without a custom poller.
Kafka EOS
/ˈkɑf.kə iː oʊ ɛs/
Kafka's exactly-once semantics — three mechanisms (idempotent producer, transactional producer, Streams EOS) that guarantee exactly-once within Kafka's boundaries. Doesn't extend to external systems.
Producer ID · PID
/prəˈdjuː.sər aɪ diː/
A unique identifier assigned by the Kafka broker to each producer session. Combined with a per-partition sequence number, enables broker-side deduplication of retried messages within a single producer session.
§ 07 — Knowledge check

Five questions.
Deduplicate the misses.

Test the exactly-once intuition. Click an answer; explanation drops in instantly.

QUESTION 1 OF 5
Loading question...
Score: 0 / 5
5 / 5

Effectively-once.

Perfect. Two Generals, idempotency, outboxes, Kafka EOS — all yours. And with this, Phase F is done. Six modules of event-driven mastery.

§ 08 — The recap

Three ideas to
carry forward.

The layer that makes every other event-driven pattern actually work in production.

i

Exactly-once is a marketing term

Strictly impossible per Two Generals. Practically achievable as at-least-once + idempotent consumers = effectively-once. That equation runs the industry.

ii

The outbox is the pattern

Atomically write business state + event to one DB in one transaction. Separate poller forwards to broker. Consumers dedup on event ID. The canonical solution to atomic state-and-event updates.

iii

Kafka EOS ends at Kafka

Idempotent producers, transactional producers, Streams EOS all work — inside Kafka's boundary. External writes are still at-least-once. The outbox pattern covers what EOS can't.

✦ Phase F Complete ✦

Event-driven at scale.

Six modules. One coherent picture. From "brokers deliver messages" to "how to build a system that treats events as truth, splits reads from writes, coordinates across services, and handles duplicates correctly under every failure mode."

M.26
Brokers
Kafka topics, partitions, consumer groups. The delivery infrastructure.
M.27
Pub/Sub
Routing at scale: direct, fanout, topic, work queues, dead letters.
M.28
Event Sourcing
History as truth. Events as first-class facts. Projections for reads.
M.29
CQRS
Read/write asymmetry taken seriously. Separate models, separate stores.
M.30
Sagas
Distributed coordination without ACID. Compensations, orchestration.
M.31
Exactly-once
The delivery layer. Idempotency, outbox, EOS. What all the others rest on.

Every event-driven pattern in production rests on these six ideas. Together, they're the mental scaffold for reading any modern architecture — Uber's payments, Netflix's playback tracking, Stripe's webhook delivery, Amazon's order flow. The vocabulary is now yours.

↓ NEXT UP · PHASE G · MICROSERVICES IN PRACTICE ↓
↓ PHASE G BEGINS

M.32 — Service
boundaries & DDD.

Phase F gave you the machinery of event-driven systems. Phase G asks the harder question: how do you decide what a service is? Bounded contexts, aggregates, ubiquitous language — the design vocabulary from Eric Evans that separates "we have microservices" from "we designed microservices."

Begin Phase G · Module 32 →