On one project, Martin Fowler writes, a capable and experienced project manager told him that event sourcing had been a disaster: every change took twice the work, because both the read and the write models had to be updated. Fowler’s reaction, in his 2017 note “What do you mean by ‘Event-Driven’?”: “Just in that phrase I can detect a potential confusion between event-sourcing and CQRS - so how can I figure out which was culprit?” The tech lead blamed something else entirely, lots of asynchronous communication, which Fowler calls a known complexity booster “but not one that’s a necessary part of either event-sourcing or CQRS.”
Three people, three diagnoses, one word. That’s the problem with event-driven, and it’s why I don’t let a team adopt events until it has two things the hype never provides: precise names for the patterns hiding under that word, and an honest ledger of what each one costs. Get the names right and the decisions become tractable. Get them wrong and you pay for patterns you never meant to buy.
The Four Things “Event-Driven” Means
Fowler’s note summarizes a Thoughtworks summit whose biggest outcome was “recognizing that when people talk about ‘events’, they actually mean some quite different things.” They identified four patterns.
1. Event Notification. A system announces a change in its domain (OrderPlaced, CustomerMoved) and “doesn’t really care much about the response.” The event often carries little more than an ID, and recipients call back for details. This is the entry-level pattern and the one most systems actually need.
2. Event-Carried State Transfer. The event carries the changed data itself, so a recipient can keep its own copy and “never needs to talk to the main customer system in order to do its work in the future.” You trade duplication for resilience and latency: consumers keep working when the source is down and answer without a remote call. Fowler’s cost accounting is blunt: “more complexity on the receiver, since it has to sort out maintaining all the state.”
3. Event Sourcing. “The event store becomes the principal source of truth, and the system state is purely derived from it.” The example every developer already uses is git: commits are the event store, your working copy is derived state. You get audit trails, historic replay and “what if” histories. You pay with schema evolution over time, and replays become “problematic when results depend on interactions with outside systems.”
4. CQRS. Separate models for reading and writing. Fowler is cool on this one: “Strictly CQRS isn’t really about events,” and “I find many of my colleagues are deeply wary of using CQRS, finding it often misused.”
The rule that falls out: these patterns compose, but none requires the others. Notifications without sourcing, sourcing without CQRS, even event sourcing without asynchrony: “consider the case of updating a local git repository - that’s entirely a synchronous operation.” When a team says “we’re going event-driven,” it almost always means pattern 1, occasionally 2. Patterns 3 and 4 are specialized tools with steep bills.
What You’re Buying
Strip the vocabulary away and the notification patterns buy you three kinds of decoupling.
Temporal decoupling. The producer doesn’t wait for its consumers. An order service that publishes OrderPlaced and returns doesn’t care that the email service is slow or down; the broker absorbs the gap. In the synchronous version, checkout latency is the sum of every downstream call and its availability the product of theirs. Five dependencies at 99.9% each leave you around 99.5% before your own code has failed even once.
Load buffering. Spikes land in a queue instead of on your consumers. A flash sale becomes a backlog that drains at the consumers’ sustainable rate: stale for a while instead of down. It’s the overload logic from the timeouts and backoff article, enforced by infrastructure instead of client discipline.
Extensibility. Adding a fraud check to OrderPlaced needs zero changes to the order service. In request/response, the producer grows a new call, and a new failure dependency, for every new consumer.
What You’re Paying
The costs are structural, and each one traces back to a fundamental from earlier in this series.
Eventual consistency everywhere. Every consumer is a lagging replica of the producer’s state, exactly the situation from the CAP article. Some screens will show orders that inventory hasn’t seen yet. That has to be a product decision, not a surprise.
At-least-once delivery, so every consumer dedupes. The broker will redeliver; that’s not a bug, it’s physics. Every consumer needs an idempotent handler, and every producer that writes state and publishes needs an outbox. The dual-write problem is the birth defect of event-driven systems, and the next article builds the fix.
The invisible flow. This is Fowler’s deepest warning. When logic is spread across notification handlers, “it can be hard to see such a flow as it’s not explicit in any program text. Often the only way to figure out this flow is from monitoring a live system.” It’s very easy to build “nicely decoupled systems with event notification, without realizing that you’re losing sight of that larger-scale flow, and thus set yourself up for trouble in future years.” Correlation IDs and tracing, from the observability article, go from nice-to-have to survival gear.
The passive-aggressive command. Fowler’s sharpest small observation: an event “used as a passive-aggressive command. This happens when the source system expects the recipient to carry out an action, and ought to use a command message to show that intention, but styles the message as an event instead.” My test: if the producer would be broken by the consumer ignoring the event, it’s a command wearing an event costume, and the decoupling is fiction.
Events Are Contracts With Strangers
A published event is an API whose consumers you may never meet. Renaming a field is a breaking change for people you don’t know exist. Three rules keep that survivable.
Evolve forward-compatibly. Gunnar Morling’s rule of thumb, from his outbox retrospective: you can add new fields and drop existing optional fields, so consumers still on the old schema keep working. Renaming a field or changing its meaning is a new version, not an edit.
Wrap every event in the same envelope. Identity, type, version and ordering metadata should never require parsing the payload:
type Envelope struct {
ID string `json:"id"` // consumers dedupe on this
Type string `json:"type"` // "order.placed"
Version int `json:"version"` // schema version of Data
AggregateID string `json:"aggregate_id"` // partition key: per-order ordering
OccurredAt time.Time `json:"occurred_at"`
CorrelationID string `json:"correlation_id"` // ties the whole flow together
Data json.RawMessage `json:"data"`
}
If you’d rather not invent your own, CloudEvents standardizes these attributes.
Give every event type one owner, in writing. The producing team owns the schema. Consumers get a deprecation window, not a surprise.
Do all three before the second consumer appears. Retrofitting versions onto events already in the wild is where “decoupled” systems find out how coupled they are.
Choreography vs. Orchestration
Once a flow spans several services, a second question shows up: who owns the sequence?
Choreography: each service reacts to events and emits its own, and the flow emerges. Maximum decoupling, minimum visibility: Fowler’s invisible-flow trap at full strength. Fine for short chains like order → email or order → analytics.
Orchestration: an explicit coordinator calls each step and tracks the state. The flow is readable in one place, testable and debuggable, at the cost of a central component that knows about everyone. For multi-step business transactions with failure handling (payment, reservation, shipping) it’s usually the right call, and it’s the doorway to sagas, covered later in this series.
My rule: choreograph notifications, orchestrate workflows. If a sequence has more than three steps or any compensation logic, make it explicit.
The Incremental Path
Don’t redesign your system around events. Pick one flow where temporal decoupling clearly pays (the confirmation email after checkout is the classic) and do this:
- Keep the synchronous core. Publish
OrderPlacedthrough an outbox, in the same transaction as the order. - Make exactly one consumer idempotent on the event ID.
- Measure consumer lag, the age of the oldest unprocessed event, and alert on it.
- Run it in production for a few weeks. If the operational cost is acceptable and a second use case is real, add the next flow.
Most systems stop after two or three flows. That isn’t failure. That’s the right size.
When NOT to Use Event-Driven
The strongest argument against event-driven architecture is that most systems that adopted it didn’t need it. Skip it when:
- The caller needs the answer. A request that must return the result of downstream work (a price, a validation) is request/response by nature. Forcing it through events adds latency and machinery to simulate the synchronous call you refused to make.
- There’s one consumer, and there will only ever be one. A queue between two services that always talk to each other buys you some buffering and costs you a broker, dedup logic and an invisible flow. A direct call with a timeout and a retry is simpler and about as resilient.
- Your team hasn’t operated the fundamentals yet. Events multiply every gap in idempotency, observability and delivery semantics. The fundamentals layer of this series is a prerequisite, not a suggestion.
- The volume doesn’t justify a broker. A modular monolith with in-process events and a jobs table in the database you already run gets you most of the decoupling at a fraction of the operational cost.
Before Your First Event
- Name the pattern in Fowler’s terms: notification, state transfer, sourcing or CQRS. If the team can’t agree which one it’s adopting, stop.
- Check for the passive-aggressive command. Would the producer break if consumers ignored this event? Then it’s a command. Model it as one.
- Prove the pipeline survives duplicates and lag: outbox on the producer, dedup on every consumer, and a product answer to “how stale is acceptable?”
- Decide choreography vs. orchestration per flow, not globally. More than three steps or any compensation means orchestrate.
- Instrument before you ship: correlation IDs in every envelope, a consumer-lag dashboard, an alert on the dead letter queue.
- Version from day one: forward-compatible changes only, a
versionfield, and a named owner for every event type.
Next in the series: the transactional outbox, the piece that makes publishing events reliable, built in Go.