Skip to content
Knowledge Hub

Writing good ADRs: a full example, lifecycle and superseding Intermediate

Estimated reading time: 6 min read

This page answers: how a good ADR differs from a bad one, what a complete one looks like, and what to do with the old ADR when you change your mind.

An outbox ADR seen very often in review

Bad ADR

# ADR 7: Outbox
Status: accepted
## Decision
Use the outbox pattern for events.
## Consequences
- Guarantees no lost events
- Standard microservices architecture

Good ADR (shortened)

# 0012. Publish order events through a transactional outbox instead of publishing directly after commit
## Context
Committing to the DB and then calling producer.send() is a dual write. In February, 2 deploys killed
the process between the two steps: orders were paid but Shipping never got the event.
Options considered: in-process retry, Kafka transactions, outbox + polling, outbox + CDC.
## Decision
We will write the order and the outbox row in the same transaction; a separate relay publishes.
## Consequences
Bad: events are delayed by one more poll cycle; still at-least-once, consumers MUST be idempotent.

Why

  • The bad version has no context, no rejected options, and a title that doesn’t state the decision.
  • Its consequences are all positive; “standard architecture” is a cargo-cult reason.
  • Two years later, it helps nobody decide whether to keep or drop the outbox.

The key differences in the good version:

Part Rule Source
Title A decision sentence, not a technology name MADR: “representative of solved problem and found solution”
Context Concrete facts and constraints, written neutrally Nygard: “value-neutral”
Decision Active voice: “We will …” Nygard: active voice, “We will …”
Consequences Good, bad and neutral Nygard: all consequences affect the future

A hypothetical scenario for a NestJS + PostgreSQL + Kafka order service. The numbers in Context belong to the scenario, not to a measurement.

# 0012. Publish order events through a transactional outbox instead of publishing directly after commit
- Status: accepted
- Date: 2026-03-04
- Decision makers: Checkout team (Lan, Minh), consulted: Shipping team, SRE
- Supersedes: none. Related: ADR-0004 (Kafka is the event bus)
## Context
OrderService currently commits `orders.status = 'paid'` and then calls `producer.send('OrderPaid')`
in the same request handler. This is a dual write: the DB and Kafka have independent transactions.
In February, 2 deploys killed the process between commit and send. As a result,
some orders were paid but Shipping never received the event; CS found out 1–2 days later
when customers asked. Reversing the order (send first, commit after) creates the opposite bug:
Shipping processes an order that the DB rolled back.
Constraints: OrderPaid events must not be lost; a few seconds of delay to Shipping is
acceptable; the team does not operate Kafka Connect yet; Shipping's consumer already dedupes by eventId.
Options considered:
1. Retry `send` in-process: cannot close the gap when the process itself dies.
2. Kafka transactions: do not include the PostgreSQL transaction, still a dual write.
3. Outbox + polling relay: uses existing infrastructure (PostgreSQL), at-least-once.
4. Outbox + CDC (Debezium): lower latency but needs Kafka Connect and a replication slot.
## Decision
We will write the business change and a row in the `outbox` table in the same
PostgreSQL transaction. A separate relay (a worker process, not running in the API pod)
claims pending rows in batches with `FOR UPDATE SKIP LOCKED`, publishes to Kafka keyed
by `orderId`, and marks `published_at` only after Kafka acks.
We are not using CDC yet. Revisit when outbox throughput exceeds what polling can handle
or when the platform team offers a shared Kafka Connect.
## Status
Accepted.
## Consequences
Good:
- No more "order is paid but nobody remembers to publish the event" state.
- A temporary Kafka outage does not fail the payment request; the outbox builds a backlog.
Bad:
- Events are delayed by up to one poll cycle plus publish time.
- Still at-least-once: the relay can publish duplicates. Every new consumer MUST be
idempotent by eventId.
- An append-only table: needs a job to clean up sent rows and an alert for old pending rows.
- One more process to deploy, monitor, and give a runbook (docs/runbooks/outbox-backlog.md).
- Multiple relays in parallel can publish two events of the same aggregate out of order;
consumers check the version, or run one relay per shard.
Neutral:
- The outbox payload becomes a public contract; schema changes must follow the same
backward-compatibility process as an API.

Reread the Consequences section: it is what the next person needs most. It states the cost up front, so when someone complains “why are events a few seconds late”, the answer is already there, along with the conditions for revisiting.

Lifecycle: immutable, supersede instead of editing

Section titled “Lifecycle: immutable, supersede instead of editing”

Loading diagram…

Operating rules:

  1. An accepted ADR is immutable. Only fix typos and the status/link fields. Nygard: old decisions are kept and marked superseded, because knowing what was once decided still has value.
  2. Changing your mind = a new ADR. ADR-0031 “Move the outbox relay to Debezium CDC” says Supersedes: ADR-0012; ADR-0012’s status becomes Superseded by ADR-0031. Both files exist side by side.
  3. Numbers increase monotonically and are never reused. Nygard: “numbered sequentially and monotonically. Numbers will not be reused.” A rejected ADR keeps its number.
  4. Proposed lives in the PR. Review ADRs like code; merge = accepted. No special tooling needed.

Loading diagram…

Nygard uses doc/arch/, MADR uses docs/decisions/. Pick one and stay consistent:

docs/
adr/
README.md # index: number, title, status
0001-record-decisions-with-adrs.md
0004-use-kafka-as-event-bus.md
0012-publish-events-via-transactional-outbox.md # Superseded by 0031
0031-move-outbox-relay-to-cdc.md
runbooks/
outbox-backlog.md
architecture.md # C4 diagrams, levels 1–2
README.md

The classic ADR-0001 is “Record architecture decisions with ADRs”: it records this very convention.

Next: Design docs, Diátaxis, runbooks and C4.