DEV Community

Cover image for Architecture Decision Records: Template and Example
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Architecture Decision Records: Template and Example

Architecture decision records are, to me, the most underrated tool in software development. Every team makes decisions all the time: Which database? Synchronous calls or events? Monorepo or not? Six months later, nobody remembers why things turned out this way. The person who knew is on vacation, in another team or has left the company. And then the same discussion starts all over again.

An architecture decision record, or ADR for short, is the simplest answer to this problem I know: one short text file per decision, right next to the code. In this article I show you what belongs in a good ADR, what a complete example for a fictional bike rental looks like, and how to introduce ADRs in your repository step by step. That includes a small TypeScript tool that assigns numbers, marks superseded decisions and builds an index.

In short

An ADR captures a single architecture decision with its context, options and consequences on about one page and lives versioned in the repository. Most of the value comes from writing it, because the team has to say its assumptions out loud. The most common mistake is rewriting accepted ADRs instead of superseding them with a new one.

What you will learn in this article

  • What an ADR is and which decisions deserve one
  • A lean template you can copy right away
  • What a fully written ADR for a Serverless system looks like
  • How to manage ADRs in your repository with a small TypeScript tool
  • How the team reviews ADRs together without endless meetings

What are architecture decision records?

The idea goes back to Michael Nygard, who described it in a short blog post in November 2011. His proposal was deliberately small: every important decision gets its own document with a sequential number, written in lightweight markup such as Markdown and stored in the project repository. The collection of these documents is called a decision log.

The key word is record. An ADR is not a concept paper and not architecture documentation describing the current state. It is a log entry: at this point in time, with this knowledge, we decided this way for these reasons. That is why an accepted ADR is not changed anymore. If the situation changes, you write a new ADR that supersedes the old one. This keeps the evolution of the system traceable.

Martin Fowler sums up in his bliki why this works: an ADR should be short, usually one page, lead with the most important information and link to supporting material. He also stresses a point I see the same way: writing itself sharpens thinking, because different views within the team become visible.

When is an ADR worth it, and when not?

Not every decision needs a document. If you bump a package or rename a variable, an ADR would be pure bureaucracy. The AWS Prescriptive Guidance uses “architecturally significant” as the criterion. Examples include structure (such as how you cut microservices), non-functional requirements like security and availability, dependencies between components, interfaces and contracts, as well as tools, frameworks and processes.

In everyday work, a simpler rule of thumb helps me: would a new team member ask “Why did you do it this way?” in six months, and would the answer take more than one sentence? Then an ADR is worth it. A second hint is work that is expensive to reverse. Whether you store rentals in DynamoDB or in Aurora will still matter in three years. Whether a function has 512 or 1024 MB of memory, you can change in a minute.

Worth an ADR Probably not
Choice of database or messaging service Package updates and small refactorings
How services and responsibilities are cut Memory size or timeout of a function
Auth concept, tenant isolation, encryption Naming conventions for individual variables
Switching from the Serverless Framework to CDK (or back) Formatting rules enforced by a linter
Public API contracts and versioning Internal helper functions

A template for architecture decision records

Nygard suggested five sections: title, context, decision, status and consequences. Later, MADR (Markdown Architectural Decision Records) became a widely used, more detailed template. It is currently at version 4.0.0 from September 2024 and licensed under MIT or CC0. Among other things, MADR adds considered options, decision drivers and a confirmation section that describes how to verify the implementation.

Nygard or MADR: which template fits your team?

Nygard (2011) MADR 4.0
Scope 5 sections, very lean several required and optional sections
Options not included dedicated section with pros and cons
Metadata status in the text YAML front matter with status, date, people involved
File name doc/arch/adr-NNN.md NNNN-title-with-dashes.md in docs/decisions
Good for teams just getting started teams with many stakeholders and reviews

My recommendation: start lean. The template below is a mix I would use myself. It takes Nygard’s core, adds the considered options from MADR and a section called “When we revisit this”. Fowler suggests something similar: write down which change in the environment would call the decision into question. That is the part that saves the most discussions later.

<!-- docs/decisions/template.md -->
# ADR-NNNN: Short title phrased as a decision

Status: proposed
Date: YYYY-MM-DD

## Context and problem

What situation forces us to decide? Which forces are at play?

## Considered options

1. Option A
2. Option B

## Decision

We will ...

## Consequences

- Positive: ...
- Negative: ...

## When we revisit this

Which change would call this decision into question?
Enter fullscreen mode Exit fullscreen mode

A few notes on the sections: in the context, describe the situation neutrally, without already selling one option. Phrase the decision in active voice; Nygard recommends sentences like “We will …”. The consequences must explicitly include the negative ones. An ADR without downsides is usually a sales brochure.

An ADR example for a bike rental

Theory is quickly explained, so here is a complete example. Picture a small bike rental with stations around the city. The backend is built Serverless. So far, one Lambda function has called three other functions directly on every return, as recorded in ADR-0002. Now more consumers want to react to returns, and the coupling becomes a problem.

<!-- docs/decisions/0003-route-returns-via-eventbridge.md -->
# ADR-0003: Route returns via EventBridge

Status: accepted
Date: 2026-11-08
Supersedes: ADR-0002

## Context and problem

When someone returns a bike at a station, our Lambda function
"book-return" currently calls three other functions directly: invoicing,
maintenance planning and the customer email. If the email function fails,
the whole return fails, even though the bike is already back in the rack.
For next season, two more teams want to react to returns
(bonus points, utilization statistics).

## Considered options

1. Keep the direct calls and protect them with retries
2. SNS topic with one SQS queue per consumer
3. EventBridge bus with one rule per consumer

## Decision

We will publish returns as a "BikeReturned" event on a dedicated
EventBridge bus. Each consumer gets its own rule and its own SQS queue
with a dead-letter queue as the target.

## Consequences

- Positive: The return succeeds independently of the consumers.
- Positive: New consumers can be added without changing "book-return".
- Negative: A flow is harder to follow, so we need tracing.
- Negative: The order of events is not guaranteed.

## When we revisit this

When a consumer needs strict ordering per bike, or more than roughly ten
consumers are added and the rules become hard to oversee.
Enter fullscreen mode Exit fullscreen mode

Notice what this ADR does not contain: no implementation details, no code, no rule patterns. Those belong in the code and the pull requests. The ADR only answers the why. If you want to dig into the patterns behind it, the AWS service finder helps you decide when EventBridge, SNS or SQS fits.

🎬 Lifecycle of an architecture decision record – this animated diagram only runs on my site: watch it on sascha-ms.de

Introduce architecture decision records in your repository

Now it gets practical. We create a folder for the ADRs and build a small tool in TypeScript. It assigns sequential numbers, creates files from the template, marks superseded ADRs and writes an overview. Of course there are ready-made tools such as adr-tools or Log4brains. I show my own variant here because it is only a few lines long, has no runtime dependencies and you can adapt it to your template.

Prerequisites

  • Node.js 22 or newer (node -v)
  • a Git repository where the ADRs should live
  • about 20 minutes

Step 1: Create the project and folder

Create a folder called docs/decisions in your repository. I borrowed the name from MADR; doc/adr is just as common. All that matters is that everyone can find it. Then install the development tools:

mkdir -p docs/decisions src scripts test
npm init -y
npm install --save-dev typescript tsx @types/node
Enter fullscreen mode Exit fullscreen mode

Add the scripts and the module type to your package.json:

{
  "name": "adr-tool",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "adr": "tsx scripts/adr.ts",
    "test": "tsx --test test/*.test.ts",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

The tsconfig.json is deliberately strict and emits nothing, because tsx runs the files directly:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "strict": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src", "scripts", "test"]
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Put the logic into a library

The real work happens in plain functions, which makes them easy to test. For example, nextNumber finds the next free number, and markSuperseded sets the status of the old ADR to “superseded by”. Everything else in the old document stays untouched.

// src/adr-lib.ts
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";

export const ADR_DIR = process.env.ADR_DIR ?? "docs/decisions";
export const STATUSES = ["proposed", "accepted", "rejected", "deprecated", "superseded"] as const;

export interface AdrInfo {
  number: number;
  file: string;
  title: string;
  status: string;
  date: string;
}

// "Route returns via EventBridge" -> "route-returns-via-eventbridge"
export function slugify(title: string): string {
  return title
    .toLowerCase()
    .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

export function listAdrFiles(dir = ADR_DIR): string[] {
  if (!existsSync(dir)) return [];
  return readdirSync(dir).filter((f) => /^\d{4}-.+\.md$/.test(f)).sort();
}

export function nextNumber(files: string[]): number {
  const numbers = files.map((f) => Number(f.slice(0, 4)));
  return numbers.length === 0 ? 1 : Math.max(...numbers) + 1;
}

export function renderTemplate(n: number, title: string, date: string, supersedes?: number): string {
  const id = String(n).padStart(4, "0");
  const replaces = supersedes ? `\nSupersedes: ADR-${String(supersedes).padStart(4, "0")}` : "";
  return `# ADR-${id}: ${title}

Status: proposed
Date: ${date}${replaces}

## Context and problem

What situation forces us to decide? Which forces are at play?

## Considered options

1. Option A
2. Option B

## Decision

We will ...

## Consequences

- Positive: ...
- Negative: ...

## When we revisit this

Which change would call this decision into question?
`;
}

export function parseAdr(file: string, content: string): AdrInfo {
  const title = /^# ADR-\d{4}: (.+)$/m.exec(content)?.[1]?.trim() ?? "(untitled)";
  const status = /^Status: (.+)$/m.exec(content)?.[1]?.trim() ?? "(missing)";
  const date = /^Date: (.+)$/m.exec(content)?.[1]?.trim() ?? "";
  return { number: Number(file.slice(0, 4)), file, title, status, date };
}

export function readAll(dir = ADR_DIR): AdrInfo[] {
  return listAdrFiles(dir).map((f) => parseAdr(f, readFileSync(join(dir, f), "utf8")));
}

export function createAdr(title: string, date: string, dir = ADR_DIR, supersedes?: number): string {
  mkdirSync(dir, { recursive: true });
  const n = nextNumber(listAdrFiles(dir));
  const file = `${String(n).padStart(4, "0")}-${slugify(title)}.md`;
  writeFileSync(join(dir, file), renderTemplate(n, title, date, supersedes), { flag: "wx" });
  if (supersedes) markSuperseded(supersedes, n, dir);
  return file;
}

export function markSuperseded(oldNumber: number, newNumber: number, dir = ADR_DIR): void {
  const prefix = String(oldNumber).padStart(4, "0") + "-";
  const file = listAdrFiles(dir).find((f) => f.startsWith(prefix));
  if (!file) throw new Error(`ADR-${prefix.slice(0, 4)} not found`);
  const path = join(dir, file);
  const updated = readFileSync(path, "utf8").replace(
    /^Status: .+$/m,
    `Status: superseded by ADR-${String(newNumber).padStart(4, "0")}`,
  );
  writeFileSync(path, updated);
}

export function checkAdrs(adrs: AdrInfo[]): string[] {
  const problems: string[] = [];
  for (const adr of adrs) {
    const base = adr.status.split(" ")[0];
    if (!(STATUSES as readonly string[]).includes(base)) {
      problems.push(`${adr.file}: unknown status "${adr.status}"`);
    }
    if (!/^\d{4}-\d{2}-\d{2}$/.test(adr.date)) {
      problems.push(`${adr.file}: date missing or not in YYYY-MM-DD format`);
    }
  }
  return problems;
}

export function renderIndex(adrs: AdrInfo[]): string {
  const rows = adrs.map(
    (a) => `| ${String(a.number).padStart(4, "0")} | [${a.title}](./${a.file}) | ${a.status} | ${a.date} |`,
  );
  return ["# Architecture decisions", "", "| No. | Title | Status | Date |", "| --- | --- | --- | --- |", ...rows, ""].join("\n");
}
Enter fullscreen mode Exit fullscreen mode

Step 3: The command line

The script only dispatches commands to the library. new creates an ADR, supersede replaces an existing one, index writes the overview and check validates status and date.

// scripts/adr.ts
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { ADR_DIR, checkAdrs, createAdr, readAll, renderIndex } from "../src/adr-lib.ts";

const [command, ...args] = process.argv.slice(2);
const today = new Date().toISOString().slice(0, 10);

switch (command) {
  case "new": {
    const title = args.join(" ").trim();
    if (!title) throw new Error('Title missing: npm run adr -- new "My title"');
    console.log(`Created: ${join(ADR_DIR, createAdr(title, today))}`);
    break;
  }
  case "supersede": {
    const oldNumber = Number(args[0]);
    const title = args.slice(1).join(" ").trim();
    if (!oldNumber || !title) throw new Error('Usage: npm run adr -- supersede 3 "New title"');
    console.log(`Created: ${join(ADR_DIR, createAdr(title, today, ADR_DIR, oldNumber))}`);
    break;
  }
  case "index": {
    writeFileSync(join(ADR_DIR, "README.md"), renderIndex(readAll()));
    console.log(`Index written: ${join(ADR_DIR, "README.md")}`);
    break;
  }
  case "check": {
    const problems = checkAdrs(readAll());
    problems.forEach((p) => console.error(p));
    if (problems.length > 0) process.exit(1);
    console.log("All ADRs are fine.");
    break;
  }
  default:
    console.log("Commands: new <title> | supersede <no> <title> | index | check");
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Write and run the tests

The tests use the built-in Node.js test runner. Each test works in its own temporary folder, so nothing ends up in your real repository.

// test/adr-lib.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkAdrs, createAdr, nextNumber, parseAdr, readAll, renderIndex, slugify } from "../src/adr-lib.ts";

test("slugify converts umlauts and special characters", () => {
  assert.equal(slugify("Route returns über EventBridge!"), "route-returns-ueber-eventbridge");
});

test("nextNumber continues without gaps", () => {
  assert.equal(nextNumber([]), 1);
  assert.equal(nextNumber(["0001-a.md", "0007-b.md"]), 8);
});

test("createAdr creates a file with status proposed", () => {
  const dir = mkdtempSync(join(tmpdir(), "adr-"));
  const file = createAdr("Store rentals in DynamoDB", "2026-11-08", dir);
  assert.equal(file, "0001-store-rentals-in-dynamodb.md");
  const adr = parseAdr(file, readFileSync(join(dir, file), "utf8"));
  assert.equal(adr.status, "proposed");
  assert.equal(adr.title, "Store rentals in DynamoDB");
});

test("supersede marks the old decision as superseded", () => {
  const dir = mkdtempSync(join(tmpdir(), "adr-"));
  createAdr("Process returns with direct Lambda calls", "2026-11-01", dir);
  createAdr("Route returns via EventBridge", "2026-11-08", dir, 1);
  const [oldAdr, newAdr] = readAll(dir);
  assert.equal(oldAdr.status, "superseded by ADR-0002");
  assert.match(readFileSync(join(dir, newAdr.file), "utf8"), /Supersedes: ADR-0001/);
});

test("checkAdrs reports unknown status and missing date", () => {
  const problems = checkAdrs([{ number: 1, file: "0001-x.md", title: "X", status: "maybe", date: "" }]);
  assert.equal(problems.length, 2);
});

test("renderIndex produces a Markdown table", () => {
  const md = renderIndex([{ number: 2, file: "0002-y.md", title: "Y", status: "accepted", date: "2026-11-08" }]);
  assert.match(md, /\| 0002 \| \[Y\]\(\.\/0002-y\.md\) \| accepted \| 2026-11-08 \|/);
});
Enter fullscreen mode Exit fullscreen mode
npm run typecheck
npm test
Enter fullscreen mode Exit fullscreen mode

Expected result: tsc reports nothing, and the test runner ends with # pass 6 and # fail 0.

Step 5: Create your first ADR

This is the most important step. My tip: your first ADR should be the decision to write ADRs at all. That way you explain format and process to the team right away.

npm run adr -- new "Record architecture decisions as ADRs"
npm run adr -- new "Store rentals in DynamoDB"
npm run adr -- index
npm run adr -- check
Enter fullscreen mode Exit fullscreen mode

The output looks like this, without the npm header lines:

Created: docs/decisions/0001-record-architecture-decisions-as-adrs.md
Created: docs/decisions/0002-store-rentals-in-dynamodb.md
Index written: docs/decisions/README.md
All ADRs are fine.
Enter fullscreen mode Exit fullscreen mode

Later, when a decision gets replaced, run npm run adr -- supersede 2 "Store rentals in Aurora". The new ADR then refers to the old one, and the old one gets the status “superseded by ADR-0003”.

Step 6: Add the check to your pipeline

To keep the conventions alive three months from now, npm run adr -- check should run in your CI pipeline, for example as an extra step after the tests. If the check fails because someone invented the status “maybe”, the build stops with exit code 1. In the same step you can regenerate the index and verify that it was committed with the pull request.

How does the team decide on an ADR together?

As a team lead, this is the part I care about most, because ADRs are as much a leadership tool as a technical one. The AWS Prescriptive Guidance describes a process I consider a good starting point: whoever writes the ADR owns it. The owner submits it in the “proposed” state. In the review, the team first reads the document quietly for about 10 to 15 minutes, collects comments and questions, and then discusses them.

If there are open points, the ADR stays “proposed”, the tasks are assigned and a new date is set. If it is rejected, the owner records the reason. That sounds formal, but it stops the same idea from coming back every three months. Once accepted, it is considered immutable.

Why silent reading works so well

Reading silently at the start is the real trick for me. In classic architecture meetings, the loudest voices often dominate and the quieter people nod along. When everyone reads first and comments in writing, you also get objections from people who would not be the first to raise their hand in a discussion. The format also suits distributed teams: an ADR as a pull request can be commented on asynchronously, and the meeting gets shorter or disappears entirely. I describe how to anchor this kind of habit in remote work in my remote onboarding checklist, because ADRs are also one of the best onboarding aids.

Who gets to decide?

An ADR does not replace a decision rule. So clarify up front who makes the final call: the whole team by consensus, the person most responsible for the system, or you as the lead after consultation. Fowler explicitly mentions ADRs in connection with the so-called advice process. In that model, anyone may decide but must first ask those affected and the experts for advice and summarize that advice in the ADR. This spreads responsibility without making decisions arbitrary.

Common mistakes with architecture decision records

ADRs turn into novels

Nobody reads an eight-page ADR. If you have benchmarks, cost estimates or spike results, link to them. The ADR itself should be readable in five minutes.

Accepted ADRs get quietly rewritten

This is the classic. Someone edits an old ADR because something changed, and the history is gone. Fix typos and links at most. Changes in substance always mean a new ADR.

The rejected options are missing

The most common question six months later is almost always: “Did you also consider X?” If the options are missing, you cannot answer it. Two or three serious alternatives with one sentence on why they were rejected are enough.

ADRs are written after the fact

An ADR written after the implementation is better than none, but it loses its biggest benefit: thinking together before the decision. Write the ADR while the question is still open.

Nobody reads them

A decision log nobody knows about is a graveyard. So link ADRs wherever decisions become visible: in code reviews, in the runbook and in the pull request template. I showed what such a template can look like in my article on the pull request template for AI-generated code. A field for “Affected ADRs” fits in nicely there.

My take

I consider ADRs one of the few documentation formats that actually survive in practice. The reason is simple: they are small, they live with the code and they never need updating. An architecture overview in the wiki goes stale; an ADR cannot, because it only describes one point in time.

At the same time, I see two risks. First, an ADR process can become an excuse to delay decisions because “the ADR isn’t through yet”. So set a deadline for reviews. Second, AI assistants make it tempting to generate ADRs at the push of a button. For the wording, that is perfectly fine. But the team has to think through the context and especially the downsides itself, otherwise you get nice-sounding documents without any real weighing of options.

One more point from the leadership perspective: ADRs are a tool against knowledge silos. If only one person knows why the system is built this way, that is a risk for the team and a burden for that person. A decision log makes knowledge shareable. To me, that is “People first” in technical form.

FAQ

What is an architecture decision record?

A short document that captures a single architecture decision with its context, the options considered, the decision itself and its consequences. It usually lives as a Markdown file in the repository.

Where is the best place to store ADRs?

In the repository of the affected code, for example in docs/decisions or doc/adr. Decisions that affect several systems can live in a dedicated architecture repository.

How long should an ADR be?

About one page, two at most. Detailed material such as benchmarks or cost estimates is linked rather than pasted in.

Can you change an ADR afterwards?

Yes, as long as it is still “proposed”. Once accepted, only typos or links are fixed. Changes in substance lead to a new ADR that supersedes the old one.

That leaves the question of who is allowed to make a decision before it ends up in an ADR. The advice process offers a simple answer: anyone may decide after asking affected people and experts for advice.

Your next step

Pick a decision your team made in the last few weeks that is still fresh in everyone’s mind. Write an ADR for it using the template above, one page at most, and open it as a pull request for discussion. You will be surprised how many different assumptions come to light.

And now I’m curious: which decision in your project would have deserved an ADR, because today nobody knows anymore why it was made?

Sources

This post was created with AI assistance.


👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)