ADR: architecture decision records
In short: An ADR (architecture decision record) is a short document with the context, options, decision and consequences; an old decision is not rewritten but superseded by a new one.
Problem
Why do we have a queue?
A new colleague opens the payment code. Notifications go through a queue, the email sometimes arrives a minute late, and its consumer has to throw away duplicates. "This is complicated. Why don't we just call the email directly?"
Nobody remembers. Whoever decided has left the company. The discussion was in a chat that was deleted long ago. Two bad choices are left: keep something you do not understand, or remove it and on the next Black Friday step on the very rake it was put there for.
The idea
An ADR: the decision and why
An ADR (architecture decision record) is a short document about one decision. It is written when the decision is made, and it lives in the repository next to the code (docs/adr/0007-notifications-queue.md). It has six parts:
- title and number: what was decided, with a running number;
- status: proposed, accepted, superseded;
- context: what is true now, what hurts and what constrains;
- options: at least two that were really considered;
- decision: which option and why;
- consequences: what gets easier and what gets harder.
Here is the ADR the colleague was looking for:
# ADR-0007: Notifications go through a queue
## StatusAccepted. Supersedes ADR-0004.
## ContextADR-0004: payment calls email directly. On Black Friday the emailprovider answered in 8 seconds and payments timed out.
## Options1. A direct call with a short timeout: payment goes through, the email is lost.2. A queue: payment publishes an event, notifications read it later.
## DecisionA queue. A lost email for a paid order is worse than a late one.
## Consequences- Payment does not wait for the email.- The email may arrive a minute later.- Delivery is "at least once", so the consumer is idempotent.- One more part to monitor: the queue.The second half is the most valuable: it tells the colleague why there are duplicates and what breaks if the queue goes. The queue and the idempotent consumer are from the lesson on events.
The idea
The statuses and changing a decision
An ADR goes through a few statuses:
- proposed: it is written and under discussion, usually in a pull request;
- accepted: from today this is the rule;
- superseded: a newer ADR changed it, and the old one points to it;
- deprecated: it no longer holds, and nothing replaces it.
An accepted ADR is not rewritten. When the decision is no longer right, you write a new ADR that supersedes the old one, and give the old one the status "superseded by ADR-0007". The old one was right in its context: delete it and you lose why it was chosen and what happened next.
See it
A decision, its consequences, a new decision
STATUSES = {"proposed", "accepted", "superseded", "deprecated"}
def check(adrs): everything = by_number(adrs) bad = [] for a in adrs: n = a.name if a.status not in STATUSES: bad.append(f"{n}: unknown status {a.status}") if not a.context: bad.append(f"{n}: no context") if len(a.options) < 2: bad.append(f"{n}: fewer than two options") if not a.decision: bad.append(f"{n}: no decision") if not a.consequences: bad.append(f"{n}: no consequences") if a.status == "superseded": nxt = everything.get(a.superseded_by) if nxt is None: bad.append(f"{n}: superseded, but does not say by what") elif nxt.status != "accepted": bad.append(f"{n} → {nxt.name}: the replacement is not accepted") return badadr4 = Adr( number=4, title="Notifications are sent synchronously", status="proposed", context="First version: email is fast", options=["a direct call", "a queue"], decision="Payment calls email directly", consequences="Simple, but a slow email slows payment", superseded_by=None,)adr4 = Adr( number=4, title="Notifications are sent synchronously", status="accepted", context="First version: email is fast", options=["a direct call", "a queue"], decision="Payment calls email directly", consequences="Simple, but a slow email slows payment", superseded_by=None,)adr4 = Adr( number=4, title="Notifications are sent synchronously", status="accepted", context="First version: email is fast", options=["a direct call", "a queue"], decision="Payment calls email directly", consequences="Simple, but a slow email slows payment", superseded_by=None,)
adr7 = Adr( number=7, title="Notifications go through a queue", status="proposed", context="ADR-0004: email slows payments down", options=["direct with a timeout", "a queue"], decision="Payment puts an event on the queue", consequences="No waiting; an email may come twice", superseded_by=None,)adr4 = Adr( number=4, title="Notifications are sent synchronously", status="superseded", context="First version: email is fast", options=["a direct call", "a queue"], decision="Payment calls email directly", consequences="Simple, but a slow email slows payment", superseded_by=7,)
adr7 = Adr( number=7, title="Notifications go through a queue", status="accepted", context="ADR-0004: email slows payments down", options=["direct with a timeout", "a queue"], decision="Payment puts an event on the queue", consequences="No waiting; an email may come twice", superseded_by=None,)STATUSES = {"proposed", "accepted", "superseded", "deprecated"}
def check(adrs): everything = by_number(adrs) bad = [] for a in adrs: n = a.name if a.status not in STATUSES: bad.append(f"{n}: unknown status {a.status}") if not a.context: bad.append(f"{n}: no context") if len(a.options) < 2: bad.append(f"{n}: fewer than two options") if not a.decision: bad.append(f"{n}: no decision") if not a.consequences: bad.append(f"{n}: no consequences") if a.status == "superseded": nxt = everything.get(a.superseded_by) if nxt is None: bad.append(f"{n}: superseded, but does not say by what") elif nxt.status != "accepted": bad.append(f"{n} → {nxt.name}: the replacement is not accepted") return badStep by step, in words
- ADR-0004 is proposed: the context, two options and the decision. Payment calls email directly.
- The team accepts it. The price is written down too: a slow email slows payment.
- Black Friday: the email provider answers in 8 seconds and payments time out. The written consequence comes true.
- A new decision, ADR-0007: notifications go through a queue. Its context points to ADR-0004.
- The options and the price: payment no longer waits, but an email may come later, and twice.
- ADR-0007 is accepted. The old one is not deleted or rewritten: its status becomes superseded, and it points to the new one.
- The check from the lesson: a superseded ADR must point to an accepted one. ADR-0004 → ADR-0007 passes.
- Whoever asks "why a queue?" reads ADR-0007, and from it ADR-0004. The history stays, with the options and the price.
See it
Checking the ADRs
In a real project ADRs are markdown files, and the check reads their headings. Here each ADR is a record with the same fields, to keep the program short:
@dataclassclass Adr: number: int title: str status: str context: str options: list decision: str consequences: str superseded_by: int | None = None
@property def name(self): return f"ADR-{self.number:04d}"adr4 = Adr( number=4, title="Notifications are sent synchronously", status="superseded", context="First version: email is fast", options=["a direct call", "a queue"], decision="Payment calls email directly", consequences="Simple, but a slow email slows payment", superseded_by=7,)
adr7 = Adr( number=7, title="Notifications go through a queue", status="accepted", context="ADR-0004: email slows payments down", options=["direct with a timeout", "a queue"], decision="Payment puts an event on the queue", consequences="No waiting; an email may come twice", superseded_by=None,)The check guards the two things people forget: the parts of an ADR and the "superseded by" chain.
def by_number(adrs): return {a.number: a for a in adrs}STATUSES = {"proposed", "accepted", "superseded", "deprecated"}
def check(adrs): everything = by_number(adrs) bad = [] for a in adrs: n = a.name if a.status not in STATUSES: bad.append(f"{n}: unknown status {a.status}") if not a.context: bad.append(f"{n}: no context") if len(a.options) < 2: bad.append(f"{n}: fewer than two options") if not a.decision: bad.append(f"{n}: no decision") if not a.consequences: bad.append(f"{n}: no consequences") if a.status == "superseded": nxt = everything.get(a.superseded_by) if nxt is None: bad.append(f"{n}: superseded, but does not say by what") elif nxt.status != "accepted": bad.append(f"{n} → {nxt.name}: the replacement is not accepted") return badADR-0005 is marked as superseded but does not say by what. ADR-0009 has one option and no consequences:
adr5 = Adr(5, "The discount is computed in the controller", "superseded", "One screen, one rule", ["in the controller", "in the domain"], "The rule lives in the controller", "Fast, but the rule gets copied", None)adr9 = Adr(9, "A cache for the product pages", "proposed", "The database is at 100% in campaigns", ["Redis"], "Cache-aside with a 5-minute TTL", "", None)problems = check([adr4, adr5, adr7, adr9])for p in problems: print(p)print(len(problems), "problems")ADR-0005: superseded, but does not say by whatADR-0009: fewer than two optionsADR-0009: no consequences3 problemsA check like this runs with the tests on every pull request, and an ADR without consequences never reaches the main branch. superseded_by: int | None = None says that the field is a number or None.
Try it
Predict the output
The check above gets two ADRs about the reports. What does it print?
adr11 = Adr(11, "Reports read the primary database", "superseded", "There are few reports", ["the primary", "a replica"], "Reports read the primary", "Simple, but it loads the database", 12)adr12 = Adr(12, "Reports read a replica", "proposed", "Reports slow down payments", ["a replica"], "Reports read a replica", "The data is a minute old", None)for p in check([adr11, adr12]): print(p)ADR-0011 is superseded by ADR-0012, and ADR-0012 is still proposed: the rule fails, because the replacement must be accepted. ADR-0012 also has a single option.
See it
Why a queue: along the chain
The colleague starts from ADR-0004, which shows up in old discussions, and reaches the decision in force:
def history(everything, number): a = everything[number] print(f"{a.name} {a.title} [{a.status}]") while a.superseded_by is not None: a = everything[a.superseded_by] print(f"→ {a.name} {a.title} [{a.status}]")# why do notifications go through a queue?history(by_number([adr4, adr7]), 4)ADR-0004 Notifications are sent synchronously [superseded]→ ADR-0007 Notifications go through a queue [accepted]Two minutes of reading instead of a week of guessing. If the context of ADR-0007 stops being true (emails go to another system that answers fast), the colleague writes ADR-0015, which supersedes it, and the chain grows.
Try it
Find the bug
A colleague proposes an ADR for the cache from the lesson on caching. Which line makes the ADR useless for the next person?
Pick the line with the bug.
The consequences are all good. A decision without a price is not a decision but an advert. What is missing is what will be needed a year from now: a price can be up to 5 minutes old; everyone who writes prices must drop the copies; Redis is one more system to monitor; if Redis goes down, the whole load comes back to the database.
The idea
When an ADR helps and when it is bureaucracy
- It helps with decisions that are expensive to change (a queue, a database, module boundaries, an API format) and that have more than one reasonable answer.
- It helps when it is written before the decision: the options and the price come out while you can still choose another.
- It hurts when every small thing gets an ADR. The name of a class, or a library you can swap in an hour, is not an architectural decision.
- It hurts when it is written afterwards to justify what is already done: then the options are made up and the consequences are all good.
- It is useless when nobody finds it. Put the ADR number in a comment at the place in the code and in the pull request.
More precisely, if you are curious
Where it comes from. The format is Michael Nygard's (2011). Popular templates are his and MADR (Markdown Any Decision Records), which adds "pros and cons of each option".
Tools. adr-tools creates a new file with the next number and changes statuses; log4brains builds a site from the ADRs. A check like the one in this lesson takes an afternoon to write and runs with the tests.
ADR, RFC and design doc. A design doc describes a whole system or a big change before it is built. An RFC is a proposal for many people to discuss. An ADR is one decision, short, and it stays as history. A design doc often ends in a few ADRs.
Who writes them. Not only the architect. Whoever proposes the change writes the ADR in the pull request, and the code review becomes a review of the decision too.
The idea
Which form when
The module showed nine ways to arrange a system. None is good on its own: each solves one pain and carries a price.
| Form | Helps when | Hurts when |
|---|---|---|
| Layers | the rules must be tested without a database and without a screen | the application is only forms over tables and the layers only pass calls along |
| Hexagonal | the domain lives long, while payment and the database change | every class has an interface with no second implementation |
| Modular monolith | one or a few teams, one deploy | the boundaries are not kept and modules reach into other modules' tables |
| Services | independent teams, different scale, separate deploys | one team: the network, the data and the monitoring cost more than they give |
| Events and queues | a slow consequence must not slow the main action | the answer is needed at once; duplicates and order are handled by hand |
| Outbox | an event must not be lost in a crash | there is no queue: a call in the same process is enough |
| API contract | clients you cannot update (the mobile app) | code in one process that changes together |
| Cache | the same thing is read a lot and may be a little old | the value must be exact or cannot be invalidated |
| Scaling | one server is not enough or must not be the only one | one server is enough and the bottleneck is the database |
Monolith first. A new system starts as a modular monolith with clear boundaries. A service is split out when there is a concrete reason: a separate team, a different scale, independent deploys. The reason goes into an ADR, together with the price. The opposite (services from day one) pays the price of a distributed system before anyone knows where the real boundaries are.
Exercise
The contents of the folder
Solve it in your editor.
The docs/adr/ folder grows and nobody remembers which ADRs are in force. Write toc: it prints all ADRs sorted by number with their status, and for a superseded one also the number of its replacement. Give it ADR-0007, ADR-0001, ADR-0009 and ADR-0004 in that order.
ADR-0001 A modular monolith, not services [accepted]ADR-0004 Notifications are sent synchronously [superseded → ADR-0007]ADR-0007 Notifications go through a queue [accepted]ADR-0009 A cache for the product pages [proposed]More tasks
- The ones in force. Print only the accepted ADRs: that is "the architecture today".
- A cycle. What happens to
historyif ADR-0004 is superseded by 0007 and 0007 by 0004? Add a check that finds such a cycle.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Transactional outbox · API design: contract, versions, retried requests · Caching · Scaling
- Continues in: Non-functional requirements you can measure · Trade-offs · Changing a live system (strangler fig, expand–contract) · Architecture review
- Tasks on this topic: Software architecture