Documenting Layer Architecture in a Monorepo

In a monorepo the layer contract has more consumers than authors, and most of them will never read the manifest file. They will read whatever the documentation site says — which is why an out-of-date layer diagram is worse than no diagram: it produces confident, wrong decisions. This page covers making the documentation a build artefact rather than a promise, extending layer naming conventions and governance under the architecture patterns reference.

Prerequisites

You need a single manifest file that is already the source of truth for layer order, and a monorepo tool that can express package dependencies (npm workspaces, pnpm, Nx or Turborepo — the approach is the same).

/* packages/styles-contract/layers.css — imported by every application. */
@layer reset, vendor, base, theme, components, utilities, overrides;

Tooling: Node for the generator script, plus whatever renders your documentation site.

Step 1: Ship the manifest as a package, not a convention

{
  "name": "@org/styles-contract",
  "version": "2.1.0",
  "exports": {
    "./layers.css": "./layers.css",
    "./layers.json": "./layers.json"
  },
  "//": "WHY: layers.json is the machine-readable twin — the generator, the linter and the tests all read it, so none of them re-parse CSS."
}
/* apps/checkout/src/entry.css
   WHY: importing the contract rather than restating it means the checkout
   app cannot drift from the shared order, and a version bump is the only
   way the stack can change under it. */
@import url("@org/styles-contract/layers.css");

What this does: turns “everyone uses the same order” from a rule people follow into a dependency the build enforces.

One contract package, many consumers A central styles contract package at the top declaring the top-level layer order. Arrows lead down to the design system package and three applications. Each consumer declares only its own nested sub-layers beneath the shared components band. @org/styles-contract declares the top-level order — once @org/design-system components.core apps/search components.search apps/checkout components.checkout apps/account components.account No consumer declares a top-level layer. A package that did would become a second source of truth, and whichever one parsed first would silently decide the order for the whole application.

Step 2: Generate the layer map from the manifest

Documentation that is typed by hand describes the stack as it was on the day someone wrote it.

// scripts/generate-layer-map.mjs
// WHY: the docs table is derived from layers.json, so it is structurally
// impossible for the published map to describe a stack that does not exist.
import { readFile, writeFile } from 'node:fs/promises';

const contract = JSON.parse(
  await readFile('packages/styles-contract/layers.json', 'utf8')
);

const rows = contract.layers.map((layer, i) => {
  const children = (layer.children ?? []).join(', ') || '—';
  return `| ${i + 1} | \`${layer.name}\` | ${layer.purpose} | ${layer.owner} | ${children} |`;
});

const table = [
  '<!-- generated by scripts/generate-layer-map.mjs — do not edit -->',
  '| # | Layer | Purpose | Owner | Sub-layers |',
  '|---|-------|---------|-------|------------|',
  ...rows,
].join('\n');

await writeFile('docs/layer-map.md', table + '\n');

What this does: makes the table a build output. Anyone editing it by hand loses their edit on the next build, which is the correct incentive.

Step 3: Record ownership beside the order

{
  "layers": [
    { "name": "reset",      "purpose": "Browser corrections only",        "owner": "@org/design-system" },
    { "name": "vendor",     "purpose": "Wrapped third-party CSS",         "owner": "@org/design-system",
      "children": ["vendor.normalize", "vendor.tiptap"] },
    { "name": "base",       "purpose": "Element defaults and typography", "owner": "@org/design-system" },
    { "name": "theme",      "purpose": "Tokens and colour schemes",       "owner": "@org/brand" },
    { "name": "components", "purpose": "Component styles, per team",      "owner": "shared",
      "children": ["components.core", "components.account", "components.search", "components.checkout"] },
    { "name": "utilities",  "purpose": "Atomic helpers",                  "owner": "@org/design-system" },
    { "name": "overrides",  "purpose": "Time-boxed product patches",      "owner": "product teams" }
  ]
}

What this does: gives the generator, the linter and the tests one shared input, and gives a reviewer a single place to check that a new sub-layer has an owner before it merges.

Step 4: Require an ADR for any order change

<!-- docs/adr/0014-move-vendor-below-reset.md -->
# ADR 0014 — Move `vendor` below `reset`

**Status:** accepted · **Date:** 2026-07-30 · **Affects:** all applications

## Decision
Reorder the manifest so `vendor` precedes `reset`.

## Why
Normalize.css was overriding our box-sizing correction because `vendor` sat
above `reset`. Rather than duplicating the correction, the band moves down.

## Consumer impact
Any package relying on a vendor rule beating a reset rule will change
rendering. Two known cases (`apps/search` map tiles, `apps/checkout` iframe
wrapper) are migrated in the same release.

## Migration
No code change for consumers; bump `@org/styles-contract` to 3.0.0. The major
version signals the behaviour change even though the API is unchanged.

What this does: treats the order as what it is — a breaking-change surface with downstream consumers. The version bump is the part teams miss; a layer reorder is semantically a major release even though no exported name changed.

The drift check that keeps documentation honest A flow from the manifest JSON through a generator to a freshly generated layer map. The generated map is compared with the committed documentation file. Matching output passes CI; any difference fails with an instruction to re-run the generator. layers.json source of truth generator script runs in CI fresh layer map compared with git identical CI passes differs CI fails — regenerate The documentation cannot describe a stack that does not exist, because it is an artefact of the stack.

Step 5: Fail CI when the documentation drifts

# scripts/check-layer-docs.sh
node scripts/generate-layer-map.mjs
if ! git diff --quiet -- docs/layer-map.md; then
  echo "docs/layer-map.md is stale — run: node scripts/generate-layer-map.mjs"
  git --no-pager diff -- docs/layer-map.md
  exit 1
fi

What this does: the only mechanism that reliably keeps architecture documentation current. Reviews do not catch stale docs; a red build does.

Verification

  1. Change a layer’s purpose in layers.json without regenerating, and confirm CI fails with a readable diff.
  2. Confirm every application imports @org/styles-contract/layers.css and none declares a top-level layer:
What each documentation artefact answers Three artefacts with the consumer question each one answers: the generated layer map answers where a rule lands, the ownership table answers who approves a change, and the decision records answer why the order is as it is. generated layer map docs/layer-map.md "where will my rule land, and what will it beat?" ownership table CODEOWNERS + layers.json "whose approval do I need to change this?" decision records docs/adr/*.md "why is the order this way — and can I change it?"
# Any top-level @layer statement outside the contract package is a defect.
grep -rn "^@layer [a-z]" apps packages --include="*.css" | grep -v styles-contract
  1. Check the published documentation renders the ownership column, and that each owner maps to a real CODEOWNERS entry.
  2. Confirm a layer-order change carries an ADR and a major version bump before it merges.

Troubleshooting

Two packages register the same layer with different children. : Sub-layer order is decided by first appearance, so the package that loads first wins and the other’s ordering is ignored. Move the nested declaration into the contract package, where the order is reviewed once.

The generated map is correct but the docs site shows the old one. : The documentation build is caching the previous artefact. Include the contract package version in the docs build’s cache key.

A consumer inserted a layer between two existing bands. : That inverts relationships other packages depend on. Document that consumers may append after the final band and never insert — and add the assertion to the bundle lint so it is enforced rather than requested.

Nobody reads the ADRs. : Link the relevant ADR from the layer map row it explains. An ADR discovered at the moment of confusion gets read; one filed in a directory does not.

Frequently Asked Questions

Should each monorepo package declare its own layers?

Only its own nested sub-layers. Top-level order must come from the shared contract package, because a package that declares top-level names becomes a competing source of truth — and since first declaration wins, whichever package the bundler happens to parse first silently decides the stack for the entire application. Sub-layers are safe precisely because their position is inherited from a parent the contract already fixed.

How do you document layer order for external consumers?

Treat the names as a published API. Ship the manifest as a real file in the package, document each name with its purpose and owner, state a deprecation policy for renames, and say explicitly that consumers may append layers after the last band but must never insert between existing ones. Appending only adds priority above the contract; inserting changes a relationship other teams have already built on.

What belongs in a layer architecture decision record?

The ordering decision being encoded, the alternatives considered, the packages affected and the migration path for consumers already writing into those layers. Most of the record is about consumers rather than CSS, because a reorder is a behavioural breaking change even when no name changes — which is also why it deserves a major version bump on the contract package.

Up: Layer Naming & GovernanceArchitecture Patterns & Design System Scaling