Skip to content
wannacode

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
## Status
Accepted. Supersedes ADR-0004.
## Context
ADR-0004: payment calls email directly. On Black Friday the email
provider answered in 8 seconds and payments timed out.
## Options
1. 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.
## Decision
A 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

static readonly HashSet<string> Statuses =
["proposed", "accepted", "superseded", "deprecated"];
static List<string> Check(List<Adr> adrs)
{
var all = ByNumber(adrs);
var bad = new List<string>();
foreach (var a in adrs)
{
var n = a.Name;
if (!Statuses.Contains(a.Status))
bad.Add($"{n}: unknown status {a.Status}");
if (a.Context == "") bad.Add($"{n}: no context");
if (a.Options.Count < 2) bad.Add($"{n}: fewer than two options");
if (a.Decision == "") bad.Add($"{n}: no decision");
if (a.Consequences == "")
bad.Add($"{n}: no consequences");
if (a.Status == "superseded")
{
var next = a.SupersededBy is int k
? all.GetValueOrDefault(k) : null;
if (next is null)
bad.Add($"{n}: superseded, but does not say by what");
else if (next.Status != "accepted")
bad.Add($"{n} → {next.Name}: the replacement is not accepted");
}
}
return bad;
}
Step by step, in words
  1. ADR-0004 is proposed: the context, two options and the decision. Payment calls email directly.
  2. The team accepts it. The price is written down too: a slow email slows payment.
  3. Black Friday: the email provider answers in 8 seconds and payments time out. The written consequence comes true.
  4. A new decision, ADR-0007: notifications go through a queue. Its context points to ADR-0004.
  5. The options and the price: payment no longer waits, but an email may come later, and twice.
  6. ADR-0007 is accepted. The old one is not deleted or rewritten: its status becomes superseded, and it points to the new one.
  7. The check from the lesson: a superseded ADR must point to an accepted one. ADR-0004 → ADR-0007 passes.
  8. 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:

record Adr(int Number, string Title, string Status,
string Context, List<string> Options, string Decision,
string Consequences, int? SupersededBy)
{
public string Name => $"ADR-{Number:D4}";
}
var adr4 = new Adr(4,
"Notifications are sent synchronously",
"superseded",
"First version: email is fast",
["a direct call", "a queue"],
"Payment calls email directly",
"Simple, but a slow email slows payment",
7);
var adr7 = new Adr(7,
"Notifications go through a queue",
"accepted",
"ADR-0004: email slows payments down",
["direct with a timeout", "a queue"],
"Payment puts an event on the queue",
"No waiting; an email may come twice",
null);

The check guards the two things people forget: the parts of an ADR and the "superseded by" chain.

static Dictionary<int, Adr> ByNumber(List<Adr> adrs) =>
adrs.ToDictionary(a => a.Number);
static readonly HashSet<string> Statuses =
["proposed", "accepted", "superseded", "deprecated"];
static List<string> Check(List<Adr> adrs)
{
var all = ByNumber(adrs);
var bad = new List<string>();
foreach (var a in adrs)
{
var n = a.Name;
if (!Statuses.Contains(a.Status))
bad.Add($"{n}: unknown status {a.Status}");
if (a.Context == "") bad.Add($"{n}: no context");
if (a.Options.Count < 2) bad.Add($"{n}: fewer than two options");
if (a.Decision == "") bad.Add($"{n}: no decision");
if (a.Consequences == "")
bad.Add($"{n}: no consequences");
if (a.Status == "superseded")
{
var next = a.SupersededBy is int k
? all.GetValueOrDefault(k) : null;
if (next is null)
bad.Add($"{n}: superseded, but does not say by what");
else if (next.Status != "accepted")
bad.Add($"{n} → {next.Name}: the replacement is not accepted");
}
}
return bad;
}

ADR-0005 is marked as superseded but does not say by what. ADR-0009 has one option and no consequences:

var adr5 = new 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", null);
var adr9 = new Adr(9, "A cache for the product pages", "proposed", "The database is at 100% in campaigns",
["Redis"], "Cache-aside with a 5-minute TTL", "", null);
var problems = Check([adr4, adr5, adr7, adr9]);
problems.ForEach(Console.WriteLine);
Console.WriteLine($"{problems.Count} problems");
Output
ADR-0005: superseded, but does not say by what
ADR-0009: fewer than two options
ADR-0009: no consequences
3 problems

A check like this runs with the tests on every pull request, and an ADR without consequences never reaches the main branch.

Try it

Predict the output

The check above gets two ADRs about the reports. What does it print?

var adr11 = new 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);
var adr12 = new Adr(12, "Reports read a replica", "proposed", "Reports slow down payments",
["a replica"], "Reports read a replica", "The data is a minute old", null);
Check([adr11, adr12]).ForEach(Console.WriteLine);
Answer

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:

static void History(Dictionary<int, Adr> all, int number)
{
var a = all[number];
Console.WriteLine($"{a.Name} {a.Title} [{a.Status}]");
while (a.SupersededBy is int next)
{
a = all[next];
Console.WriteLine($"→ {a.Name} {a.Title} [{a.Status}]");
}
}
// why do notifications go through a queue?
History(ByNumber([adr4, adr7]), 4);
Output
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 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.

Output
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 history if ADR-0004 is superseded by 0007 and 0007 by 0004? Add a check that finds such a cycle.

Test

Check yourself

1. Which of these is NOT a required part of an ADR?
2. An accepted ADR is no longer true. What do you do?
3. The "Consequences" section says only "everything gets faster". What is the problem?
4. When is an ADR needless bureaucracy?
5. A shop with one team of five starts a new system. What should it start with?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·