Skip to content
chiltepin

Generated from: “Document the order.placed event so other teams can consume it safely.

order.placed event

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

DOCUMENTDRAFT

order.placed event

What the event carries, who emits it, who consumes it, and the rules that keep a consumer safe.

The orders service emits order.placed once per order, when the payment gateway captures the payment and the order reaches PAID (see order-lifecycle). 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.

SECTION 01 · Note

Assumptions

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

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

The payment gateway captured the payment and the order reached PAID. Fulfilment, billing, and notifications start from this event.

Producers (1)
orders
Consumers (4)
fulfilmentbillingnotificationsanalytics
deliveryat-least-onceorderingper-keykeyorder_idretention7d
Payload
FieldTypeDescription
#order_idstringOrder id with prefix ord_; the partition key and the idempotency key
merchant_idstringMerchant that owns the order; prefix mer_
?customer_referencestringFree text the merchant gave at creation, max 64 chars
placed_atstringRFC 3339 UTC time when the order reached PAID
currencystringISO 4217 code for every amount in the event
subtotal_minorintegerSum of line totals in minor units, before shipping and discount
shipping_minorintegerShipping cost in minor units
discount_minorintegerDiscount applied in minor units, 0 when none
total_minorintegerAmount captured by the gateway in minor units
line_itemsarrayOne item per SKU with sku, name, quantity, unit_price_minor
shipping_addressobjectRecipient name, line1, line2, city, region, postal_code, country
payment_methodstringOne of card, wallet, bank_transfer
# partition key · ? optional
Headers
FieldTypeDescription
event_idstringUUID v4, unique per publish; repeats on redelivery
event_versionstringSchema version, v1 today
trace_idstringW3C trace id from the checkout request
produced_atstringRFC 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
ErrorWhen
DuplicateEventthe same order_id was already handled; skip and ack
UnknownVersionevent_version is newer than the consumer supports; park in the DLQ and page the owner
InvalidTotalsubtotal_minor + shipping_minor - discount_minor differs from total_minor; park in the DLQ

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.

SECTION 03 · Architecture
EVENT
Block diagram: 7 nodes, 6 connectionsConsumer groupsOrders serviceoutbox relayPRODUCERorder-eventsKafkaTOPICFulfilmentCONSUMERBillingCONSUMERNotificationsCONSUMERAnalyticsCONSUMERorder-events-dlqKafkaQUEUE123456
1publish order.placed, key = order_id2reserve stock3issue invoice4send confirmation5record sale6park after 5 failed attempts
LegendPRODUCERproducerTOPICtopicCONSUMERconsumerQUEUEqueuecallsasync / optionalerrorentry point

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.

SECTION 04 · Sequence
SEQUENCE
Sequence diagram: 10 messages between 5 actorsOrders serviceOrders DBOutbox relayorder-eventsFulfilmentLOOP[every 200 ms]ALT[broker ack][broker down]ALT[order_id already handled][stock reserved][5 attempts failed]1UPDATE orders SET status = PAID; INSERT outbox (order.placed)2commit3SELECT pending outbox rows4publish order.placed (key = order_id)5mark outbox row published6leave row pending, retry next tick7deliver order.placed8ack (skip duplicate)9ack after commit10park in order-events-dlq, then ack
Legendcallresponseerrorthe answer the caller getsfragment (alt / opt / loop)active
Outbox lag: p99 under 1 sRetry backoff: 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.

SECTION 05 · Spec

What every consumer must hold

Idempotency
Store order_id with the side effect and skip an order_id already stored. event_id changes on every publish; order_id never does.
Ordering
Order holds only within one order_id. Two different orders can arrive in any order, on any partition.
Acknowledge
Read eventCommit side effectAck message
Unknown fields
Ignore any payload field the consumer does not know. A minor version adds optional fields and never removes or renames one.
Major version
A new major version ships on a new topic, order-events-v2, with both topics live for 90 days. The old topic then stops.
Replay
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.
Poison messages
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.
Money
Treat total_minor as the captured amount. Never recompute it from line items; a rounding difference is an InvalidTotal error, not a correction.
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." }
```