Put a Kafka topic between two services, and sooner or later someone in the design review will say "so we're doing event sourcing now." You aren't. You might be doing something very useful, but it isn't that, and the confusion is expensive in both directions.
Teams that think they have event sourcing because they have a broker end up with no audit trail, no replay, and a Kafka topic with a 7-day retention they're treating as a system of record. Teams that think they need a broker because they want event sourcing end up with a dual-write bug baked into the heart of their persistence layer. Same word, two different problems, and mixing them up gets you the costs of both and the benefits of neither.
Let's pull them apart.
Two different questions
Here's the shortest version I can give you.
Event-driven architecture answers: how do my components find out that something happened somewhere else? It's a communication style. Service A does its thing, then tells the world "an order was placed," and whoever cares reacts. Service A doesn't know who's listening.
Event sourcing answers: how does one component store its own state? It's a persistence strategy. Instead of keeping the current row for an order and overwriting it, you keep the full sequence of things that happened to it, and the current state is whatever you get by replaying them.
One is about the space between services. The other is about the inside of one service. That's why they're orthogonal, and why all four combinations exist in real systems:
| Talks to others via events | Talks to others via calls (or not at all) | |
|---|---|---|
| Stores state as events | Event-sourced service that also publishes integration events | Event-sourced service behind a plain REST or gRPC API |
| Stores current state | Ordinary CRUD service that publishes events through an outbox | Ordinary CRUD service. Most of the internet. |
The bottom-left cell is where most "event-driven" systems actually live, and that's fine. The top-right cell surprises people: an event-sourced service doesn't need a message broker at all.
Martin Fowler has the cleanest example of that last point. In his 2017 piece "What do you mean by 'Event-Driven'?" he points out that there's no need for event processing to be asynchronous in event sourcing, and uses a local git repository as the illustration. Think about it. Every commit is an immutable event appended to a log. The working tree is a projection you can rebuild from that log. And there's no broker, no subscriber, no network. git log is an event store. git checkout is a replay. It's event sourcing on your laptop, and nothing about it is "event-driven" in the microservices sense.
Where the confusion comes from
It isn't just sloppy vocabulary. The umbrella term really does cover several patterns. Fowler wrote that same article because "event-driven" kept getting used to mean quite different things, and he split it into four patterns:
- Event notification. A service sends a message saying something changed. The message is thin, often just an ID, and consumers call back if they need details.
- Event-carried state transfer. The event carries enough data that consumers can keep their own copy and never call back.
- Event sourcing. The log of events is the primary record of state.
- CQRS. Separate models for reading and writing.
Only the first two are about communication. The third is about storage. The fourth is about how you shape your models, and it's frequently bolted onto the third, which is how the confusion compounds. Fowler describes a project manager who blamed event sourcing for a painful project because every change took twice the work to update both the read and write models. That's a CQRS cost, not an event sourcing cost. From that one sentence you can't even tell which pattern was hurting them, because they'd bought them as one bundle. (If you want the CQRS side of that story in detail, I went through it in CQRS in NestJS: When It's Worth the Complexity.)
The word "event" does a lot of damage here too. An event-driven system's events are messages: they travel, they get delivered at least once, they're consumed and eventually forgotten. An event-sourced system's events are records: they're stored forever, read back in order, and they are the data. Same noun, different lifecycle.
What event sourcing actually needs from storage
If you want to know whether something is event sourcing, don't look for a broker. Look for two capabilities in the storage layer:
-
Read one entity's stream, in order, from the beginning. To load order
42, you need every event for order42and nothing else. - Append conditionally. "Write this event only if the stream is still at version 7." Without that, two concurrent commands can both read version 7, both decide the business rule passes, and both append. Now you've sold the last seat twice.
Microsoft's Azure Architecture Center page on the Event Sourcing pattern uses exactly that seat example: two handlers each see five remaining seats, both accept a reservation, and the event store's optimistic concurrency is what rejects the second append so the handler can reload and retry.
You don't need a specialized database for this. A Postgres table with a composite primary key does both jobs:
events.sql
CREATE TABLE events (
stream_id text NOT NULL,
version integer NOT NULL,
type text NOT NULL,
data jsonb NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (stream_id, version)
);
The primary key is the whole trick. Reading a stream is an index range scan on stream_id. Appending with an expected version is just inserting version = expected + 1 and letting the constraint reject a race:
append.ts
import { Pool } from "pg";
const pool = new Pool();
export class ConcurrencyError extends Error {}
export async function append(
streamId: string,
expectedVersion: number,
events: { type: string; data: unknown }[],
): Promise<void> {
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const [i, e] of events.entries()) {
await client.query(
"INSERT INTO events (stream_id, version, type, data) VALUES ($1, $2, $3, $4)",
[streamId, expectedVersion + i + 1, e.type, JSON.stringify(e.data)],
);
}
await client.query("COMMIT");
} catch (err: any) {
await client.query("ROLLBACK");
// 23505 = unique_violation: someone else appended first
if (err.code === "23505") throw new ConcurrencyError(streamId);
throw err;
} finally {
client.release();
}
}
Notice what's missing: a broker. That's a complete, if minimal, event store. Everything else (snapshots, projections, upcasting old event shapes) is built on top of those two operations, and those are the parts that get hard. I won't repeat them here; they're covered in Event Sourcing: The Parts That Are Genuinely Hard.
Why your Kafka topic isn't an event store
Now hold a broker up against those two requirements. Kafka is the usual suspect because it's a durable, ordered, replayable log, which sounds a lot like an event store. Oskar Dudycz's post Event Streaming is not Event Sourcing! is the canonical write-up of why the resemblance is shallow. Here's how it breaks down against the two capabilities.
Conditional append. Kafka doesn't have it. There's a Jira ticket, KAFKA-2260 "Allow specifying expected offset on produce", that proposes exactly this check-and-set on offsets. It was opened in June 2015. As of this writing it's still open, unresolved, and marked Minor priority. That's not a criticism of Kafka. It tells you what Kafka is for: moving events from producers to consumers, not arbitrating which of two writers wins.
Kafka's idempotent producer sometimes gets mentioned as the answer here, and it isn't. It de-duplicates retries from the same producer session using a producer ID and per-partition sequence numbers. It does nothing about two different application instances that both read version 7 and both decide to write.
Reading one entity's stream. Kafka orders messages within a partition, and routes by key, so all events for order-42 land in the same partition in order. But that partition also contains events for thousands of other orders. To rebuild order-42, you'd scan the partition and filter. The workarounds (a partition per entity, a topic per entity) don't scale, which is the point Dudycz and others keep making.
Retention. This is the one that bites people who never meant to do event sourcing but drifted into treating the topic as the source of truth. Kafka's broker default log.retention.hours is 168, which is 7 days. You can set a topic's retention.ms to -1 for no time limit, or switch it to cleanup.policy=compact to keep only the latest message per key. But a compacted topic has, by design, thrown away history. It's a great fit for event-carried state transfer ("here's the latest customer record") and the opposite of an event store ("here's everything that ever happened to this customer").
Azure's pattern page puts it bluntly: don't confuse an event store with an eventstream message broker. Brokers like Kafka typically lack per-entity stream queries and optimistic concurrency. They're a good distribution layer for fanning events out to projections and other services, but not a substitute for the store.
Note
"Kafka can't be an event store" is a slight overstatement. People have built event stores on top of Kafka by adding a consistency layer in front of it, for example with ksqlDB or a database acting as the write gate. That's the point, though: you end up adding the missing pieces yourself. The broker on its own is a pipe.
Event-driven without event sourcing: the outbox
Flip it around. Most services that publish events store plain current state, and that's the right default. The one real engineering problem here isn't event sourcing, it's the dual write: you update your database and publish to the broker, and those are two systems with no shared transaction. Crash between them and one happened without the other.
Here's the version that looks fine and isn't:
place-order.ts
await db.query("UPDATE orders SET status = 'placed' WHERE id = $1", [orderId]);
await producer.send({
topic: "orders",
messages: [{ key: orderId, value: JSON.stringify({ type: "OrderPlaced", orderId }) }],
});
If the process dies after the UPDATE commits and before send resolves, the order is placed and nobody downstream ever hears about it. Flip the order and you can announce an order that was never saved.
The fix is the transactional outbox: write the message into a table in the same transaction as the state change, and let a separate relay publish it.
place-order.sql
BEGIN;
UPDATE orders SET status = 'placed' WHERE id = $1;
INSERT INTO outbox (id, topic, key, payload)
VALUES (gen_random_uuid(), 'orders', $1,
jsonb_build_object('type', 'OrderPlaced', 'orderId', $1));
COMMIT;
A relay (a polling loop, or change data capture with something like Debezium) reads unpublished rows and sends them. It will sometimes send one twice, because it can crash after publishing and before marking the row done. So consumers must be idempotent, which in SQL usually means recording what you've already handled:
consumer-dedupe.sql
INSERT INTO processed_messages (message_id) VALUES ($1)
ON CONFLICT (message_id) DO NOTHING;
-- 0 rows inserted = duplicate delivery, skip the side effect
Look at what this service has: events, a broker, consumers reacting asynchronously. It's thoroughly event-driven. And its source of truth is still the orders table. The outbox table isn't an event log either; rows are published and can be deleted. There's no replay, no stream per order, no fold. That's the most common shape of "event-driven architecture" in the wild, and it has nothing to do with event sourcing.
The coupling trap when you have both
The interesting failures happen in the top-left cell: an event-sourced service that also publishes to other services. The tempting move is to take your stored events and put them straight on the bus. You already have them, they're already events, why write anything new?
Because they're the wrong events for the job. Christian Stettler at INNOQ made this argument in Domain Events vs. Event Sourcing back in 2019. Events used for event sourcing are fine-grained and shaped by your aggregate's internals. In his bike-sharing example, registration produces events like MobileNumberProvided and UserDetailsProvided. A consuming service doesn't want to understand that sequence and stitch it together. It wants UserRegistrationCompleted. Publishing the internal ones, he writes, breaks encapsulation between parts of the system and increases coupling. Azure's pattern page says the same thing more briefly: event sourcing events are typically low level, and you may need to generate specific integration events instead.
Think about what it does to your schema evolution. Your internal events are the ones you're allowed to refactor, upcast, split, and rename as your model matures. The moment another team consumes them, every one of those changes becomes a cross-team migration. You've turned your private storage format into a public API without meaning to.
The fix is a translation step: a projector that reads your internal stream and emits a small number of coarse, stable integration events, written through the same outbox discipline as before.
projector.go
package registration
import "encoding/json"
type StoredEvent struct {
StreamID string
Type string
Data json.RawMessage
}
type IntegrationEvent struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
// Translate maps internal, fine-grained events to public contracts.
// Most internal events have no public counterpart at all.
func Translate(e StoredEvent) (*IntegrationEvent, bool) {
switch e.Type {
case "RegistrationConfirmed":
payload, _ := json.Marshal(map[string]string{"userId": e.StreamID})
return &IntegrationEvent{Type: "UserRegistrationCompleted", Payload: payload}, true
default:
// MobileNumberProvided, UserDetailsProvided, ... stay private
return nil, false
}
}
The default branch is the important line. Most of what happens inside your aggregate is none of anyone else's business.
Even the reference docs blur the line
Here's a fun one. That same Azure page that warns you not to confuse an event store with a broker also describes the pattern's typical flow as: application code raises events, sends them to a queue, and a separate event handler listens to the queue and persists them to the event store. Its overview diagram shows command handlers pushing events to a queue or topic, with one handler writing them into the store.
Read that with the two capabilities in mind. If the event only becomes durable after it's gone through a queue, what does the command handler's optimistic concurrency check run against? And if the queue delivers at least once, what stops the persisting handler from appending the same event twice? You can make that design work, but you've moved the conditional append to the far side of an asynchronous hop, which is exactly where it's hardest to enforce. The safer default, and the one most dedicated event stores are built around, is the opposite order: append to the store first, with the expected version check, and let subscribers read from the store to feed projections and the bus.
I don't think the docs are wrong so much as compressed. But it shows how naturally people draw "event sourcing" with a broker in the middle, even when the text right next to the diagram is telling you they're different things.
When each one is the right call
Since they're independent decisions, make them independently.
Reach for event-driven communication when
- Several consumers care about the same fact and you don't want the producer to know them. Billing, email, analytics, and fulfillment all react to an order being placed. The producer shouldn't need a deploy every time a fifth consumer shows up.
- Consumers can tolerate a delay. The email going out two seconds later is fine.
- You want a service to keep working when another one is down. That's event-carried state transfer's real selling point: keep a local copy of the customer data you need, and a customer-service outage stops being your outage.
Skip it, or keep it small, when
- The caller needs an answer now. "Is this card valid?" is a request, not an event. Publishing it as an event and then waiting for a reply event is RPC with extra steps and worse error handling.
- The business flow is a sequence you need to see. Fowler's warning about event notification is the one that ages best: a flow spread across a chain of event handlers can be hard to see because it isn't explicit in any program text. If a process has five steps and an owner who asks "where is order 42 stuck?", an explicit orchestrator is usually kinder. (I compared two ways of doing that in AWS Step Functions vs Camunda for Sagas.)
Reach for event sourcing when
- The history is the product. Ledgers, balances, inventory movements, anything where "how did we get here?" is a question auditors, support, or customers actually ask.
- Intent matters more than the final value. Azure's page has a good line on this: an event that records two seats were reserved is more valuable than one that records remaining seats changed to 42. If you find yourself wanting the first kind of record, that's the signal.
- You'll want read models you haven't thought of yet. Replaying history into a new projection is the one trick CRUD can't pull off after the fact.
Skip it when
Azure's list of where the pattern might not be suitable is refreshingly honest, and I'd sign every line: straightforward CRUD without audit or replay needs, prototypes and MVPs, systems that need views to be immediately consistent, mostly static reference data like lookup tables and catalogs, and teams without event-driven experience. It also says the thing that should be printed above every whiteboard: event sourcing doesn't have to be all or nothing. Use it for the payment ledger and leave user profiles and application config as plain tables.
My own position is a bit sharper than that. Event sourcing is a per-aggregate decision, not a per-system one, and certainly not a consequence of having adopted Kafka. If you can't name the specific aggregate whose history you need and why, you don't need event sourcing, you need an outbox.
Ask the two questions separately
Next time the design review drifts into "so we're going event-sourced," split the question in two out loud:
- How will other services learn that this changed? Events through an outbox, a direct call, or nobody needs to know.
- How will this service remember what it knows? Current state in tables, or an append-only stream per entity with a conditional append.
You'll find that most of the time the answers are "events" and "tables," which is a perfectly respectable, boring, reliable architecture. Occasionally it's "tables" and "stream," and that's an event-sourced service that nobody outside needs to know is event-sourced. And when it's genuinely both, you'll know to keep two sets of events: the private ones you store, and the public ones you promise.
Originally published at andriiboyko.com.


Top comments (0)