Skip to content
chiltepin

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.

Order events fan-out

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTDESIGN

Order events fan-out

How five services react to a placed order while the Orders service knows none of them.

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.

SECTION 01 · Note

Assumptions

Note
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.

SECTION 02 · Architecture
EVENT
Block diagram: 7 nodes, 6 connectionsSubscribersOrdersorder.placedPRODUCERorder-eventsKafkaTOPICBillingCONSUMERInventoryCONSUMERFulfilmentCONSUMERNotificationsCONSUMERAnalyticsCONSUMER123456
1publish2charge the card3reserve stock4create shipment5send confirmation6record the sale
LegendPRODUCERproducerTOPICtopicCONSUMERconsumercallsasync / optionalentry point

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.

SECTION 03 · Event contract
EVENT·v1order.placedchannelorder-events

A customer completed checkout and Orders accepted the order.

Producers (1)
orders
Consumers (5)
billinginventoryfulfilmentnotificationsanalytics
deliveryat-least-onceorderingper-keykeyorder_idretention7d
Payload
FieldTypeDescription
#order_iduuidThe order this event is about; also the partition key
customer_iduuidThe buyer
placed_attimestampWhen Orders accepted the order, UTC
currencystringISO 4217 code for every money field
totalmoneyGrand total after discounts and tax
linesarrayOne entry per line with sku, quantity, and unit_price
shipping_addressobjectStreet, city, postal code, country
payment_method_idstringToken Billing charges; never the card number
# partition key
Headers
FieldTypeDescription
event_iduuidUnique per event; the idempotency key for consumers
trace_idstringW3C trace id from the checkout request
schema_versionstring"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
ErrorWhen
DuplicateEventthe same event_id was already processed; the consumer skips it
UnknownSchemaVersionschema_version is newer than the consumer supports; the consumer parks the message

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.

SECTION 04 · Sequence
SEQUENCE
Sequence diagram: 10 messages between 6 actorsCheckoutOrders APIPostgresOutbox relayorder-eventsBillingALT[charge succeeds][charge fails after 5 retries]1POST /orders2BEGIN; insert order; insert outbox row; COMMIT3201 Created4poll outbox rows where sent_at is null5publish order.placed6set sent_at7order.placed8commit offset9publish to order-events.dlq10commit offset
Legendcallresponseerrorthe answer the caller getsfragment (alt / opt / loop)active

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.

SECTION 05 · Spec

Subscriber contract

Idempotency
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.
Ordering
Events for one order_id land on one partition and arrive in order. Events for different orders carry no ordering guarantee.
Retry
Retry in process with backoff up to 5 timesPublish to order-events.dlq with the failure reasonCommit the offset so the partition moves on
Lag alert
Page the owning team when consumer lag on order-events passes 10 minutes.
Schema change
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.
Ownership
Orders owns the event schema. Each subscriber owns its consumer group, its DLQ, and its lag alert.
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." }
```