Skip to content
Knowledge Hub

Common mistakes when writing ADRs, runbooks and READMEs Intermediate

Estimated reading time: 9 min read

This page collects the mistakes most often found when reviewing ADRs, runbooks and READMEs. Each one shows the common version, the version to write instead, and the concrete cost of leaving it as is.

Group Mistake
ADR Overwriting an old ADR, Only good consequences, Noun title, no options, ADRs in a wiki, ADRs for everything, Duplicate numbers
Operations Vague runbooks, Alerts without runbooks
README README mixing four types

Editing ADR-0012 to match reality after moving to CDC

Common PR

# 0012. Publish order events through a transactional outbox
A separate relay claims pending rows with FOR UPDATE SKIP LOCKED...
We are not using CDC yet. Revisit when throughput exceeds what polling can handle.
Debezium reads the outbox table's WAL and publishes to Kafka.

PR to make instead

# 0012. Publish order events through a transactional outbox
-- Status: accepted
+- Status: Superseded by ADR-0031
# 0031. Move the outbox relay to Debezium CDC (new file)
+- Supersedes: ADR-0012
## Context
Outbox throughput exceeds what polling can handle; the platform team now offers Kafka Connect
(exactly the "revisit when..." condition from ADR-0012).

Why

  • Overwriting erases why polling was chosen. When CDC hits a replication slot problem, nobody knows how the old option was weighed.
  • Nygard: old decisions are kept and marked superseded. Only the status and links may be edited.

Consequences that read like a sales pitch

Bad ADR

## Consequences
- Never lose events
- The system scales better
- Modern, standard microservices architecture

Good ADR

## Consequences
Good:
- No more paid orders without an event when the process dies midway.
Bad:
- Still at-least-once: the relay can publish duplicates. New consumers MUST be idempotent by eventId.
- Events are delayed by up to one more poll cycle.
- The outbox table only grows: needs a cleanup job and an alert for old pending rows.

Why

  • Unrecorded bad consequences still happen. A new team writes a non-idempotent consumer, the relay publishes a duplicate, and Shipping creates two shipments for one order.
  • The next person meets it as a surprise, concludes the decision was wrong, and reverses it blindly. Nygard requires recording all consequences, not just the positive ones.

An ADR that can't answer 'why not X'

Bad ADR

# ADR 15: Isolation level
## Decision
Use READ COMMITTED.

Good ADR

# 0015. Decrement stock with READ COMMITTED + SELECT ... FOR UPDATE instead of SERIALIZABLE
## Context
Two concurrent checkouts can both read stock = 1 and both decrement it (lost update).
Options considered:
1. SERIALIZABLE for the whole transaction: correct, but requires retrying on serialization failure,
and every checkout code path needs a retry loop.
2. UPDATE ... SET stock = stock - 1 WHERE stock >= 1: cheapest, but doesn't work
when several rows must be read before deciding.
3. READ COMMITTED + SELECT ... FOR UPDATE on the rows to be decremented.
## Decision
We will lock inventory rows with SELECT ... FOR UPDATE in ascending product_id order.

Why

  • A noun title doesn’t say what was chosen; the ADR index becomes a meaningless list.
  • Without rejected options, six months later someone proposes “switch to SERIALIZABLE to be safe”, and the debate starts from scratch.
  • MADR makes Considered Options mandatory precisely for this reason.

Code points to a wiki nobody sees during review

Common

// See the decision at https://wiki.example.com/pages/viewpage.action?pageId=884213
await this.outbox.insert(tx, event);

Write instead

docs/adr/0012-publish-events-via-transactional-outbox.md
// ADR-0012: events go through the outbox, do NOT call producer.send() directly here.
await this.outbox.insert(tx, event);

Why

  • A wiki separate from the repo: PRs that change code don’t touch the ADR, and reviewers don’t see it during review. Links break when the company changes wiki tools.
  • ADRs in docs/adr/ share the code’s version, are greppable, and are reviewed in the same PR.

The decision log turns into a junkyard

Bad decision log

docs/adr/
0041-rename-variable-orderSvc-to-orderService.md
0042-bump-lodash-4-17-20-to-4-17-21.md
0043-add-index-orders-created-at.md
0044-use-prettier-print-width-100.md
0045-keep-modular-monolith-dont-split-shipping-yet.md

Good decision log

docs/adr/
0045-keep-modular-monolith-dont-split-shipping-yet.md
# The rest: commit messages, migrations, linter config
git log --oneline
a1c3e9f refactor: rename orderSvc to orderService
7b20d4e chore(deps): bump lodash 4.17.21 (security patch)
e91f0aa perf: add index orders(created_at) for the daily report

Why

  • 300 files about variable names bury the important ADRs; nobody reads the index anymore.
  • Filter with two questions: is it hard to reverse? will others ask about it again? If not, a commit message is enough.

Runbook: “check the logs and handle appropriately”

Section titled “Runbook: “check the logs and handle appropriately””

A runbook that's useless at 3 a.m.

Bad runbook

# Outbox alert
Check the relay logs and handle appropriately.
If needed, delete the failing rows in the outbox table.

Good runbook

# Runbook: OutboxBacklogHigh
Owner: Checkout team. Last updated: 2026-09-12
## Impact
Shipping receives OrderPaid late. Payments are NOT affected.
## Diagnosis
1. kubectl get pods -l app=outbox-relay
2. SELECT id, event_type, created_at FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 20;
## Mitigation
- Poison row: do NOT delete. Move it to outbox_parked, open a ticket.

Why

  • No commands, no thresholds: the on-call engineer (who usually didn’t write this code) has to guess, and MTTR drags on.
  • “Delete the failing rows” means permanently losing events: the order is paid but Shipping never receives it. A good runbook spells out what not to do.

An alert that fires but doesn't point the way

Bad alert rule

- alert: OutboxBacklogHigh
expr: outbox_oldest_pending_age_seconds > 60
labels:
severity: page

Good alert rule

- alert: OutboxBacklogHigh
expr: outbox_oldest_pending_age_seconds > 60
for: 5m
labels:
severity: page
annotations:
summary: Outbox has a pending row older than 60s for 5 minutes
runbook_url: https://git.example.com/order-service/-/blob/main/docs/runbooks/outbox-backlog.md

Why

  • Without for, the alert fires on the first evaluation over the threshold, even for a brief spike during a deploy: on-call gets woken up for nothing.
  • Without a runbook link, whoever gets paged has to hunt for docs in the middle of the night. Prometheus describes annotations as exactly the place for alert descriptions and runbook links.

README mixing tutorial, reference and explanation

Section titled “README mixing tutorial, reference and explanation”

A README that's a warehouse instead of an entry point

Bad README

# order-service
## History
In 2023 the team chose Kafka over RabbitMQ because... (3 pages)
## Installation
If you use a Mac M1 then..., if you use Windows then..., if you use Postgres 14 then...
## Env vars
DB_URL, KAFKA_BROKERS, OUTBOX_POLL_MS (=500) <- hand-written, code has changed the default to 200

Good README

# order-service
Manages the order lifecycle (Ordering bounded context). Does NOT handle shipping.
## Quickstart
docker compose up -d && npm ci && npm run migrate && npm run dev
## Docs
- Architecture (C4): docs/architecture.md
- Decisions: docs/adr/ (why Kafka: ADR-0004)
- Runbooks: docs/runbooks/
- Env vars: generated from the config schema, see docs/reference/config.md
Owner: Checkout team, #team-checkout

Why

  • Per Diátaxis, “why Kafka” is explanation (belongs in an ADR), and the env var table is reference (should be generated from code).
  • A hand-written env var table with a stale default: a new dev configures per the README, it runs, but behaves differently from production.
  • A quickstart that branches everywhere means newcomers can’t get the service running on day one.

Two parallel branches both create 0013

Common

docs/adr/
0013-use-redis-for-idempotency-keys.md # merged from branch A
0013-split-reporting-schema.md # merged from branch B

Do instead

Terminal window
# CI: fail if two ADR files share a number prefix
dupes=$(ls docs/adr | grep -oE '^[0-9]{4}' | sort | uniq -d)
if [ -n "$dupes" ]; then
echo "Duplicate ADR numbers: $dupes"
exit 1
fi

Why

  • “See ADR-0013” now points to two different decisions; links in code and commit messages become ambiguous.
  • Whoever merges second renumbers before merging; CI blocks it if they forget. No need to switch to date-based numbering.

Next: go back to the ADR and technical docs overview, or see the Code review cluster.