Generated from: “Document the order.placed event so other teams can consume it safely.”
View the Markdown
```meta
title: order.placed event
subtitle: What the event carries, who emits it, who consumes it, and the rules that keep a consumer safe.
tag: DRAFT
```
The orders service emits `order.placed` once per order, when the payment
gateway captures the payment and the order reaches `PAID` (see
[order-lifecycle](order-lifecycle.md)). Downstream teams act on this event
instead of polling the REST API. The broker delivers at least once, so every
consumer must survive a duplicate.
```callout
tone: note
title: Assumptions
body: "The broker is Kafka and the topic is order-events. The event fires on PAID, not on PAYMENT_PENDING, so a consumer never sees an order the customer did not pay for. Amounts are integer minor units with an ISO 4217 currency, the same as the REST API. The producer writes through a transactional outbox, so a committed order always produces exactly one event on the topic, possibly delivered more than once. Schema changes follow the additive-only rule in the consumer rules below."
```
## The contract
The partition key is `order_id`, so every event for one order lands on one
partition and one consumer sees them in order. A consumer that needs the
line items reads them from the payload. It does not call `GET /orders`,
because the order can change status before the call returns.
```eventcontract
id: contract
name: order.placed
version: v1
channel: order-events
summary: "The payment gateway captured the payment and the order reached PAID. Fulfilment, billing, and notifications start from this event."
producers: [orders]
consumers: [fulfilment, billing, notifications, analytics]
delivery: at-least-once
ordering: per-key
key: order_id
retention: 7d
schema:
- order_id string required — Order id with prefix ord_; the partition key and the idempotency key
- merchant_id string required — Merchant that owns the order; prefix mer_
- customer_reference string — Free text the merchant gave at creation, max 64 chars
- placed_at string required — RFC 3339 UTC time when the order reached PAID
- currency string required — ISO 4217 code for every amount in the event
- subtotal_minor integer required — Sum of line totals in minor units, before shipping and discount
- shipping_minor integer required — Shipping cost in minor units
- discount_minor integer required — Discount applied in minor units, 0 when none
- total_minor integer required — Amount captured by the gateway in minor units
- line_items array required — One item per SKU with sku, name, quantity, unit_price_minor
- shipping_address object required — Recipient name, line1, line2, city, region, postal_code, country
- payment_method string required — One of card, wallet, bank_transfer
headers:
- event_id string required — UUID v4, unique per publish; repeats on redelivery
- event_version string required — Schema version, v1 today
- trace_id string required — W3C trace id from the checkout request
- produced_at string required — RFC 3339 UTC time the outbox relay published
example: |
{
"order_id": "ord_8f3k2m",
"merchant_id": "mer_41",
"customer_reference": "web-checkout-77120",
"placed_at": "2026-09-13T09:41:07Z",
"currency": "EUR",
"subtotal_minor": 4200,
"shipping_minor": 500,
"discount_minor": 200,
"total_minor": 4500,
"line_items": [
{ "sku": "MUG-BLUE-350", "name": "Blue mug 350ml", "quantity": 2, "unit_price_minor": 2100 }
],
"shipping_address": {
"name": "A. Lopez", "line1": "Calle Mayor 12", "line2": null,
"city": "Madrid", "region": "MD", "postal_code": "28013", "country": "ES"
},
"payment_method": "card"
}
errors:
- DuplicateEvent — the same order_id was already handled; skip and ack
- UnknownVersion — event_version is newer than the consumer supports; park in the DLQ and page the owner
- InvalidTotal — subtotal_minor + shipping_minor - discount_minor differs from total_minor; park in the DLQ
note: "Ack only after the side effect commits. A consumer that acks first and fails loses the order."
```
## Who publishes and who subscribes
The orders service does not know its consumers. A new team subscribes with its
own consumer group and never asks orders for a change. Each consumer group
reads the topic at its own pace, so a slow analytics job never delays
fulfilment.
```block
preset: event
groups:
- { id: subs, col: 3, row: 1, cols: 1, rows: 4, label: Consumer groups }
nodes:
- { id: orders, col: 1, row: 2, kind: producer, name: Orders service, tech: outbox relay }
- { id: topic, col: 2, row: 2, kind: topic, name: order-events, tech: Kafka }
- { id: fulfilment, col: 3, row: 1, kind: consumer, name: Fulfilment }
- { id: billing, col: 3, row: 2, kind: consumer, name: Billing }
- { id: notifications, col: 3, row: 3, kind: consumer, name: Notifications }
- { id: analytics, col: 3, row: 4, kind: consumer, name: Analytics }
- { id: dlq, col: 2, row: 4, kind: queue, name: order-events-dlq, tech: Kafka }
edges:
- orders -> topic: "publish order.placed, key = order_id"
- topic --> fulfilment: reserve stock
- topic --> billing: issue invoice
- topic --> notifications: send confirmation
- topic --> analytics: record sale
- fulfilment -x-> dlq: park after 5 failed attempts
```
## One order, from commit to acknowledgement
The order row and the outbox row commit in one transaction. A broker outage
delays the event; it never loses it. On the consumer side, the failure branch
is the part other teams get wrong. Retry with backoff first, then park the
message. Never ack a message the side effect did not commit.
```sequence
id: publish-consume
actors:
- { id: orders, name: Orders service }
- { id: db, name: Orders DB }
- { id: relay, name: Outbox relay }
- { id: topic, name: order-events }
- { id: fulfilment, name: Fulfilment }
messages:
- orders -> +db: "UPDATE orders SET status = PAID; INSERT outbox (order.placed)"
- db --> -orders: commit
- loop: every 200 ms
- relay -> db: SELECT pending outbox rows
- relay -> topic: "publish order.placed (key = order_id)"
- alt: broker ack
- relay -> db: mark outbox row published
- else: broker down
- relay -> db: leave row pending, retry next tick
- end
- end
- topic --> +fulfilment: deliver order.placed
- alt: order_id already handled
- fulfilment --> -topic: ack (skip duplicate)
- else: stock reserved
- fulfilment --> -topic: ack after commit
- else: 5 attempts failed
- fulfilment -x-> topic: park in order-events-dlq, then ack
- end
foot:
- { label: Outbox lag, value: "p99 under 1 s" }
- { label: Retry backoff, value: "1 s, 2 s, 4 s, 8 s, 16 s" }
```
## Consumer rules
These rules are the contract between the orders team and every subscriber.
Breaking one on the producer side is a new major version; breaking one on
the consumer side is a lost or double-handled order.
```spec
title: What every consumer must hold
accent: teal
rows:
- { label: Idempotency, value: "Store order_id with the side effect and skip an order_id already stored. event_id changes on every publish; order_id never does." }
- { label: Ordering, value: "Order holds only within one order_id. Two different orders can arrive in any order, on any partition." }
- { label: Acknowledge, steps: [Read event, Commit side effect, Ack message] }
- { label: Unknown fields, value: "Ignore any payload field the consumer does not know. A minor version adds optional fields and never removes or renames one." }
- { label: Major version, value: "A new major version ships on a new topic, order-events-v2, with both topics live for 90 days. The old topic then stops." }
- { label: Replay, value: "Retention is 7 days. A consumer that is down longer re-reads from the REST API by placed_at; the topic no longer has the events." }
- { label: Poison messages, value: "After 5 failed attempts, park the message in order-events-dlq and ack. The consumer team owns its DLQ and drains it within one business day." }
- { label: Money, value: "Treat total_minor as the captured amount. Never recompute it from line items; a rounding difference is an InvalidTotal error, not a correction." }
```