Overview
A manifest is a tree of nodes keyed by path pattern. A key ending in / is a folder;
anything else is a file. Everything the architecture says about a part of the tree is
written at that part of the tree. It is a YAML file, architecture.yaml at the repository
root — see Formats for the others.
# yaml-language-server: $schema=https://dataquail.github.io/goodbones/schema/architecture.schema.jsonresolve: scopes: - files: "" language: typescript options: { tsconfig: tsconfig.json } unresolved: errorbaseline: .architecture-baseline.jsonaliases: { "@": packages/server/src, "~": packages }deny: [] # prohibitions that hold everywhereexports: [] # who may name a given exported symbolgraph: {} # cycles, orphans, reach — the whole repository at oncelimits: {} # ceilings on the adoption backlog, floors on coverage, ceilings on conformancedefs: {} # fragments the tree below reusestree: {} # the repositoryTop level
Section titled “Top level”| Field | Meaning |
|---|---|
resolve |
how an import specifier becomes a file — Resolution |
baseline |
path to the ratchet file — Baseline |
aliases |
shorthands expanded in every glob, so a pattern reads the way your imports do |
deny |
prohibitions that hold everywhere, declared once |
exports |
which importers may name a given exported symbol |
graph |
cycles, orphans and transitive reach over the whole import graph — Graph |
limits |
ceilings on unrestricted/partial tiers, floors on coverage, ceilings on what conformance measures — Adoption |
campaigns |
refactors the repository is running, each with objectives, sectors and phases — Campaigns |
ledger |
the directory the objectives’ ledgers are written to — Ledger |
defs |
named fragments, used anywhere below as { use: <name> } — Reuse |
tree |
the manifest proper |
Any of these, or any node or list inside them, may be { include: <path> } — another file
that holds it — so a monorepo’s policy can live beside its packages:
Splitting the manifest.
deny, exports, graph, limits and campaigns sit above the tree because they are
statements about the whole repository rather than about a tier. A rule like “this SQL driver is imported from one
package” put on every tree root would be six copies and a seventh forgotten.
aliases are pure text substitution applied to every glob before compilation. With
{ "@": "packages/server/src" }, the key @/modules/{module}/domain/ is the pattern
packages/server/src/modules/{module}/domain/. They exist so the manifest reads like the
imports it governs, not so specifiers go unresolved — resolution is separate and always
real.
A node
Section titled “A node”"@/modules/{module}/domain/{subdomain}/": message: "domain/ holds the model for one subdomain." imports: { reset: true, external: [effect], allow: ["@/modules/{module}/domain/**"] } children: "*.root.ts": name: { like: "{subdomain}" } requires: ["{base}.root.test.ts"] "*.repository.ts": importedBy: message: "A repository is reached from a command handler." allow: ["@/modules/*/commands/**"] members: - message: 'A port method named "{name}" is a query in disguise.' subject: members declares: [type, interface] in: "*RepositoryShape" allow: [findOneById, save, delete] surface: - message: "A port exports its type and nothing else." declares: [variable, function, class]| Field | Question it answers | Page |
|---|---|---|
message |
what does this folder admit, and why | |
imports |
what may this reach? | Imports |
importedBy |
who may reach this? | Imported by |
members |
which names may it declare or call? | Members |
surface |
what may it export? | Surface |
name |
what may the variable part of a name look like? | Structure |
requires |
which siblings does this file owe? | Structure |
requiresNot |
which filenames does that obligation skip? | Structure |
children |
which files and folders does this folder admit? | Structure |
partial |
carry policy here without enumerating contents | Inheritance |
layout |
open — do not enumerate file names here |
Structure |
Every field is optional. An empty node — "*.root.ts": {} — is meaningful and common: it
says the file is admitted here and inherits everything above it. That is how a folder’s
children doubles as its file taxonomy.
Formats
Section titled “Formats”A manifest is a data file: architecture.yaml (or .yml), or architecture.json, which the
same parser reads. Both hosts look for one by name at the repository root, in that order,
and then for architecture.config.mjs; exactly one may be present, and ARCHITECTURE_CONFIG
names a file somewhere else. The JavaScript module is still read — see
Using a JavaScript manifest for when that
is worth it — but the data forms are what every page here is written in, and the only forms a
host in another language could read.
The first line of a YAML manifest names its schema, and the editor does the rest: completion on every key, and a misspelled one flagged before the loader runs.
# yaml-language-server: $schema=https://dataquail.github.io/goodbones/schema/architecture.schema.jsonA JSON manifest says the same with a $schema key, which the loader strips. The schema is
generated from the codec that decodes the manifest, so the two cannot disagree, and it ships
inside @goodbones/core as schema/architecture.schema.json for an editor that would rather
not fetch it. A file the manifest includes names
architecture-node.schema.json instead, beside it.
What the schema does not catch, the loader does, naming the file, the line and the column — and every issue, not the first:
architecture.yaml: the manifest does not decode: architecture.yaml:142:9 tree["~/core/"].members[0].subject: Expected "members" | "calls", got "call" architecture.yaml:201:7 tree["~/cli/"].imports.alow: Unexpected keyQuote every glob and every message: *, @, a backtick and { all mean something to YAML
when bare. Patterns has the
rules.
Reuse: defs and use
Section titled “Reuse: defs and use”A top-level defs map names fragments. Anywhere below it — a rule in members, surface,
exports, deny or graph, a whole imports object, a whole node under children —
{ use: <name> } is replaced by a copy of the fragment before the manifest is decoded:
defs: no-file-system-calls: message: "`{name}` reads or writes the file system. This tier is given facts." subject: calls match: ["*Sync", readFile, writeFile] package-floor: external: [effect] allow: ["node:**"]
tree: "packages/core/": imports: { use: package-floor, message: "This import is not on the allowlist." } children: "src/domain/": { members: [{ use: no-file-system-calls }], children: {} } "src/core/": { members: [{ use: no-file-system-calls }], children: {} }A key written beside use overrides the fragment’s key of the same name, shallowly: a list
replaces the list rather than merging with it, and a fragment that needs partial override is
two fragments. A fragment may itself contain use. A cycle is refused, naming the chain, and
so is a name defs does not contain, listing the ones it does. The decode that follows judges
the copy where it landed, so a fragment used where its shape does not fit is reported at the
fragment, via the use that pulled it in.
This is the manifest’s own mechanism, so it works the same in YAML, in JSON, and in a
JavaScript module. YAML anchors and merge keys (&floor, <<: *floor) work too, because the
parser resolves them before the manifest is read; but JSON has no such thing, and an error
inside a merged key cannot name its line, so defs is the one the docs use.
There is no interpolation and no deep merge. A manifest that needs those needs a generator, and a generator can emit YAML.
Splitting the manifest: include
Section titled “Splitting the manifest: include”A monorepo’s manifest grows with the monorepo, and one file of two thousand lines is a file
nobody reads. { include: <path> } standing anywhere in the manifest — a node under tree, a
whole section, one entry of a list — is replaced by the value of the file it names, so each
package’s node can live beside the package it governs:
tree: "packages/contracts/": { include: packages/contracts/architecture.yaml } "packages/server/": { include: packages/server/architecture.yaml }graph: { include: architecture/graph.yaml }# yaml-language-server: $schema=https://dataquail.github.io/goodbones/schema/architecture-node.schema.jsonmessage: "The server is a hexagon: domain, ports, adapters."layout: openimports: { use: package-floor }children: "src/domain/": { members: [{ use: no-file-system-calls }], children: {} }The path is relative to the file that wrote it, so a package moves with its manifest, and the
root stays the one index of the policy: read it and you have seen every file that takes part.
An included file is YAML or JSON only — a module would leave the manifest readable by one
runtime — and it is replaced whole: nothing may be written beside include, and there is no
merge. A list item naming a file that holds a list is spliced in, so exports or a graph
family can be split across files too.
Patterns in an included file are what they are everywhere: repo-relative, with the aliases
the root declares. defs are one namespace across every file — a fragment the root defines is
there for every included file to use, an included file may carry a top-level defs of its
own, and a name defined twice is refused, naming both files. A file that includes itself, at
any distance, is refused with the chain.
Every error names the file it is in. A decode issue inside an included file reports as
packages/server/architecture.yaml:12:5, relative to the root manifest; a reference that
names a missing file reports at the line that wrote it. The schema for a per-package file is
architecture-node.schema.json, beside the manifest’s: one node of the tree, with the
$schema and defs keys a file of its own may carry at the top.
This works the same in YAML, in JSON, and from a JavaScript module. Rules, probes, the
baseline and limits are all computed on the assembled manifest, so a rule that has drifted
into matching nothing fails at load whichever file it was written in.
What it compiles to
Section titled “What it compiles to”The manifest is lowered to flat rules before anything is matched, one per family per node that states something. Rule names are derived from node paths: the top-level key is slugified and each child key is appended as written, so a violation reports something like
[modules-module/domain/{subdomain}/imports] domain/ holds the model for one subdomain.for a rule declared at "@/modules/{module}/" → "domain/{subdomain}/". Repo-wide entries are
named repo/deny-0, repo/deny-1, … in declaration order. Given a rule name you find the
sentence that caused it by reading down the same path in the manifest.
The lowering is where inheritance is resolved, where a folder’s allowlist becomes a rule over its whole subtree, and where each rule’s probe is generated. It is worth understanding if you are debugging a rule that fires more or less than you expected — Inheritance covers it.