ADRs
Architecture Decision Records — the lightweight, version-controlled document format for capturing what was decided, why it was decided, and what alternatives were rejected. Covers ADR structure, the status state machine, tooling with adr-tools, when to write an ADR, and how to make them a living part of the codebase rather than stale historical artifacts.
Overview
An Architecture Decision Record (ADR) is a short document that captures a single architectural decision: what was decided, why it was decided, and what the trade-offs are. ADRs live in your repository alongside the code they affect, making them version-controlled, searchable, and permanently linked to the history of the project.
Without ADRs, the reasoning behind critical decisions disappears when team members leave or memories fade. Six months later, no one knows why a specific library was chosen, why a service was split, or why a particular pattern was avoided. ADRs solve that problem.
How It Works
Each ADR is a standalone Markdown file stored in a dedicated directory — typically docs/adr/ or docs/decisions/. Files are numbered sequentially and never deleted. If a decision is reversed, a new ADR supersedes the old one rather than modifying it. This preserves a full audit trail of every decision the team has made.
ADR Lifecycle (Status State Machine)
Proposed → Accepted → (Deprecated) → Superseded
↓
Rejected| Status | Meaning |
|---|---|
| Proposed | Under active discussion — not yet in effect |
| Accepted | The team agreed; this is the current standard |
| Deprecated | Outdated but no replacement yet; don't follow for new work |
| Superseded | A newer ADR (referenced by number) replaces this one |
| Rejected | Discussed and explicitly decided against |
Think of each ADR like a Git commit message for architecture: it explains the why, not just the what.
When to Write an ADR
Write an ADR for any decision that is:
- Hard to reverse without significant cost
- Not obvious from the code itself
- Likely to be questioned by new team members
- Resolved after genuine deliberation between alternatives
Examples: choosing a state management library, deciding between tRPC and REST, selecting a database migration strategy, adopting a monorepo tooling approach, defining an authentication pattern.
Do NOT write an ADR for: naming conventions, minor library additions, straightforward bug fixes, or any decision that is immediately obvious from the code.
Code Examples
Directory Structure
docs/
adr/
0001-use-nextjs-app-router.md
0002-adopt-postgresql-over-mysql.md
0003-use-zod-for-runtime-validation.md
0004-use-kysely-as-query-builder.md
README.md ← index linking to each ADRADR Template
# ADR-0003: Use Zod for Runtime Validation
**Date:** 2025-06-12
**Status:** Accepted
**Deciders:** @alice, @bob, @carol
**Supersedes:** N/A
**Superseded by:** N/A
---
## Context
Our API route handlers accept user-supplied JSON payloads. We had no consistent
mechanism to validate the shape of incoming data at runtime. TypeScript types are
erased at compilation — type annotations alone cannot protect against malformed
or malicious input at runtime.
We evaluated three options:
- **Yup** — Mature, but verbose API and slower parse performance
- **Joi** — Battle-tested, but no first-class TypeScript type inference
- **Zod** — TypeScript-first; infers static types from schemas via `z.infer<>`;
fast parse performance; widely adopted (React Hook Form, tRPC, Prisma ecosystem)
## Decision
We will use **Zod** as the standard library for all runtime schema validation
across API route handlers, Server Actions, and server-side data loading functions.
## Consequences
**Positive:**
- Single source of truth: one Zod schema produces both the runtime validator
and the TypeScript type via `z.infer<>` — no duplication.
- Consistent error formatting via `ZodError` across all endpoints.
- Strong ecosystem support (React Hook Form, tRPC, Drizzle, Prisma).
**Negative:**
- Adds a runtime dependency (~12 kB minified + gzipped).
- Team members unfamiliar with Zod need a short ramp-up.
- Highly recursive schemas can be verbose.
## Implementation Notes
All schemas live in `src/lib/schemas/`. Route handlers call
`schema.safeParse(body)` for API responses and `schema.parse()` when
a thrown `ZodError` is acceptable (e.g., inside Server Actions where
the error is caught by an error boundary).Applying the Decision in Code
// src/lib/schemas/create-order.ts
import { z } from "zod";
// This schema is the living implementation of ADR-0003.
// Changing the validation library means updating this file and writing ADR-0008.
export const createOrderSchema = z.object({
userId: z.string().uuid(),
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1),
}),
)
.min(1, "An order must contain at least one item"),
couponCode: z.string().optional(),
});
export type CreateOrderInput = z.infer<typeof createOrderSchema>;// src/app/api/orders/route.ts
import { createOrderSchema } from "@/lib/schemas/create-order";
export async function POST(request: Request) {
const body = await request.json();
const result = createOrderSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ code: "VALIDATION_ERROR", details: result.error.flatten().fieldErrors },
{ status: 422 },
);
}
// result.data is fully typed as CreateOrderInput
const order = await createOrder(result.data);
return Response.json(order, { status: 201 });
}Superseding an ADR
When a decision is reversed, write a new ADR and mark the old one as superseded — never edit the original:
# ADR-0008: Replace Zod with Valibot for Runtime Validation
**Date:** 2026-03-01
**Status:** Accepted
**Deciders:** @alice, @dave
**Supersedes:** ADR-0003
---
## Context
ADR-0003 chose Zod for runtime validation. Since then, Valibot has matured
significantly with a bundle size of ~1.4 kB (vs Zod's ~12 kB), which has
become relevant as we add validation to several Client Components where
bundle cost matters.
## Decision
We will migrate from Zod to Valibot for validation in Client Components.
Server-only validation may continue to use Zod until a migration opportunity
arises. New server-side schemas should use Valibot.
## Consequences
**Positive:** ~10 kB reduction in client bundle per page using forms.
**Negative:** Migration cost for existing schemas; two libraries in the
codebase during the transition period.Then update ADR-0003:
**Status:** Superseded by ADR-0008Tooling — adr-tools
adr-tools automates ADR file creation with correct sequential numbering:
# Install
npm install --save-dev adr-tools
# Initialise the ADR directory
npx adr init docs/adr
# Create a new ADR (auto-increments the number)
npx adr new "Use Zod for Runtime Validation"
# Creates: docs/adr/0003-use-zod-for-runtime-validation.md
# Link ADRs (updates the index README)
npx adr generate toc docs/adr/README.md
# Supersede an old ADR
npx adr new -s 3 "Replace Zod with Valibot"
# Marks ADR-0003 as superseded, creates ADR-0008ADR Index (README.md in adr/)
# Architecture Decision Records
| ADR | Title | Status |
| ---------------------------------------------- | ------------------------------ | ------------------ |
| [0001](0001-use-nextjs-app-router.md) | Use Next.js App Router | Accepted |
| [0002](0002-adopt-postgresql-over-mysql.md) | Adopt PostgreSQL over MySQL | Accepted |
| [0003](0003-use-zod-for-runtime-validation.md) | Use Zod for Runtime Validation | Superseded by 0008 |
| [0004](0004-use-kysely-as-query-builder.md) | Use Kysely as Query Builder | Accepted |
| [0008](0008-replace-zod-with-valibot.md) | Replace Zod with Valibot | Accepted |Real-World Use Case
A growing SaaS team of five. In month two, a developer adds pg (node-postgres) directly for a new feature. In month four, another developer installs kysely for a different feature. By month eight, two database access patterns coexist, onboarding is confusing, and a bug surfaces because connection pooling is configured differently in each.
ADR-0004 written in month two — "Use Kysely as the Query Builder" — would have surfaced the decision explicitly during the PR review, invited discussion before implementation, and left a permanent record explaining why raw pg was insufficient. New engineers read ADR-0004 during onboarding and immediately understand why Kysely is used and why adding raw pg queries is not the pattern.
Common Mistakes / Gotchas
1. Writing ADRs retroactively. An ADR written months after the decision, without the original participants, is speculation disguised as documentation. Write ADRs during the decision process — ideally as a PR that the team reviews before the implementation PR merges.
2. Editing an accepted ADR instead of superseding it. Modifying a past ADR destroys the historical record. If a decision changes, mark the old ADR as "Superseded by ADR-XXXX" and write a new one.
3. Making ADRs too broad. An ADR titled "Frontend Architecture" covering routing, state management, styling, and testing is too large. Each significant decision gets its own ADR. Smaller scope means faster review and clearer accountability.
4. Skipping the Consequences section. The positive and negative trade-offs are what separate a useful ADR from a changelog entry. Without trade-offs, the document loses most of its value when the context shifts and engineers wonder whether the original decision still applies.
5. Storing ADRs outside the repository. ADRs in Confluence or Notion become stale and disconnected from the code. In-repo ADRs appear in git log, get reviewed in PRs, and stay in sync with the codebase they document.
Summary
ADRs are lightweight, version-controlled documents that capture significant architectural decisions alongside the code they affect. Each ADR records the context that led to the decision, the decision itself, the rejected alternatives, and the positive and negative consequences. ADRs are never deleted or retroactively edited — superseded decisions get a new ADR that references and replaces the old one. Use adr-tools to automate sequential file naming and maintain an index. Write ADRs during the decision process — as a PR that can be reviewed — not after. In-repo ADRs surfaced during code review and visible in git log are a team's institutional memory that survives turnover.
Interview Questions
Q1. What is an ADR and what problem does it solve that code comments and documentation systems don't?
An Architecture Decision Record captures not just what was decided but why — the context, the alternatives that were considered and rejected, and the trade-offs accepted. Code comments can document what the code does; they cannot document why competing libraries were rejected, what constraints existed at decision time, or why the seemingly-obvious alternative was not chosen. Documentation systems like Confluence or Notion suffer from staleness — they're disconnected from the code they describe and rarely updated. ADRs live in the repository alongside the code, appear in git log, are reviewed in PRs, and are automatically discoverable by anyone who clones the project. They solve the specific problem of institutional memory loss: when the three engineers who agreed on a pattern have left the team, the next engineer wondering "why do we use Kysely and not Prisma?" finds ADR-0004 and understands the decision without bothering anyone.
Q2. Why must ADRs never be edited after they are accepted, and what is the correct process when a decision changes?
Editing an accepted ADR destroys the historical record. The value of an ADR includes the ability to reconstruct what constraints existed at decision time and why those constraints led to a particular choice. If the constraints change (a new library matures, a performance requirement is introduced, a previously dominant vendor abandons their product), the ADR no longer represents what was actually decided — it now misrepresents the decision. The correct process: mark the old ADR's status as "Superseded by ADR-XXXX" (leaving its full content intact), then write a new ADR that references the old one, describes the new context, explains why the old decision no longer applies, and documents the new decision with its trade-offs. This produces an audit trail: engineers can read both ADRs and understand the full history, including why the team changed direction.
Q3. What is the ADR status state machine and what does each status mean in practice?
The ADR lifecycle has five statuses. Proposed: the ADR is under active discussion — it represents a candidate decision, not an enacted one; the implementation PR should not merge until the ADR is accepted. Accepted: the team agreed; this is the current standard for new work. Deprecated: the decision is outdated for new work but no replacement decision has been made yet — existing code following this pattern doesn't need to be updated immediately. Superseded: a specific newer ADR (referenced by number) replaces this one; existing code may follow the old pattern but should be migrated. Rejected: the option was explicitly decided against after deliberation — this is valuable because it documents why an apparently reasonable option was not chosen, preventing the same debate from recurring. Storing Rejected ADRs prevents teams from repeatedly revisiting and relitigating decisions that were deliberately closed.
Q4. When should you write an ADR versus when is it overkill?
Write an ADR when a decision is: hard to reverse without significant cost (framework selection, database choice, authentication strategy), not immediately obvious from the code (why Kysely over Prisma, why tRPC over REST), likely to be questioned by new engineers, or reached after genuine deliberation between alternatives. The test: would a new engineer joining the team in 6 months wonder "why did they do it this way?" — if yes, write an ADR. Do not write an ADR for: naming conventions (document in a CONTRIBUTING.md), adding a minor utility library with no architectural implications, straightforward refactors, or any decision that is self-evident from the code itself. ADRs should be relatively rare — a large codebase might have 20–30 ADRs representing the most significant structural decisions, not hundreds covering every library choice.
Q5. What is the value of the Consequences section, and why is documenting negative consequences important?
The Consequences section — specifically the negative trade-offs — is what makes an ADR useful when the context changes, rather than just when it's written. Positive consequences confirm the decision was reasonable. Negative consequences are the warnings for the future: "adopting Zod adds 12 kB to any bundle that imports it" tells a future engineer that using Zod in a Client Component has a cost. "Two libraries in the codebase during the transition period" tells a future engineer why they see both Zod and Valibot in the same file. Without negative consequences, engineers in 18 months encounter the downsides without understanding that they were known and accepted trade-offs. They either assume it was an oversight (wasted investigation) or re-open a debate that was already closed (wasted discussion). Documenting negative consequences is an act of intellectual honesty: "we knew this was a trade-off and decided the benefits outweighed the costs."
Q6. How do ADRs integrate with the code review and PR process, and what does that workflow look like?
The recommended workflow: when an engineer reaches a decision point — choosing between two libraries, deciding on a structural pattern — they write a draft ADR as a separate PR (or as part of the implementation PR) with status "Proposed." The PR description explains the context and links to the ADR. Reviewers engage with the ADR: they may suggest alternative approaches, challenge the stated consequences, or approve the decision. The ADR is approved in code review before (or alongside) the implementation. Once merged, the status changes to "Accepted." This makes architectural decisions as visible as code changes — they appear in the PR history, reviewers can comment inline, and the decision is preserved in git log forever. The alternative — making the decision informally in a Slack thread and implementing it without an ADR — means the reasoning is in a message thread that will be archived and lost, not in the repository where it can be found.
API Contract Design
Designing explicit, evolvable API contracts between frontend and backend — tRPC for zero-codegen TypeScript contracts, Zod schemas shared as a single source of truth, standardised error envelopes, runtime response validation, contract-first parallel development, and breaking change strategy.
Technical Debt
A system for identifying, categorising, and prioritising technical debt — the four debt types, the impact/effort prioritisation matrix, automated detection with ESLint and TypeScript strict mode, a debt registry with compound cost scoring, and strategies for allocating sprint capacity to paydown without stalling feature delivery.