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.
Before / after: a bad ADR and its fix
Section titled “Before / after: a bad ADR and its fix”An outbox ADR seen very often in review
Bad ADR
# ADR 7: Outbox
Status: accepted
## DecisionUse the outbox pattern for events.
## Consequences- Guarantees no lost events- Standard microservices architectureGood ADR (shortened)
# 0012. Publish order events through a transactional outbox instead of publishing directly after commit
## ContextCommitting to the DB and then calling producer.send() is a dual write. In February, 2 deploys killedthe 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.
## DecisionWe will write the order and the outbox row in the same transaction; a separate relay publishes.
## ConsequencesBad: 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 complete ADR example
Section titled “A complete ADR example”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 laterwhen 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 isacceptable; 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 samePostgreSQL 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 keyedby `orderId`, and marks `published_at` only after Kafka acks.
We are not using CDC yet. Revisit when outbox throughput exceeds what polling can handleor 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:
- 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.
- Changing your mind = a new ADR. ADR-0031 “Move the outbox relay to Debezium CDC” says
Supersedes: ADR-0012; ADR-0012’s status becomesSuperseded by ADR-0031. Both files exist side by side. - Numbers increase monotonically and are never reused. Nygard: “numbered sequentially and monotonically. Numbers will not be reused.” A rejected ADR keeps its number.
- Proposed lives in the PR. Review ADRs like code; merge = accepted. No special tooling needed.
Loading diagram…
Folders and numbering
Section titled “Folders and numbering”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–2README.mdThe classic ADR-0001 is “Record architecture decisions with ADRs”: it records this very convention.