The Anatomy of an Event
The shape of the whole thing
Six stages, from the cost hiding inside a name like OrderEvent to the discipline of owning the decision on purpose. Read it top to bottom, or jump to the part you came for.
One over-general type (OrderEvent) looks free the day you write it — the coupling is really C × N, and it's invisible until a stranger reads the log.
Three ways of not deciding — the grab-bag, the command wearing a fact's clothes, thin-or-fat by reflex — each cheap on day one and expensive once strangers show up.
Fact, command, notification — the broker treats all three identically; which one you meant decides your coupling, your replay-ability, and who's allowed to say no.
id, key, two clocks, type, version, correlation and causation — the metadata every consumer and every tool will depend on, and the one thing you can't retrofit.
Thin vs. fat, one fact vs. many — decisions with no universal answer, plus how Stripe and CloudEvents actually resolved them.
Who pays for a bad intent call, when the discipline earns its cost, and the questions worth asking before a stranger reads your log.
Introduction
The topic was called order-events, and the message type on it was OrderEvent. Singular. One type for everything an order could do. It carried a status field, and that field read placed at first. Then paid. Then cancelled. Then refunded, once finance asked for it.
At the time this was the reasonable choice, and I want to be fair to it. One type meant one schema to register and one consumer group to wire up. When the next order state came along, you added a string to an enum. You did not stand up a whole new stream for it. Three teams subscribed to the topic. Every one of them opened its handler the same way: switch (event.status). I approved that design. I'd understand why someone would.
The bill arrived the day we added partially_refunded.
The analytics consumer had never heard of that status. It hit the default branch of its switch and counted a partial refund as a full one. It did this for a week. The number it produced was wrong in a dashboard a VP read every morning — wrong in the boring way that doesn't throw an exception and doesn't page anyone. Meanwhile the search indexer only ever cared about one status, placed. But it had to receive every other status too, just to find the ones it wanted, so it had spent a year deserializing payments and refunds and then throwing them away. And the fraud team, it turned out, had started emitting OrderEvent too, with status: hold — using our fact stream to send a command, telling the order service to go do something and quietly waiting for it to happen.
Nobody designed any of that. Each piece was one reasonable pull request. OrderEvent had simply let us not decide what these messages were, and the not-deciding shipped to production and went to work.
One over-general message type forces every consumer to branch on a discriminator field, which couples all of them to all of its variants. Find the message in your system that everyone has to switch on.
That is the decision this chapter is about, and it's the one Chapter 1 set up. Chapter 1 asked whether to turn a call into an event. This chapter asks the next question: once you've decided to, what shape does the event take? And the shape is not a small thing, because you choose it once and then live with it for as long as the log exists.
Here is the idea the whole chapter turns on. Every message you publish has an intent. It is one of three things — a fact, a command, or a notification — and which one it is decides three things you care about: how tightly it couples you to other services, whether you can replay it, and how far the damage spreads when it's wrong. The broker will not decide this for you. A broker moves bytes. It has no opinion about whether those bytes describe something that already happened or order something to happen next. That distinction was always yours to make. The trouble with a name like OrderEvent is that it's a way of agreeing to make the decision later — at runtime, under load, inside someone else's switch statement.
Here's the ground we'll walk together:
- The three intents hiding inside the word "event," and how each one quietly sets your coupling and decides who is allowed to reject what
- Why
OrderEventisn't really a name — it's a decision postponed — and what postponing it costs every consumer you've met and every one you haven't - The envelope you'll wish you'd standardized on day one, and the difference between a correlation id and a causation id when you're staring at a broken three-hop flow at 3 AM
- How much state to put on an event: the thin-versus-fat trade that buys you autonomy and bills you in coupling and versioning
- Why an event is the one record you can never take back, and what that means the morning legal asks you to delete a user
By the end, a design review that opens with "let's just publish an OrderEvent" should sound to you like someone proposing to skip the only decision on the table.
The Problem, Precisely
Naming a message "OrderEvent" doesn't avoid the decision. It defers it — downstream, to runtime, into a switch statement, under load.
Start with the thing that makes event modeling harder than API design. It's a single property, and it's the reason this chapter needs to exist: an event, once published, is immortal. And it is read by consumers you don't control and can't even list.
Compare it to an HTTP endpoint, which is the shape most of us reason about by reflex. An endpoint is a contract with whoever is calling it right now. It's versioned at the URL. When you want to change it, you ship a v2, watch the traffic drain off v1, and delete v1.
You can't do any of that to an event. The event sits in the log long after you've forgotten why you wrote it. Code you haven't written yet will read it. A consumer that subscribes for the first time next year will read the events you're emitting today. So you are not writing a contract with the callers you have. You're writing a contract with strangers, across time — and you get to sign it exactly once.
Now put a number on the anemic OrderEvent against that backdrop. Say you have C consumers subscribed to the topic. Say the type carries N variants behind its status field. Every one of those C consumers has to handle, or at least deliberately ignore, all N of the variants. So the coupling in the system isn't C relationships. It's C × N.
Watch what that does when the business adds one more state. You add variant N+1. In the same moment, you have potentially broken all C consumers at once — and broken them silently, because a new enum value doesn't raise an exception. It slips through a default branch and turns into a wrong number somewhere downstream.
Now picture the disciplined version instead. Three separate fact types, one per thing that actually happened. The search indexer subscribes to exactly the one it wants, and it never even deserializes the other two. The coupling collapses from C × N down to the handful of real (consumer, fact) pairs that genuinely matter. Same information moved through the system. Wildly different blast radius. And the entire difference came from how you named and split the messages.
Here's the part that catches experienced people. None of this shows up while you're building it. On the day you design the grab-bag, N is 2 and C is 1 and you are the only consumer and you know every case by heart. The grab-bag is genuinely less code that day. The cost isn't in the code — it's a function of time and reach, and both of those are zero at the start. You didn't make a mistake you could see in review. You signed a contract whose terms only print once there are strangers on the other end of it.
Naive Solutions, and What They Cost
Before the taxonomy, the three ways teams actually model events before they've been burned. They show up in this order, roughly, because each one is the path of least resistance at the moment it's chosen.
One event type to rule them all. This is the grab-bag from the opening. You emit OrderEvent with a status field, and you make the payload big enough to cover every case the order might be in. The appeal is real and worth naming honestly: one topic, one schema in the registry, one consumer group, and evolution that feels like appending a string to an enum. Then the costs arrive, and they arrive together. Every consumer switches on the discriminator, so every consumer is coupled to every variant — including the ones it never wanted. A new variant falls through some consumer's default and becomes a silent wrong answer instead of a loud failure. And the schema swells into the union of every field any variant ever needed, so half of every message is null and nobody can tell which halves are legal at the same time. It ships anyway, and here's why: on day one the grab-bag really is smaller than the alternative, and you have not yet met the consumers who will pay for it.
Commands wearing a fact's clothes. The second one is subtler. You publish something like CaptureFunds or SendEmail to a topic, and you call the system event-driven — because it's on Kafka now, and Kafka is where events go. But those aren't facts. They're instructions. Each one is addressed to a particular handler, and each one expects something to happen as a result. The tell shows up fast. The publisher can't actually move on until the funds are captured, so it starts polling for the result. And there it is: you've rebuilt synchronous request/response, except now it runs over a broker. You're paying for the log and keeping the exact temporal coupling Chapter 1 said events were supposed to cut. It ships because "we put it on a queue" sounds like decoupling, and nobody looks closely enough to notice that the producer is still, in every way that matters, waiting for a reply.
Thin or fat, chosen by reflex. The third one is a decision nobody remembers making. In the first pull request, someone picks whether the event carries the whole record or just an id — and picks it by habit, not by argument. The thin camp emits {orderId: 4815} and lets each consumer call back to the order service for the details. That feels clean and small. It stays clean right up until you notice that every consumer now needs the order service to be up in order to process an event at all — which is temporal coupling, smuggled back in through the read path. The fat camp does the opposite and serializes the whole order object onto the event. That feels self-contained. It stays self-contained right up until the order object's internal shape has quietly become a schema that forty consumers depend on, and a field you rename for your own convenience breaks teams you didn't know existed. Both are defensible. The problem is that neither was decided. A real per-stream trade-off got made once, globally, by whoever typed first.
Failure Modes Worth Naming
Here is the field guide before the framework. Each of these is a modeling decision coming back to visit you as an incident.
| Failure mode | Trigger | Symptom on-call sees | Cost |
|---|---|---|---|
| Anemic event (the grab-bag) | One over-general type + a discriminator every consumer switches on | A new variant breaks or silently mis-handles consumers that never cared about it | Silent wrong numbers; every consumer coupled to every variant |
| Command disguised as event | An addressed instruction published as if it were a broadcast fact | The "async" producer's latency tracks a consumer's; a poll loop shows up | The temporal coupling you adopted events to escape, now undocumented |
| Unreconstructable flow | Events carry no correlation or causation id in the envelope | "Where did this come from?" has no answer; nobody can rebuild the chain | A multi-hop incident you can only guess at |
| PII in an immortal event | Personal data serialized into the payload of a fact | Legal asks you to delete a user; the data is in events from two years ago | A retroactive crypto-shredding project, or a compliance finding |
Two of these are intent errors and two are envelope errors, and that split is the whole rest of the chapter. The anemic event and the disguised command are what happens when you model the wrong kind of message. The unreconstructable flow and the immortal PII are what happens when you model the message fine but forget the metadata wrapped around it. So the next two sections are exactly those two corrections, in that order: first get the intent right, then get the envelope right.
Getting the Intent Right
Every message you publish is one of three things. The broker treats all three of them identically — same publish() call, same bytes on the wire — and that is precisely why you have to be the one who knows the difference.
A fact is a record that something happened. It's past tense: OrderPlaced, PaymentAuthorized. It's immutable, because the thing already happened and can't un-happen. It has no addressee — it's simply true, and it's published for anyone who cares to read it. Nobody can reject a fact; there's nothing to reject. The producer owns it and takes on no dependency at all on who consumes it, or whether anyone does. This is why facts are the foundation of loose coupling. It's also why they're the only intent that genuinely replays: replaying a fact just re-asserts something that was already true, whereas — as we'll see in a second — replaying a command re-issues an order.
A command is an instruction to do something. It's imperative: CaptureFunds, CancelOrder. It's addressed to a specific handler, and that handler is expected to act on it. And crucially, a command can be rejected — the funds can be insufficient, the order can already have shipped. That right of refusal is the sign you're looking at a command and not a fact. A command creates a directed dependency, pointing from the sender to the handler: the sender cares that someone acts. That's exactly the dependency a fact doesn't have. Commands are legitimate and necessary; plenty of real work is issuing them. The failure isn't using a command. The failure is disguising one as a fact and broadcasting it — because then you've built a directed dependency and hidden it inside a stream that advertises independence.
A notification is a minimal "something changed, go look." It's a fact stripped down to almost nothing: an id, plus the fact that something changed. OrderChanged. It decouples the producer from the payload, which sounds thrifty and appealing. But look at what it forces. Every consumer that cares now has to turn around and call back to the source for the details. And that callback is temporal coupling coming in through the back door: the consumer can't finish processing the notification unless the source system is up to answer the callback. A notification is a thin fact that has outsourced its own contents, and the outsourcing has a bill.
| Tense | Addressed to | Can be rejected? | Coupling it creates | Who owns it | Replayable as truth? | |
|---|---|---|---|---|---|---|
| Fact | Past tense | No one | No | Loose | Producer | Yes |
| Command | Imperative | A specific handler | Yes | Directed, to the handler | Sender | No — it re-issues an order |
| Notification | "Changed" | No one | No | Temporal, via the callback | Producer | Yes, but it re-fires the callbacks |
The intent of a message is its real API. The name is just where you write the intent down. OrderEvent is an API that says "figure it out at runtime." OrderPlaced is an API that says "this happened; you can build on it."
That's why naming isn't cosmetic, and it's worth being precise about the mechanism. A specific, past-tense fact name — OrderPlaced, not OrderEvent, and not the imperative CreateOrder — is the one place the fact/command/notification decision becomes visible and reviewable. A name like OrderEvent is unfalsifiable. You cannot look at it and tell whether the message is a fact or a command. You therefore cannot tell whether it should be broadcast or addressed, replayed or re-issued. Think back to the fraud team from the opening, emitting OrderEvent{status: hold}. The name is what let them get away with it: a command was hiding inside a type that had already surrendered the distinction. Make the intent legible in the name, and the wrong intent has nowhere left to hide. A reviewer sees HoldOrder sitting on a stream of facts and asks the obvious question — the exact question the grab-bag let everyone skip.
The Envelope You Wish You'd Standardized
Getting the intent right settles what the message is. The envelope settles whether you can operate it in production. These are two different jobs, and the second one is easy to forget until it's expensive.
Every event is really two things wearing one coat. There's the payload — the domain-specific part, the fields that vary from one event type to the next. And there's the envelope — the standardized metadata that wraps every event, no matter its type. The envelope is boring. It's also the single highest-leverage thing to get right on day one, for one reason: it's the part that every consumer and every piece of tooling depends on, and it's the part you cannot retrofit once a billion events already exist without it.
A serviceable envelope carries a short, specific list. A unique id, so a consumer can dedup — remember from Chapter 1 that at-least-once delivery guarantees you'll see some events twice. A key, naming the entity whose ordering matters; that's the whole subject of Chapter 5, so we'll leave it there for now. Two timestamps, not one: an occurred-at for when the thing happened, and a recorded-at for when the log heard about it. Those are different numbers, and quietly conflating them is a whole genre of bug. A type and a version. And finally, the two ids that let you rebuild a flow after the fact: a correlation id and a causation id.
The correlation id you already met, in the Internet-Scale Product Systems atlas. It's one id shared by every event in a single logical flow, so you can gather them together. The causation id is its sharper sibling, and it's the one that event systems specifically need. It's the id of the single event or command that directly caused this one. The two answer different questions. Correlation says: "these forty events all belong to order 4815's journey." Causation says: "this ShipmentRequested was caused by that PaymentAuthorized, which was itself caused by that OrderPlaced." One of them groups the family. The other one draws the family tree.
Correlation only: every event glows the same color — you know they belong to the same flow, but the arrows between them are dashed guesses.
On a good day you want the family: show me everything that touched order 4815. At 3 AM you want the family tree: this refund just fired, and I need to know exactly which event caused it, because something in that chain lied to me.
One more payoff, and then we'll move on, because the mechanics of it belong to a later chapter. Standardizing the envelope is also what lets you change events later without agony. The version field is the seam Chapter 6 pulls on when it argues that you can't deprecate the past. I won't teach schema evolution here. I only want you to notice one thing now: every escape hatch Chapter 6 offers you depends on the envelope having reserved room for it before the first event shipped. The teams who suffer most in Chapter 6 are the ones who skipped this section. (An illustrative envelope, and a fact-versus-command naming pair, are in Appendix A.)
Tradeoffs Worth Arguing About
Two decisions in this chapter don't have a right answer. They have a per-stream answer, and the job is to make it on purpose.
Thin versus fat: how much state does the event carry? A thin event carries an id and a pointer — "order 4815 changed, go look." A fat event carries the whole denormalized order, so the consumer never has to call back for anything.
Take the thin side first. What it buys you is a small, stable message that leaks nothing about your internals. What it costs you is a callback for every consumer — which, as we saw a moment ago, is temporal coupling coming back through the read path, plus a load multiplier on the source system every consumer now has to call.
Now the fat side. What it buys you is consumer autonomy: a consumer can process the event on a desert island, with no callback to anyone. What it costs you is two things, and they compound. First, every field you expose becomes a field some stranger comes to depend on — so the event's schema slowly becomes your internal model, published to everyone, forever. That's logical coupling, in the vocabulary of Chapter 1. Second, every one of those exposed fields now inherits Chapter 6's schema-evolution problem.
So there's no universal answer, only a per-stream one. A stable entity with high fan-out leans fat, because the autonomy compounds across all those consumers. A fast-churning internal model read only by your own team leans thin, because you don't want to publish your churn to anyone. What you don't get to do is default the choice in the first PR and then act surprised. Chapter 8 comes back to this decision with real throughput and storage numbers, once read models are on the table.
Every field you add to an event is a promise to a consumer you haven't met yet. Generosity in an event schema is just coupling that hasn't been billed to you yet.
Granularity: one fact per event, or many? When a customer checks out, is that one OrderPlaced carrying five line items? Or is it five LineItemAdded facts plus an OrderPlaced?
Fine-grained events are precise. Each one replays independently, and a consumer can subscribe to exactly the fact it cares about. The cost is throughput — more messages, more overhead — and the loss of cross-fact atomicity, because a consumer can now catch three of your five line items while it's still mid-stream. Coarse-grained events are the mirror image. They're atomic and cheap per unit of information: one message, all-or-nothing. The cost is that every consumer has to take the whole batch and pick through it for the part it wants — which, if you look closely, is the anemic-event failure sneaking back in through a different door.
The honest resolution is to let granularity follow the consumer's unit of decision. If everyone reacts to the whole order, one coarse event is right. If different consumers react to different line items, the fine-grained split earns its overhead. It depends — and it depends on who's reading, not on what's convenient for you to emit.
What the Companies Actually Built
Stripe publishes the cleanest working example of this entire chapter, and the nice part is you can just read it in their docs.
When something happens to a charge, Stripe emits an Event object. Its type is a past-tense fact — charge.succeeded, charge.refunded, invoice.created. The envelope around it is textbook: an id, a created timestamp, the type, an api_version, and a data field holding the payload. Two details in there are worth stealing outright. The first is the api_version, which is stamped into the event itself. Stripe's own docs note that the contents of data never change after the fact is recorded. So the event is immortal and versioned right there in the envelope — and that is exactly the discipline that lets Stripe keep their webhooks working for years without breaking the thousands of external integrations reading them. The second detail is the intent split, hiding in plain sight. POST /v1/charges is the command — it's addressed, it's rejectable, and it returns a 402 if the card declines. charge.succeeded is the fact that may result from it. Same business moment, two different message intents, and Stripe never conflates the two. They can't afford to. They have millions of consumers they've never met, and that is precisely the condition that turns naming from bikeshedding into architecture.
CloudEvents is what the industry built after enough teams re-invented the envelope badly. It's a CNCF project — a vendor-neutral spec for exactly the metadata wrapper this chapter has been arguing for. It defines four required attributes: id, source, specversion, and type. It adds a few optional ones — subject, time, datacontenttype — and the data payload. And it sets one rule worth remembering: source plus id must be unique for each event. That's the whole idea. It exists because the envelope is the part everyone needs and almost everyone gets slightly wrong, and standardizing it means your events can cross broker and vendor boundaries without a translation layer in between. You don't have to adopt CloudEvents. But if you catch yourself inventing an envelope from scratch, read it first — as a checklist of the fields you're about to forget.
And now the example that complicates the chapter's own advice. Domain-driven design and EventStorming — the practices associated with Eric Evans and Alberto Brandolini — exist in large part because naming is the hard part. EventStorming, mechanically, is a room full of people arguing about what the domain events even are and what to call them, in past tense, on orange sticky notes, before anyone writes a line of code. If fact-versus-command-versus-notification were obvious, that workshop wouldn't need to exist. And here's the complication for this chapter's tidy little taxonomy: real domains are full of messages whose intent is genuinely contested. Is OrderConfirmed a fact about something the customer did? Or is it a notification that the system changed state? The taxonomy doesn't hand you the answer. What it does is tell you which question to argue about — it doesn't spare you the argument.
Technologies Worth Knowing
This section is short on purpose. The tooling that carries events is Chapter 3's subject, and the registry that governs how they evolve is Chapter 6's, so all that belongs to the anatomy is a short list.
CloudEvents is the envelope standard from the section above. The one-line takeaway: reach for it before you hand-roll your own metadata.
A schema registry — Confluent's, and the others like it — is where the envelope-and-payload contract gets enforced instead of merely documented. Concretely, it can reject a producer that tries to publish an incompatible event, and it can do that before the incompatible event becomes immortal. I'll stop there deliberately. How a registry decides what counts as compatible, and what "backward" versus "forward" compatibility actually buys you, is the whole subject of Chapter 6 — and it's the payoff for having put a version in your envelope back in this one.
The Principal Engineer's Perspective
When this discipline earns its complexity — and when it doesn't. Start with the case where it doesn't. If you have one producer, one consumer, and a handful of event types you can all hold in your head, the grab-bag is fine and the full taxonomy is overhead. The discipline starts earning its keep at a specific moment: the moment you can no longer enumerate your consumers. That happens when a second team subscribes, or when events cross an org boundary, or — worst case for your future self — when they reach external developers. So the rigor scales with how many strangers read your events. It's cheap to adopt early and brutal to retrofit late. Which means the real question to ask is simply: "Will this stream ever have consumers I don't control?" For anything living on a durable log, the honest answer is usually yes.
Who pays. The costs here don't land where they're created, which is why they're easy to miss. The producer of a fact takes on a permanent, invisible liability: it now owns a contract with every consumer, present and future, and it can't break that contract without breaking them. But that cost lands later, on whoever eventually tries to change the event and discovers it has forty dependents. Consumers pay in the other direction. An anemic event taxes every one of them with a switch and a coupling to variants they never asked for. Good event modeling is, in large part, just moving these costs to where someone will actually notice them before production does.
The business decision in an engineering costume. "Is this a fact or a command, and what do we call it?" reads like pedantry. It's actually a question about ownership. Whoever owns a fact stream owns the power to break every consumer of it with a single schema change. So deciding what counts as a fact is really deciding where authority sits — and that authority belongs with the people who own the domain, not with the platform team who happen to own Kafka. The grab-bag is corrosive here precisely because it relocates that authority to nobody in particular, which is why it rots. Name the intent, and you've named the owner.
The observability that has to exist first. Don't ship a multi-hop event flow until correlation and causation ids ride the envelope through every hop. The reason is timing: the day you need those ids is the day you're already inside an incident, and by then it's far too late to add them to events that already happened. Causation is the difference between "here are forty events from that hour" and "here is the exact chain that produced the wrong refund." Chapter 13 is the whole flashlight; this is just the wiring you have to install before you'll ever get to switch the light on.
Cost awareness. Standardizing an envelope, running a registry, and holding the line on intent is a real up-front cost. It's governance, review, and a little ceremony on every new event type. But weigh it against the right alternative. The alternative isn't "no cost" — it's deferred cost, with interest. That deferred cost is the migration when the grab-bag finally breaks, the crypto-shredding project when legal finds the PII, and the un-debuggable flow at 3 AM. The up-front tax is small, predictable, and paid by the team that benefits from it. The deferred one is large, unpredictable, and paid by whoever happens to be on call.
Questions to take back to your team.
- For the event we're about to add — say it out loud: is it a fact, a command, or a notification? If the honest answer is "sort of a fact, but the producer needs someone to act on it," you have a command in disguise and a coupling you haven't admitted to.
- Can we name it in the past tense, without leaning on the word "Event" as a suffix? If
OrderPlacedloses information thatOrderEventplus astatusfield carried, that's the tell that we're modeling a grab-bag. - Does every event carry a correlation id and a causation id today? Not "should it" — does it, and has anyone actually reconstructed a real multi-hop flow from them?
- If a consumer we've never met subscribes tomorrow, what does our event leak about our internal model — and are we prepared to keep that shape stable forever?
- What's our answer when legal asks us to delete a user whose data lives in two-year-old events? A blank stare means we already made a decision about immortality by not making one.
An event's intent — fact, command, or notification — is its real API, and its name is just where you commit that intent to writing. OrderEvent isn't a name; it's a decision postponed until production. Decide it on purpose, write it in the past tense, wrap it in an envelope you'll still trust in two years, and remember that everything you put on an event you are promising to a stranger, forever.
Exercises
None of these has a clean answer, and that's the point. Paste any of them into an AI and you'll get a confident, well-structured reply in four seconds — one that skips the single contested case that actually matters. The value is in the argument, not the answer.
Exercise 1 — Reclassify. Take five event types from a system you own and label each one: fact, command, or notification. The interesting output isn't the four you label easily. It's the one you can't cleanly label — the message whose intent is genuinely contested — because that's where your domain model is unresolved, not where the taxonomy failed you. Argue it out with someone. The argument is the design.
Exercise 2 — Rebuild the grab-bag, in your domain. Our worked example was OrderEvent with its status field. Rebuild it in your own system. Name your over-general type. Name its discriminator field. Name the consumers that switch on it. Then find the part that teaches: the point where the mapping breaks — the specific incident a real fact/command split would have saved you. If it maps cleanly with no break at all, you may already be living inside the failure and calling it normal.
Exercise 3 — Audit an envelope. Take one real event from your system and check its envelope against the list in this chapter: id, key, occurred-at and recorded-at, type, version, correlation, causation. Now try to reconstruct an actual multi-hop flow from your logs, using only what's actually there. Whatever's missing is precisely the incident you can't currently debug.
Exercise 4 — Argue thin versus fat, with numbers. Pick one stream. Make the strongest possible case for a fat event, and then the strongest possible case for a thin one — using your real fan-out, your real change frequency, and your real source-system load. "It depends" is allowed here only if you say exactly what it depends on, and then actually pick a side.
Exercise 5 — Write the deletion story. Choose an event that carries user data. In three sentences, write down what you would do if that user invoked a right to be deleted. If your answer requires rewriting the log, you've just discovered why "an event is immortal" is a design constraint and not a slogan — and you've found the exact conversation Chapter 6 is going to want to have with you.
Connections
← From Request/Response to Events (Chapter 1). Chapter 1 said that converting a call into an event takes on logical coupling. This chapter says which intent you choose decides how much of it you take on. A fact is the low-coupling option. A command-as-event is directed logical coupling, with the temporal coupling smuggled back in. A thin notification trades payload coupling for a callback. Fact/command/notification is the lens the accidental-adoption failures were missing — the grab-bag is what "backing into events" looks like at the level of a single message.
→ Brokers, Logs, and Queues (Chapter 3). Intent and infrastructure line up with each other. Facts want a log — retained, replayable, fanned out to many readers. Commands often want a queue — one handler, an ack, delete-on-consume. Chapter 3 separates those two sets of semantics, and shows why choosing the carrier by familiarity, instead of by the intent you just identified, is what buys you a migration later.
→ Event Schema Evolution (Chapter 6). Everything the envelope's version field made possible gets spent in Chapter 6. "You can't deprecate the past" is the immortality of facts from this chapter, turned into an operational discipline — including the crypto-shredding answer to the PII-in-an-immortal-event problem that this chapter only named and walked past.
→ Event-Carried State (Chapter 8) and Workflows (Chapter 9). The thin-versus-fat trade comes back in Chapter 8 with real numbers, once consumer-owned read models are the subject. And the command intent we isolated here becomes first-class in Chapter 9's process managers — the very distinction you're learning to name is what keeps a saga from decaying into the distributed monolith of Chapter 16.
Appendix A: Reference Implementations
The snippets below are illustrative, not production drop-ins. They exist to make the envelope and the intent split concrete.
A.1 — An event envelope (payload separated from metadata):
{
"envelope": {
"id": "evt_01HZX8...", // unique — the dedup key for at-least-once delivery
"type": "OrderPlaced", // past-tense fact; NOT "OrderEvent"
"version": 3, // the seam Chapter 6 pulls on
"key": "order_4815", // the entity whose ordering matters (Chapter 5)
"occurred_at": "2026-03-01T14:07:02Z", // when it happened
"recorded_at": "2026-03-01T14:07:04Z", // when the log heard about it — different number
"correlation_id": "corr_ord_4815", // groups the whole flow
"causation_id": "evt_01HZX7..." // the exact event that caused THIS one
},
"payload": {
"order_id": "order_4815",
"line_items": [ /* domain-specific; varies by type */ ]
}
}
The load-bearing parts are all in the envelope. id is what makes the consumer idempotent against redelivery. causation_id is what makes the flow reconstructable. version is what makes evolution survivable. The payload is the only part that varies by type, and the only part a consumer should ever have to change its code for. Standardize the envelope once, across every type, and you write the operational tooling — dedup, tracing, replay — once too. Note what's deliberately absent: no raw PII in the payload, because this record is immortal. (Chapter 6 covers crypto-shredding for the cases where you can't avoid it.)
A.2 — The same business moment, as a command and as a fact:
// COMMAND — imperative, addressed, rejectable. Returns a result.
POST /v1/charges { amount: 4200, source: "card_..." }
→ 200 { id: "ch_...", status: "succeeded" }
→ 402 { error: "card_declined" } // a command can be refused
// FACT — past tense, unaddressed, immutable, broadcast to any subscriber.
{ "type": "charge.succeeded", "data": { "id": "ch_...", "amount": 4200 } }
The command is a request to do something, and it can fail. The fact is a record that something did happen, and it cannot. Conflating the two — publishing charge.succeeded as the thing that triggers the capture, or accepting CaptureFunds onto your fact topic — is the command-disguised-as-event failure. It's how a stream that advertises decoupling ends up with a producer quietly polling for a reply.
Next: Chapter 3 — Brokers, Logs, and Queues: why the tools that carry facts and the tools that carry commands share a vocabulary and almost nothing else, and how choosing by familiarity buys you a migration.