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.
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.
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
fiWhat this does: the only mechanism that reliably keeps architecture documentation current. Reviews do not catch stale docs; a red build does.
Verification
- Change a layer’s purpose in
layers.jsonwithout regenerating, and confirm CI fails with a readable diff. - Confirm every application imports
@org/styles-contract/layers.cssand none declares a top-level layer:
# 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- Check the published documentation renders the ownership column, and that each owner maps to a real CODEOWNERS entry.
- 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.
Related
- Layer Naming Conventions and Governance — the contract this documentation publishes
- Enforcing a Layer Contract With Stylelint Rules — the lint side of the same contract
- Coordinating Layer Order Across Independently Deployed Apps — the same problem without a shared build
- Build-Pipeline Layer Automation — guaranteeing the contract is emitted first in every bundle
Up: Layer Naming & Governance → Architecture Patterns & Design System Scaling