Six months after a change, git log tells you what happened and git blame tells you who. Neither tells you why you picked this approach over the two you rejected. That reasoning usually lived in a meeting, a chat thread, or your head.
Architecture decision records (ADRs) fix half of this by writing the reasoning down. The other half is linking each decision to the commits that carried it out, so you can go from code to reasoning and back. This post shows a setup that uses nothing more exotic than a notes folder, a naming convention, and git's own features.
I'll use Obsidian for the notes, but everything here works with any folder of Markdown files.
What counts as a decision
Not every commit needs one. I'd write a decision note when:
- there were real alternatives and you rejected some of them,
- the choice is expensive to reverse (a database, a framework, an auth model, a public API shape), or
- you can imagine someone (including you) asking "why did we do it this way?" later.
Renaming a variable doesn't qualify. Switching your session store does.
Step 1: Give every decision a stable ID
The ID is the glue, so it has to be boring and predictable. A date plus a short slug works well and sorts nicely:
decisions/adr-20260927-session-store.md
The filename is the ID. Here's a Templater template that creates the frontmatter and structure (save it as templates/decision.md):
---
id: <% tp.file.title %>
status: proposed # proposed | accepted | superseded | rejected
decided: <% tp.date.now("YYYY-MM-DD") %>
reviewed: <% tp.date.now("YYYY-MM-DD") %>
superseded_by:
tags: [decision]
---
# <% tp.file.title %>
## Context
What problem, what constraints, what forced a decision now.
## Options considered
1. **Option A**: what it is. Pros / cons.
2. **Option B**: what it is. Pros / cons.
## Decision
We chose ... because ...
## Consequences
What gets easier, what gets harder, what we're now committed to.
## Commits
Filled in from git, see below.
Create the note with a filename like adr-20260927-session-store, apply the template, fill it in. It takes ten minutes when the decision is fresh and an hour of archaeology if you wait.
Step 2: Reference the ID in commits with a trailer
Git has a built-in convention for structured lines at the end of a commit message, called trailers (Signed-off-by: is the best-known one). Use one for decisions:
Replace JWT refresh flow with server-side sessions
Refresh-token rotation was racing across browser tabs.
Sessions now live in Redis with a 30-day sliding expiry.
Decision: adr-20260927-session-store
You can add the trailer from the command line too:
git commit -m "Move session reads to Redis" --trailer "Decision: adr-20260927-session-store"
(--trailer needs git 2.32 or newer.)
If you want a reminder, add a commit template to the repo and point git at it:
# .gitmessage in the repo root
# Subject line (~50 chars)
# Why this change?
# Decision: adr-YYYYMMDD-slug (delete if not applicable)
git config commit.template .gitmessage
Lines starting with # are stripped from the final message, so the hints cost nothing.
Step 3: Querying the link from the git side
This is where the trailer pays off. No script needed:
# Every commit that implemented a given decision
git log --oneline --grep="Decision: adr-20260927-session-store"
# All commits that reference any decision, with the ID shown
git log --format='%h %s [%(trailers:key=Decision,valueonly,separator=%x2C)]' --grep="^Decision:"
# Which decision explains this line? blame first, then read the commit
git blame -L 40,60 src/auth/session.ts
git show <hash> # the trailer is at the bottom
The second command prints each commit with its decision ID in brackets, which makes a decent audit log on its own.
Step 4: Catch typos with a commit-msg hook
Links are only useful if they point at something real. A small commit-msg hook rejects IDs that don't match a note. (It must be commit-msg, not pre-commit: only commit-msg receives the message file as $1.)
#!/bin/sh
# .git/hooks/commit-msg (or .husky/commit-msg)
DECISIONS_DIR="docs/decisions" # adjust to where your notes live
ids=$(grep -oE '^Decision: adr-[0-9]{8}-[a-z0-9-]+' "$1" | sed 's/^Decision: //')
for id in $ids; do
if [ ! -f "$DECISIONS_DIR/$id.md" ]; then
echo "commit-msg: no decision note found for '$id' in $DECISIONS_DIR" >&2
exit 1
fi
done
exit 0
Make it executable (chmod +x). It uses grep -E rather than grep -P so it also works with the BSD grep on macOS.
If your vault is a separate repo, point DECISIONS_DIR at its checkout path, or drop the hook and live with the occasional typo.
Step 5 (optional): Show commits inside the note
Going from commit to decision is covered by git. Going from decision to commits is covered by git log --grep too, but it's nice to see the list inside the note itself. A small script can rebuild a ## Commits section from git:
#!/bin/sh
# scripts/decision-commits.sh adr-20260927-session-store
id="$1"
note="docs/decisions/$id.md"
git log --reverse --format='- `%h` %ad %s' --date=short --grep="Decision: $id" > /tmp/commits.md
# Replace everything after the "## Commits" heading with the fresh list
awk '/^## Commits/{print; while((getline l < "/tmp/commits.md")>0) print l; skip=1; next} !skip' "$note" > "$note.tmp" && mv "$note.tmp" "$note"
It assumes ## Commits is the last section of the note, which the template above guarantees. Run it by hand when you finish a decision; I wouldn't wire it into a post-commit hook, because a hook that edits files after every commit leaves your working tree permanently dirty.
Seeing decisions in Obsidian
With Dataview, a small dashboard shows what's in flight and what's gone stale:
## Proposed, not yet decided
```dataview
LIST
FROM "decisions"
WHERE status = "proposed"
SORT decided ASC
```
## Accepted, not reviewed in a year
```dataview
TABLE decided, reviewed
FROM "decisions"
WHERE status = "accepted" AND reviewed < date(today) - dur(1 year)
SORT reviewed ASC
```
Decisions that span many commits
A migration might take a dozen commits over several weeks. Nothing changes: every commit carries the same trailer, and git log --grep returns them in order. If phases matter, say so in the subject line ("Phase 2: dual-write sessions to Redis") rather than inventing more metadata.
Keep the rejected ones
When you decide not to do something, write that down too and set status: rejected. The next time someone proposes the same idea, there's a note with the context and the reasons, and you can judge whether those reasons still hold instead of having the whole discussion again. Same for superseded decisions: link the old note to the new one with superseded_by instead of deleting it.
An example, start to finish
To make it concrete, here's what a small one might look like (illustrative, not a real project):
-
Note:
adr-20260310-drop-axios.md. Context: we only use axios for simple JSON requests, and Node 18+ ships a globalfetch. Options: keep axios, switch tofetchwith a small wrapper. Decision: switch, with a wrapper inlib/http.tsthat handles retries and JSON errors. Consequence: we lose interceptors and write our own retry helper. -
Commit 1: "Add fetch wrapper with retry" +
Decision: adr-20260310-drop-axios - Commit 2: "Migrate API handlers to lib/http" + same trailer
- Commit 3: "Remove axios dependency" + same trailer
A year later, someone runs git blame on lib/http.ts, opens the commit, sees the trailer, and reads the note. Total overhead at the time: one note and one extra line per commit.
The short version
Name decision notes with a stable ID, reference it in a commit trailer, and let git do the searching. Add the hook if typos bother you and the Dataview dashboard if you use Obsidian. Everything else is optional.
If you want a ready-made starting point, Dev Second Brain, my Obsidian vault for developers, includes an ADR template along with Dataview dashboards.
More templates and a free Dataview starter pack are at forge.engelailabs.com.
Top comments (0)