Guides › ADR practice
ADR Directory Structure in a Monorepo — Per-Service vs Central, Numbering, and CI
The most common structural question engineering teams hit after writing their first 10 ADRs in a monorepo: one central directory or one directory per service? The answer depends on team ownership, numbering tolerance, and how often decisions cross service boundaries.
TL;DR
Two primary patterns: a single central directory (/docs/decisions/) or a per-service directory (/services/auth/docs/decisions/). Most platform teams converge on a hybrid — central for cross-service decisions, per-service for service-local ones. Numbering: per-service numbered files (each service starts from 0001 in its own directory) is lowest-friction; global sequences maximize collision risk. CI: per-service directories need a paths filter in the GitHub Action; Log4Brains handles multiple directories via the workspace config. The inescapable problem with pure per-service layouts: cross-cutting decisions need a central home regardless of your preferred structure.
The two primary patterns
All monorepo ADR layouts reduce to two approaches or a combination of them.
Central directory
One ADR directory at the root covers all decisions regardless of which service they affect:
monorepo/
docs/
decisions/
0001-use-kafka-for-async-events.md
0002-use-postgres-for-auth-service.md
0003-use-redis-for-session-cache.md
services/
auth/
payments/
notifications/
Central layout works well when: the team is small enough that all engineers share context across services; decisions frequently span service boundaries; you want the simplest possible CI configuration (one GitHub Action trigger, one numbering sequence). It breaks down when: teams have distinct ownership of distinct services and a Postgres decision in the auth service creates noise for the payments team's ADR feed.
Per-service directories
Each service owns its own ADR directory, co-located with the service code:
monorepo/
services/
auth/
docs/
decisions/
0001-use-redis-for-session-cache.md
0002-use-bcrypt-over-argon2.md
payments/
docs/
decisions/
0001-use-postgres-over-mysql.md
0002-use-stripe-sdk-not-direct-api.md
notifications/
docs/
decisions/
0001-use-sendgrid-for-transactional-email.md
Per-service layout works well when: teams have clear service ownership; the same number can appear in different directories without confusion (auth/0001 and payments/0001 are different decisions with different scopes); CI should only trigger on the relevant service's PR. It breaks down immediately when a cross-cutting decision arises and you have no central home for it.
The hybrid pattern (what most platform teams converge on)
After operating either pure pattern for six to twelve months, most teams with 3+ services and distinct ownership converge on a hybrid:
monorepo/
docs/
decisions/ ← platform-level and cross-service decisions
0001-use-kafka-for-async-events.md
0002-api-versioning-strategy.md
0003-auth-token-format-across-services.md
services/
auth/
docs/
decisions/ ← auth-service-local decisions
0001-use-bcrypt-over-argon2.md
0002-session-storage-redis-vs-db.md
payments/
docs/
decisions/ ← payments-service-local decisions
0001-use-stripe-sdk.md
The key distinction driving the split: does this ADR's Consequences section describe obligations for engineers on other services? If the answer is yes — other teams must follow a constraint created by this decision — it belongs in the central directory. If the consequences are entirely local to one service's codebase, it belongs in that service's directory.
In practice, roughly 20–30% of decisions in a 5–20 service architecture are cross-cutting. The central directory is not a catch-all; it's the home for calls that affect system-wide contracts (message formats, auth protocols, API versioning conventions, shared library choices).
Numbering strategies
Three numbering strategies exist for monorepos. Each has a different collision profile and cross-reference ergonomics.
Strategy 1: Per-service numbered files (recommended)
Each service directory starts its own sequence from 0001. The same number exists in multiple directories; the directory path provides the namespace.
Collision risk: None within a service. Cross-service reference requires directory-qualified paths, which is rarely needed in practice since service-local decisions rarely cite decisions from other services' private directories.
Cross-referencing: ../../payments/docs/decisions/0001-use-stripe-sdk.md — verbose but precise. For the hybrid layout, platform-level ADRs reference per-service ones rarely; the direction is usually per-service ADRs referencing platform-level decisions in /docs/decisions/.
CI: The GitHub Action paths filter triggers the relevant service's numbering check independently. No shared counter needed.
Strategy 2: Global sequence across all services
One counter for the entire monorepo. ADR 0047 might be in services/payments/docs/decisions/ and ADR 0048 in services/auth/docs/decisions/.
Collision risk: Maximum. Any two parallel PRs from different service teams that both add an ADR will collide on the next number if the merge-base allocator isn't configured to look across all ADR directories. The merge-base-aware allocator handles this but requires scanning all service directories, not just the current one.
Cross-referencing: Simple — ADR 0047 is globally unique. No directory qualification needed.
CI: The numbering job needs to scan all ADR directories to determine the next valid number, which is more complex than the per-service approach.
Strategy 3: Per-service prefix schemes (avoid)
Numbered files with a service-name prefix: AUTH-0001-use-bcrypt.md, PAY-0001-use-stripe.md.
The problem: Service renames break every historical reference. When the "auth" service becomes "identity," all AUTH- prefixed ADRs carry a stale prefix. Git history shows the rename, but cross-references in other files, PR comments, and runbooks all cite the old prefix. The never-renumber rule exists specifically because of this — filename changes propagate breakage silently.
Use instead: Directory paths as the namespace. The directory path can be renamed with a git mv that updates the path itself, but bare filenames like 0001-use-bcrypt.md survive unchanged.
GitHub Action configuration for monorepos
The ADR GitHub Action covers the single-directory case. In a monorepo with per-service directories, you need separate workflow triggers or a matrix job. The cleanest approach is one composite action with a paths filter per service:
name: ADR lint — auth service
on:
pull_request:
paths:
- 'services/auth/docs/decisions/**'
jobs:
adr-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Structure lint
run: |
for f in services/auth/docs/decisions/*.md; do
grep -q "^## Decision" "$f" || (echo "Missing Decision section: $f" && exit 1)
grep -q "^## Status" "$f" || (echo "Missing Status section: $f" && exit 1)
grep -q "^## Consequences" "$f" || (echo "Missing Consequences section: $f" && exit 1)
done
- name: Numbering check
run: |
ADR_DIR="services/auth/docs/decisions"
# check for collisions within this service's directory only
BASE=$(git merge-base HEAD origin/main)
NEXT=$(git show "$BASE:$ADR_DIR/" 2>/dev/null | \
grep -oP '\d{4}' | sort -n | tail -1 | \
awk '{printf "%04d", $1+1}')
echo "Next valid ADR number for auth: $NEXT"
For the hybrid layout, the platform-level directory gets its own workflow:
name: ADR lint — platform
on:
pull_request:
paths:
- 'docs/decisions/**'
An alternative is a single workflow with a matrix over service paths, but the paths filter only supports literal glob patterns — a matrix strategy requires a dynamic matrix generated from the directory listing, which adds complexity without much benefit over separate named workflows.
The supersession integrity check (verifying bidirectional Superseded-by / Supersedes pointers) is the one job worth running across all service directories on every PR — a supersession in the auth service could reference a platform-level ADR in /docs/decisions/. A nightly workflow that scans all ADR directories for unmatched supersession pointers is more practical than per-service PR-time checks for cross-directory references.
Log4Brains workspace configuration
Log4Brains generates a searchable static site from your ADR files. Its workspace mode handles multiple directories natively via the log4brains.yml config:
project:
name: Platform decisions
slug: platform
workspace:
packages:
- name: Platform
slug: platform
adrFolder: docs/decisions
- name: Auth service
slug: auth
adrFolder: services/auth/docs/decisions
- name: Payments service
slug: payments
adrFolder: services/payments/docs/decisions
With this config, log4brains build generates a unified site where ADRs are listed under their package group in the left sidebar. The URL structure is /adr/platform/0001-use-kafka, /adr/auth/0001-use-bcrypt — the slug prefix disambiguates same-numbered ADRs from different services.
Three Log4Brains monorepo limitations worth knowing:
- Build time scales with total ADR count across all packages. A monorepo with 8 services and 20 ADRs each (160 total) builds noticeably slower than a single-service repo with 160 ADRs. Log4Brains doesn't parallelize per-package builds.
- Custom template per package isn't supported. All packages share the same ADR template configuration. If different services use different frontmatter schemas (one uses MADR, another uses Nygard), Log4Brains renders them both but the structured fields (Status, Deciders) only display correctly for the format it's configured for.
- GitHub Pages deployment needs one build artifact. The
fetch-depth: 0requirement applies to the full monorepo — shallow clones miss thedatePublishedattribution that Log4Brains infers from git history. In large monorepos, full clone time can exceed 10 minutes and is worth caching explicitly.
The cross-cutting decision problem
The structural question that per-service layouts don't answer: where does a decision live when it creates obligations for multiple services?
The answer is always the central directory, but identifying cross-cutting decisions before they're written isn't always obvious. The diagnostic: after writing the Consequences section, count how many services would need to change their behavior if this ADR is accepted. Zero → service-local. One or more → platform-level.
The failure mode: a service team writes an ADR for a decision that starts as service-local (a specific data format for that service's events) and then becomes cross-cutting when another service needs to consume those events. At that point, the ADR lives in the wrong directory. Two correct moves:
- Write a new platform-level ADR that formalizes the cross-service contract, with a
Supersedes:reference to the service-local one. The service-local ADR is marked Superseded; the platform-level ADR becomes the canonical reference. - Write a "related" platform-level ADR (not a supersession) that documents the cross-service adoption of the service's format as a distinct decision. The service-local ADR stays Accepted; the platform-level ADR records that the decision to adopt this format system-wide is separate from the decision to use it in one service.
The distinction between (1) and (2) is whether the original service-local decision was reversed by the platform-level adoption or extended by it. Adopting an auth service's token format system-wide doesn't reverse the auth service's original choice — it extends it. Use (2) in that case; use (1) when the original decision's scope was genuinely wrong and the platform-level decision replaces it.
A practical convention: add a Related Services: frontmatter field to platform-level ADRs listing the services whose behavior the decision constrains. This field is not part of the standard Nygard or MADR schemas, but it's useful in monorepo contexts and CI can validate that the named services exist in the directory tree.
When architecture decisions never make it to an ADR
In a monorepo where multiple services are evolving in parallel, the volume of decisions made in AI chat sessions — across the auth team, payments team, and platform team simultaneously — far exceeds what any ADR practice can capture prospectively. Engineers are using ChatGPT and Claude to work through service design questions, library choices, and cross-service contract negotiations in chat conversations that never become ADRs.
The WhyChose extractor surfaces the decision-shaped exchanges from your ChatGPT and Claude exports — the conversations where alternatives were compared, trade-offs named, and a direction chosen — and emits ADR-shaped records that map directly onto the hybrid monorepo structure. A retrospective ADR generated from a chat export can be placed in the correct directory (service-local or platform-level) based on the Consequences section's scope, filling the most common gap in monorepo ADR practices: not the format, but the coverage.
Related questions
Should each microservice have its own ADR directory in a monorepo?
It depends on service team ownership. If distinct teams own distinct services with separate review cadences, per-service ADR directories reinforce team autonomy and keep each team's decision history co-located with the service code. The trade-off is that cross-cutting decisions need a separate home outside any single service directory. The hybrid pattern most platform teams converge on: a central /docs/decisions/ directory for platform-level and cross-service decisions, and per-service directories for service-local decisions. This avoids forcing every service-boundary question into the central directory while still having a canonical home for shared constraints. Start with a central directory; split to per-service only when service team ownership makes the split valuable.
What ADR numbering strategy works best in a monorepo?
Per-service numbered files (each service directory starts from 0001) is lowest-friction. The same number can appear in different service directories — auth/0001-use-redis.md and payments/0001-use-postgres.md — without conflict, because the directory path provides the namespace. Cross-referencing requires directory-qualified paths, which is rarely needed since service-local decisions rarely cite other services' private directories. Avoid per-service prefix schemes (AUTH-0001) because service renames break all historical references. Avoid global sequences because they maximize collision risk under parallel PRs from different service teams. For the central platform directory, a separate global sequence starting from 0001 is fine — platform-level decisions rarely collide because they're reviewed by a cross-team architecture board, not parallel per-team PRs.
How do you handle cross-cutting decisions in a per-service ADR setup?
Cross-cutting decisions — where the Consequences section creates obligations for engineers on other services — belong in the central directory, not in any single service directory. The diagnostic: after drafting the Consequences section, count how many services would need to change behavior if this ADR is accepted. Zero = service-local, one or more = platform-level. If a service-local ADR becomes cross-cutting later (a format extracted into a shared library, a rate limit enforced at the gateway), write a new platform-level ADR that either supersedes or extends the service-local one, with explicit cross-references in both directions.
Can Log4Brains generate a single documentation site from multiple ADR directories in a monorepo?
Yes. Log4Brains supports monorepos via the workspace section of log4brains.yml, where each package entry defines a name, slug, and adrFolder path. Log4Brains generates one unified static site with ADRs grouped by package in the sidebar. The limitation: all packages share one template configuration, so mixed-format directories (some services use MADR, others use Nygard) render inconsistently for structured fields. The GitHub Action CI still needs per-path triggers independent of Log4Brains — the workflow's paths filter determines which service's numbering and lint check runs on a given PR.
Further reading
- ADR numbering scheme — padding, gaps, and collision recovery — the merge-base-aware allocator that prevents numbering collisions under parallel PRs; why gaps are correct; the three renumbering motivations to reject. Directly relevant to the global-sequence collision risk in monorepos.
- The ADR GitHub Action — a CI pipeline for architecture decision records — the four-job CI workflow (structure lint, numbering, supersession integrity, index rebuild); the
pathsfilter that scopes each job to its directory; the nightly supersession check that runs across all directories. - Log4Brains ADR — generate an architecture decision record website from your codebase — the publishing layer for ADR files; workspace configuration for monorepos; GitHub Pages deployment with full fetch-depth and the .nojekyll requirement; the build-time scaling limitation at 100+ ADRs.
- ADR storage format comparison — Markdown vs AsciiDoc vs Notion vs Confluence vs SharePoint — the separate question of storage medium (Markdown in repo vs wiki vs document store). Most monorepo ADR setups use Markdown-in-repo, but the comparison covers the trade-offs of each option including the one-way-door of migrating to Notion or Confluence.
- ADR tooling comparison — adr-tools vs Log4Brains vs adr-log vs hand-rolled — which tools handle monorepo layouts and which don't; creation vs publication as independent jobs; the gap all four tools share for surfacing AI-chat decisions.
- ADR template for squad-sized teams — scaling the practice from 3 to 50+ engineers — how the template and governance ceremony should differ by team size; the 10-person threshold for CI enforcement; the 50-person threshold for a Log4Brains publication layer. The monorepo structure question typically emerges at the 10–20 engineer threshold when the first service boundaries are drawn.
- When to write an ADR (and when you shouldn't) — the four-question threshold (durable, cross-team, contested, costly-to-reverse) applies to the placement question too: if a decision doesn't pass the threshold for cross-team, it belongs in a service-local ADR directory rather than the central platform directory.