This website uses cookies

Read our Privacy policy and Terms of use for more information.

Say "we're going event-driven" in a design review and everyone nods. Then you ship, and the nods turn out to have meant four different things. One engineer heard "fire-and-forget notifications." Another heard "stop calling my service, I'll push you the data." A third was quietly planning to rebuild the database as an append-only log. They are all correct, because event-driven architecture is not one pattern. It is a family of patterns that share a message bus and almost nothing else.

Martin Fowler made this precise in 2017, and the taxonomy has aged well: there are four distinct things people call event-driven, and they have different failure modes, different coupling profiles, and different reasons to exist. Two of them, Event Sourcing and CQRS, are big enough to get their own letters in this series. This one is about the two that most systems actually reach for first, plus the operational reality that bites regardless of which you choose: delivery is not exactly-once, and someone has to own that.

An event is a fact, not a command

Start with the thing that trips people up on day one. An event is a statement about something that already happened. OrderPlaced, PaymentCaptured, InventoryReserved. It is past tense, it is a fact, and crucially, the publisher does not care who listens or what they do about it.

A command is the opposite. PlaceOrder, CapturePayment, ReserveInventory. It is imperative, it is addressed to a specific handler, and the sender expects it to be carried out. Commands can be delivered over a queue too, which is why the two get confused, but the intent is inverted. With a command, the sender owns the outcome. With an event, the publisher has washed its hands.

That inversion is the entire point of event notification. When the order service publishes OrderPlaced instead of calling the shipping service, the dependency arrow flips. Shipping now depends on order, not the other way around. Order does not know shipping exists. You can add a fraud check, a loyalty-points service, and an analytics sink, all subscribing to the same event, and the order service never changes.

// Command: the sender owns the outcome, knows the receiver
shippingService.createShipment(orderId, address);

// Event: the publisher states a fact, owns nothing downstream
eventBus.publish(new OrderPlaced(orderId, customerId, lineItems, address));

Pattern one: event notification

Event notification is the lightweight version. The event is a thin announcement, usually just an ID and enough context to route: "order 8842 was placed." Subscribers who need more go back and ask the source. The shipping service receives OrderPlaced(orderId=8842), then calls the order service to fetch the full address and line items.

This is the pattern most teams land on without a design meeting, and it buys real decoupling. The tradeoff is the one Fowler names directly: you lose the ability to reason about the flow by reading code. There is no method that says "when an order is placed, do these five things." The behavior is smeared across five services and only assembled at runtime. When something goes wrong, "why didn't this shipment get created" is not a stack trace. It is a distributed-tracing exercise across five services and a message broker.

Netflix runs enormous swaths of its platform this way, and the reason its engineering blog talks so much about distributed tracing and observability is not incidental. Once you commit to event notification at scale, tracing stops being a nice-to-have and becomes the only way to answer basic questions about what your system did. Budget for it up front. A notification-based system without correlation IDs threaded through every event is a system you cannot debug.

The other trap is the callback storm. Thin events mean subscribers call back to the source for details. Publish OrderPlaced to eight subscribers and you may have just generated eight synchronous reads against the order service in the same second. You decoupled the write path and quietly recoupled the read path. Which leads to the second pattern.

Pattern two: event-carried state transfer

Here the event stops being a pointer and becomes the payload. OrderPlaced carries the customer ID, the full shipping address, every line item, the totals. Everything a downstream consumer could plausibly need travels in the message. Subscribers never call back, because there is nothing to call back for.

The win is availability and latency. The shipping service can build a shipment even if the order service is down for maintenance, because it already has the data. No synchronous dependency, no callback storm, no coupling on the read path either. This is the pattern behind a lot of what people mean when they say "each service owns a local copy of the data it needs."

The cost is that you are now maintaining replicas. Every consumer holds a copy of data whose source of truth lives elsewhere, and copies drift. If a customer changes their shipping address after OrderPlaced fired, the shipping service is working from a stale snapshot unless you also publish CustomerAddressChanged and every replica processes it correctly. You have traded a consistency problem you understood (call the source, get the truth) for an eventual-consistency problem you have to design around. There is a window where the copies disagree, and your product has to be okay with that window.

The schema also gets heavier and more coupling-prone in a subtle way. Fat events mean the producer is broadcasting its internal state shape to every consumer. Add a field, remove a field, rename one, and you can break subscribers you have never heard of. Notification events are thin enough to evolve freely. State-transfer events are contracts, and contracts need versioning, a schema registry, and a compatibility policy from the start.

// Event-carried state transfer: consumer needs no callback
{
  "type": "OrderPlaced",
  "orderId": "8842",
  "customer": { "id": "c-771", "name": "..." },
  "shipTo": { "line1": "...", "city": "...", "postalCode": "..." },
  "lines": [ { "sku": "A-12", "qty": 2, "unitPrice": 1999 } ],
  "totals": { "subtotal": 3998, "currency": "EUR" },
  "occurredAt": "2026-07-09T06:30:00Z",
  "schemaVersion": 3
}

Notice schemaVersion. If you are shipping state in events, you will need it. The teams that skip it in month one spend month six unable to change an event without a coordinated multi-service deploy, which is precisely the coupling they adopted events to escape.

The coupling did not disappear, it moved

This is the non-obvious thing worth internalizing. Event-driven architecture is sold as decoupling, and it does decouple systems in time and in deployment. But coupling is conserved. It moves from the call graph into the event schema.

In a synchronous system, the coupling is loud. Service A calls service B's endpoint; break the endpoint and A's build or A's requests fail immediately. In an event-driven system, the coupling is quiet. Service A publishes an event that service B parses; change the event's shape and nothing fails at deploy time. It fails in production, later, when B receives a message it cannot understand, and you find out from a dead-letter queue instead of a compiler. Semantic coupling through the payload is still coupling. It is just coupling that has learned to hide.

This is why a schema registry and explicit compatibility rules are not enterprise ceremony for event systems. They are the thing standing between you and a distributed monolith where every "independent" service must deploy in lockstep because they all secretly agree on an unversioned event shape.

Choreography versus orchestration

Once events drive a multi-step business process, a real fork appears. In choreography, each service reacts to events and emits its own, and the overall workflow is an emergent property of who listens to what. Order publishes OrderPlaced, payment reacts and publishes PaymentCaptured, inventory reacts to that and publishes InventoryReserved, shipping reacts to that. No one is in charge. The flow exists only in the wiring.

Choreography maximizes decoupling and is wonderful until you need to answer "where is order 8842 in the process right now," which nobody can, because no single component holds the state of the workflow. In orchestration, a coordinator does hold that state. It sends commands and waits for replies, and the flow lives in one readable place at the cost of reintroducing a central dependency. Sam Newman's guidance in Building Microservices is worth memorizing: choreography for loosely related reactions, orchestration when the steps form a genuine transaction that someone has to be accountable for. The Saga pattern, coming later in this series, is the disciplined way to run that orchestrated multi-service transaction with compensation when a step fails.

Delivery is at-least-once, and that is your problem

Whatever pattern you pick, the broker underneath is almost certainly giving you at-least-once delivery, and you need to build for it on the first day, not after the first incident.

Apache Kafka, per Confluent's own documentation, delivers at least once by default. A consumer can process a message, then crash before committing its offset, and on restart it reads that message again. Producer retries can write the same record twice. Exactly-once semantics exist in Kafka, built from idempotent producers and transactions, but they are constrained, they cost throughput, and they do not extend to the side effects your consumer performs against an external database or a third-party API. The moment your handler charges a card or sends an email, exactly-once is your responsibility, not the broker's.

The durable fix is idempotent consumers. Give every event a stable identifier, record which IDs you have processed, and make reprocessing a no-op.

def handle(event):
    # Dedup on a unique event id, enforced by the database
    try:
        db.execute(
            "INSERT INTO processed_events (event_id) VALUES (%s)",
            [event.id],
        )
    except UniqueViolation:
        return  # already handled, at-least-once redelivery, safe to drop

    reserve_inventory(event.order_id, event.lines)
    db.commit()

The unique constraint does the work. A redelivered event hits the constraint and exits before touching inventory. This one pattern, an idempotency key backed by a database constraint, prevents the double-charges and double-shipments that are the signature failure of every naive event system. Pair it with a dead-letter queue for messages that fail repeatedly, so a single poison message does not wedge the whole partition, and you have the operational floor that event-driven architecture actually stands on.

When to reach for it, and when not to

Events earn their complexity when you have genuinely independent consumers, when producers and consumers scale or fail independently, and when you can tolerate eventual consistency. They are a bad trade when you need a synchronous answer right now, when the "workflow" is really one transaction that wants to be atomic, or when you have two services and one caller, where a plain HTTP call is honest and a broker is theater.

Reach for notification when you want decoupling and can afford tracing. Reach for state transfer when you want availability and can afford replicas and a schema registry. Choose choreography for loose reactions and orchestration for accountable transactions. And underneath all of it, assume every message arrives at least once and design your handlers so a duplicate is boring. The teams that get burned by event-driven architecture are rarely burned by the concept. They are burned by shipping one of the four patterns while a colleague shipped another, and by trusting a broker to deliver exactly once when it never promised to.

Sources

Keep Reading