Generated from: “When an order is placed, five other services have to react to it without the order service knowing about any of them. Show how that works.”
View the Markdown
```meta
title: Order events fan-out
subtitle: How five services react to a placed order while the Orders service knows none of them.
tag: DESIGN
```
The Orders service publishes one event, `order.placed`, to a topic and stops there. Billing, Inventory, Fulfilment, Notifications, and Analytics each subscribe to that topic with their own consumer group. Adding a sixth subscriber is a deploy in that team's repo, not a change to Orders.
```callout
tone: note
title: Assumptions
body: "The broker is Kafka and the topic is order-events. The Orders service writes to Postgres and publishes through an outbox relay. Each subscriber runs its own consumer group. Billing, Inventory, Fulfilment, Notifications, and Analytics subscribe. Retention is 7 days and the partition key is order_id."
```
## Who publishes, who subscribes
The topic is the only thing the two sides share. Orders holds no list of subscribers, no URLs, and no retry logic for them. Each subscriber can be down for up to the retention window and still read every event it missed.
```block
preset: event
groups:
- { id: subs, col: 3, row: 1, cols: 1, rows: 5, label: Subscribers }
nodes:
- { id: orders, col: 1, row: 3, kind: producer, name: Orders, tech: order.placed }
- { id: topic, col: 2, row: 3, kind: topic, name: order-events, tech: Kafka }
- { id: billing, col: 3, row: 1, kind: consumer, name: Billing }
- { id: inventory, col: 3, row: 2, kind: consumer, name: Inventory }
- { id: fulfilment, col: 3, row: 3, kind: consumer, name: Fulfilment }
- { id: notifications, col: 3, row: 4, kind: consumer, name: Notifications }
- { id: analytics, col: 3, row: 5, kind: consumer, name: Analytics }
edges:
- orders -> topic: publish
- topic --> billing: charge the card
- topic --> inventory: reserve stock
- topic --> fulfilment: create shipment
- topic --> notifications: send confirmation
- topic --> analytics: record the sale
```
## The event
The payload is the whole contract between Orders and its subscribers. A subscriber that needs a field that is not here asks for a new event version; it never calls Orders back. Fields are added, never removed or renamed, inside a major version.
```eventcontract
id: order-placed
name: order.placed
version: v1
channel: order-events
summary: "A customer completed checkout and Orders accepted the order."
producers: [orders]
consumers: [billing, inventory, fulfilment, notifications, analytics]
delivery: at-least-once
ordering: per-key
key: order_id
retention: 7d
schema:
- order_id uuid required — The order this event is about; also the partition key
- customer_id uuid required — The buyer
- placed_at timestamp required — When Orders accepted the order, UTC
- currency string required — ISO 4217 code for every money field
- total money required — Grand total after discounts and tax
- { name: lines, type: array, required: true, desc: "One entry per line with sku, quantity, and unit_price" }
- shipping_address object required — Street, city, postal code, country
- payment_method_id string required — Token Billing charges; never the card number
headers:
- event_id uuid required — Unique per event; the idempotency key for consumers
- trace_id string required — W3C trace id from the checkout request
- schema_version string required — "v1"
example: |
{ "order_id": "ord_8f3a", "customer_id": "cus_291", "placed_at": "2026-09-13T09:41:07Z", "currency": "EUR", "total": "84.90", "lines": [{ "sku": "MUG-01", "quantity": 2, "unit_price": "12.45" }], "shipping_address": { "country": "NL" }, "payment_method_id": "pm_tok_55" }
errors:
- DuplicateEvent — the same event_id was already processed; the consumer skips it
- UnknownSchemaVersion — schema_version is newer than the consumer supports; the consumer parks the message
note: "Consumers must be idempotent on event_id. Orders can republish an event after a relay crash."
```
## One hop, and what happens when it fails
The order row and the outbox row commit in one transaction, so an accepted order always produces an event. The relay publishes the outbox row and then marks it sent. A crash between the two steps sends the event twice, so consumers dedupe on `event_id`. Billing stands in for any subscriber: the other four follow the same path from the topic.
```sequence
id: place-order-hop
actors:
- { id: Client, name: Checkout }
- { id: Orders, name: Orders API }
- { id: DB, name: Postgres }
- { id: Relay, name: Outbox relay }
- { id: Topic, name: order-events }
- { id: Billing, name: Billing }
messages:
- Client -> +Orders: POST /orders
- Orders -> DB: "BEGIN; insert order; insert outbox row; COMMIT"
- Orders --> -Client: 201 Created
- Relay -> DB: poll outbox rows where sent_at is null
- Relay -> Topic: publish order.placed
- Relay -> DB: set sent_at
- Topic --> +Billing: order.placed
- alt: charge succeeds
- Billing -> Topic: commit offset
- else: charge fails after 5 retries
- Billing -x-> Topic: publish to order-events.dlq
- Billing --> -Topic: commit offset
- end
```
## Rules every subscriber follows
The topology stays loose only if each subscriber holds up its side. These rules are the price of Orders not knowing the subscribers.
```spec
title: Subscriber contract
accent: teal
rows:
- { label: Idempotency, value: "Store event_id before the side effect. Skip the message if event_id exists. The store keeps ids for 14 days, twice the topic retention." }
- { label: Ordering, value: "Events for one order_id land on one partition and arrive in order. Events for different orders carry no ordering guarantee." }
- { label: Retry, steps: [Retry in process with backoff up to 5 times, Publish to order-events.dlq with the failure reason, Commit the offset so the partition moves on] }
- { label: Lag alert, value: "Page the owning team when consumer lag on order-events passes 10 minutes." }
- { label: Schema change, value: "Additive fields ship under the same version. A removed or renamed field ships as v2 on a new topic, order-events-v2, and both run for 30 days." }
- { label: Ownership, value: "Orders owns the event schema. Each subscriber owns its consumer group, its DLQ, and its lag alert." }
```