Structure — building blocks and the coupling you forbid
Building blocks and the dependencies between them — derived from the decisions, not authored ahead of them. Most documentation starts here and most of it dies here, because the part everyone draws is the part the code already tells you.
Spine concern · arc42 §5
Structure is the set of building blocks, each with a single responsibility, and the dependencies allowed between them. Document one level and add a second only where a block is opaque to the people who change it. The load-bearing column is what a block may not depend on: it is the only part a build check can enforce, and therefore the only part still true in a year.
The Basics
Durable. What this concern is responsible for, regardless of decade or stack.
Structure is derived. The blocks exist because decisions were made, and if you author the structure first you are making those decisions implicitly, with a diagram as the only record. Which is why this concern has no machine layer and why the useful half of it is not the boxes but the rules about what may not touch what.
One level, and the test for a second
Six to nine blocks at the top level, and stop. A second level earns its place in exactly one situation: a block is opaque to the people who have to change it, so its internals need a map that the code does not readily give them.
Three levels drawn and none maintained is the usual outcome, and the deeper the level the faster it rots — because the deeper the level, the more of it is recoverable from the code anyway. Depth is not thoroughness; it is a maintenance obligation you took on in exchange for something a directory listing already provides.
The corollary is that decomposition stops when the reason for decomposing stops. If nobody can say which question the third level answers, it is not documentation, it is diagram habit.
A block is a responsibility, not a folder
Write each block's responsibility in one sentence with no "and" in it. If the sentence needs an "and", you are looking at either two blocks or a boundary drawn along the wrong axis, and both are cheaper to fix on the page than in the repository.
The names that fail this test are always the same: utils, common, core, shared, helpers. Each names a location rather than a responsibility, which means nothing can be forbidden from depending on it and everything eventually does. A block nobody can be prevented from importing is not a block; it is a global variable with a directory.
Prefer names from the domain, and take them from the vocabulary concern rather than inventing a synonym here. Two names for the same thing across the spine is a small cost per document and a large one for anyone — or anything — searching the repository for where something lives.
The forbidden column is the artefact
What a block depends on is derivable: an import graph, a dependency-cruiser run, a trace. Tooling produces it faster than you can type it and cannot be out of date. What a block may not depend on is not derivable from anything, because it is intent — the boundary you are choosing to hold.
That column is also the only one a check can enforce. "The order domain must not import the payment provider SDK" is an import-boundary test, a lint rule, an ArchUnit test: a sentence that can fail a build. "Orders depends on payments" cannot fail anything; it merely describes what is.
So every boundary that matters belongs in guardrails with a check behind it, and the structure page names which guardrail defends which rule. A boundary defended only by a diagram is a boundary that will be crossed by whoever is under deadline pressure on a Friday, in good faith, without ever seeing the diagram.
When the page and the code disagree, the code is right
Structure is the concern most likely to be confidently wrong, and a wrong structure page is worse than no structure page: it costs a reader the time to read it plus the time to discover it lied, and it burns the credibility of every other page in the spine.
Split the page accordingly. Generate what is derivable — the picture, the actual dependency graph — and author only the intent: responsibilities, ownership, the forbidden edges, and the decision behind each one. Authored content is small enough to keep true; generated content cannot be stale.
Cite the decision that drew each boundary. A rule with no ADR beside it looks arbitrary, and arbitrary rules get deleted by the next person who finds one inconvenient. The citation is what turns "we do not do that" into "here is what that boundary is protecting, and here is what it cost us to have it".
A block entry is complete when
- It has a one-sentence responsibility
- No "and". A responsibility that needs a conjunction is two blocks, or one boundary drawn along the wrong axis.
- It names what it may not depend on
- The only column tooling cannot derive and a build check can enforce. Without it the row is a description, not a rule.
- It names an owner
- A team, not a person. A block owned by three teams is one block too big; a block owned by nobody drifts fastest.
- It cites the decision
- One ADR id per boundary that matters, so the rule can be argued with, and retired, rather than quietly ignored.
- It says whether the boundary is enforced
- A guardrail id, or an honest blank. A blank is a known risk; a missing column is a false sense of safety.
- It states what it owns in the data
- Which tables, topics or buckets are this block's. Data coupling is the coupling that survives every code refactor.
In the spec
What the concern looks like once it is written down. No machine layer here — this one is derived from the decisions and quality goals it cites, so the artefact is the page a person reads.
# 7. Structure
Derived from the decisions. The picture is generated from the build graph
(`make deps`); this page is the intent.
| Block | Responsibility | Owner | May not depend on | Enforced by |
|---|---|---|---|---|
| `orders` | Owns the order lifecycle from cart to fulfilment. | orders | payment SDKs; `reporting` | G-2 |
| `payments` | Adapts one payment provider behind our own interface. | orders | `orders` internals (public API only) | G-2 |
| `pricing` | Computes what a customer is charged. The product. | pricing | `orders` (orders calls pricing, never the reverse) | G-5 |
| `identity` | Wraps the identity vendor; issues our own session. | platform | domain blocks | G-6 |
| `reporting` | Reads the nightly snapshot. Never the write model. | data | `orders`, `pricing` tables | G-7 |
| `platform` | HTTP, config, logging, one job runner. No domain logic. | platform | every domain block | G-8 |
## Data ownership
| Block | Writes | Read by |
|---|---|---|
| `orders` | `orders`, `order_items`, `fulfilments` | `reporting` (snapshot only) |
| `pricing` | `price_rules`, `quotes` | `orders` via API |
| `identity` | `sessions` | nobody |
Second level: `pricing` only (arch/07a-pricing.md). It is the one block
opaque to the people who change it. ADR-0031 explains why it is one block
and not three.# Structure has no spine.yaml key: the blocks are derived, and the
# only enforceable part of the page is already a guardrail. The rule
# names the blocks; checks names the goal the boundary protects.
guardrails:
- id: G-2
checks: [QG-2]
kind: build
rule: >
The orders domain must not import a payment provider SDK.
All provider calls go through src/payments (ADR-0011).
threshold: "0 violations"
enforced_by: eslint boundary rule in CI
on_breach: fail
- id: G-7
checks: [QG-1]
kind: build
rule: >
reporting must not read orders or pricing tables directly.
Snapshot only (ADR-0022) — keeps reporting load off checkout.
threshold: "0 violations"
enforced_by: dependency-cruiser + a schema-grant test
on_breach: fail
- id: G-8
checks: [QG-3]
kind: build
rule: >
platform must not import any domain block. Enforces the
direction of dependency so platform stays releasable alone.
threshold: "0 violations; allowlist must stay empty"
enforced_by: ArchUnit test in CI
on_breach: failThe Current
Reviewed 2026-09-09How it actually plays out in production now. Dated, because this is the part that decays.
The interesting boundaries have moved inside the deployable unit. Once the default shape is a modular monolith on one managed database, the boundaries that used to be network calls — with all the friction that implied — are module boundaries that nothing physically prevents anyone from crossing. Cheaper to run, and entirely dependent on enforcement.
A module boundary needs the check a network boundary got for free
A service boundary was enforced by the network whether you liked it or not: you could not reach into another team's tables from inside your process. A module boundary is enforced by an import statement nobody blocked, which means the discipline that was structural is now procedural.
This is the trade the modular monolith asks you to make, and it is usually the right trade — provided the check exists on day one. Add the boundary test with the first boundary, not after the first violation, because the first violation is never one import; it is nine, spread across four merged pull requests, and by then the cheap fix is gone.
The signal to watch is not the count of violations but the count of exceptions in the rule's allowlist. A boundary with six exceptions is a boundary that has already been re-drawn without anyone deciding to re-draw it.
Data coupling outlives code coupling
Two modules sharing a table are coupled far more tightly than two modules sharing a function, and no amount of code restructuring touches it. The schema is the contract, migrations are the release process, and whoever writes to a table owns every reader of it whether they know them or not.
So record data ownership per block: the tables, topics and buckets it writes. It takes a line per block and it answers the question that decides how expensive a future split will be — which is exactly the question a diagram of code dependencies cannot answer.
The common failure is a table that two blocks write. It reads as convenient until the day one of them needs a schema change, at which point both are in the migration and neither can ship independently. If two blocks must write the same data, one of them owns it and the other goes through an interface — the same rule the context concern applies to external systems, applied inward.
Generate the picture, author the rules
C4 remains a good notation and there is no reason to replace it; service maps and dependency graphs come free with any competent observability or build tooling. Let the tooling own the picture, and let it be the reason nobody hand-maintains a box diagram again.
Then spend the time saved on what tooling cannot infer: why a boundary exists, who owns each block, which dependencies are forbidden, and which check defends them. Roughly one page. That page is the artefact; the diagram is an illustration of it.
Team shape shows up here whether you write it or not
Blocks drift toward the shape of the teams that maintain them, and the drift is invisible until an ownership question forces it into the open. Writing the owner beside each block turns Conway's law from something that happens to you into something you can look at.
Two patterns are worth naming. A block owned by three teams is either one block too big or three boundaries not yet drawn, and it will be the slowest thing in the repository to change. A block owned by nobody — the shared library, the internal platform, the utils package — accumulates whatever anyone needs, and it accumulates fastest, because there is no owner to say no.
Future-ready
What changes when agents write and operate the code. Opinionated on purpose.
Boundary drift used to be paced by human typing speed. It is not any more: an unenforced boundary can be crossed a dozen times in an afternoon by an agent doing exactly what it was asked, in changes that read well and pass their tests. Enforcement is the only part of structure that scales with generated code, which makes the forbidden column the whole point of the page.
The plausible import is the new drift
Ask an agent for a feature and it will reach for whatever makes the feature work, which is frequently a direct import across a boundary it has no way to see. The diff is competent, the tests pass, and the boundary is gone — not through carelessness but because the rule existed only as prose in a document the agent was not given.
Reviewing for this by hand does not scale. Fifty small correct-looking changes a week is exactly the volume at which humans stop noticing architectural drift and start noticing only whether the change works. A build check does not get tired, and it fails the pull request rather than the retrospective.
So the rule of thumb inverts: previously you documented the boundary and hoped for discipline; now you write the check first and let the page describe what the check defends. If a boundary is not worth a check, say so on the page and treat it as a preference.
The block list is where code goes
"Where does this belong?" is the question an agent answers worst and answers silently. With no map it invents a location — a new folder beside the old one, a second http client, a helper module that duplicates something three directories away — and each invention is individually defensible and collectively a mess.
A block list with a path, a one-sentence responsibility and an owner per row answers the question in the terms the agent is already working in. It is small, it fits in every session, and it converts a guess into a lookup. Pair it with the forbidden column and the agent can check its own plan before writing: this change needs orders to call payments, which G-2 forbids, so either it goes through the adapter or it needs a decision.
Agents are also good at the reverse audit, which is the pass nobody schedules: blocks in the repository that are absent from the page, rows whose responsibility no longer matches what the code does, and forbidden edges with no check behind them.
Cheap code cuts both ways on block size
Generating a small, well-bounded module is nearly free now, which removes the oldest excuse for putting something in the wrong place. The same economics also make the opposite failure more likely, because an agent working inside a file keeps working inside that file, and a module that was the right size in March is four thousand lines in June with no single change that looks wrong.
Express the limit as a check rather than as review vigilance: a size or file-count budget per block, an ownership rule, a fan-in threshold. Whatever the shape, it should be the sort of thing that fails a build, because the growth arrives as many small increments and no reviewer sees the curve.
Draw the blocks that confine a failure
When writing code is cheap, structure's remaining job is confinement: what can a change in here reach, what can a failure in here take down, and what data can it see. Those questions produce different boundaries than tidy layering does — a tenancy boundary, a blast-radius boundary around anything holding money or personal data, a boundary around the code an agent is allowed to modify unattended.
That last one is new and worth stating explicitly. A repository where a large share of the code is generated benefits from saying which blocks are open to that and which require a human author — the migration path, the permission model, the payment adapter. It is a structural statement about risk, it belongs beside the block it constrains, and like every other boundary on this page it is worth only as much as the check behind it.
How this concern fails
Named, because a failure mode you can name is one you can spot in your own repository before it costs you a quarter.
Three levels drawn, none maintained
The top level was useful, the second is half true, the third describes a refactor from two years ago. Depth was mistaken for thoroughness, and the maintenance cost was never priced.
The block called utils
It names a location rather than a responsibility, so nothing can be forbidden from importing it. Within a year it holds business rules and every block depends on it.
No forbidden column
Every dependency is described and none is prohibited, so the page duplicates what the import graph already shows and forbids nothing. Nothing on it can fail a build.
The boundary defended only by a diagram
Everyone agrees the rule exists; no check knows about it. It survives until the first deadline, and its collapse is discovered later during an unrelated incident.
The shared table
Two blocks write the same data because it was convenient. They are now in each other's migrations permanently, and no code-level restructuring can separate them.
The unowned block
Shared library, internal platform, common package. No owner means nobody to say no, so it accumulates whatever anyone needs and becomes the slowest thing in the repository to change.
Go deeper
Frequently asked
What is a building block view?
A decomposition of the system into the blocks that make it up, each with a responsibility and its dependencies. In practice, one level of six to nine blocks is enough, and a second level is worth writing only where a block is opaque to the people who change it. The half that pays for itself is not the list of dependencies — tooling derives that — but the list of dependencies you forbid.
How many levels of decomposition should you document?
One, plus a second level only where a block's internals are genuinely unclear from the code. Deeper levels rot fastest precisely because they are the most recoverable from the repository, so each one you author is a maintenance obligation traded for information a directory listing already provides. If nobody can name the question a level answers, do not write it.
How do you stop an architecture document from going stale?
Split it into what can be generated and what must be authored. Generate the picture and the actual dependency graph from the build or from traces, so it cannot be out of date. Author only the intent — responsibilities, owners, forbidden dependencies, the decision behind each — which is small enough to keep true. Then bind every boundary that matters to a build check, so the code cannot silently diverge from the page.
How does this map to arc42 section 5?
It is arc42 section 5 with the emphasis moved and one column added. arc42 asks for a white-box decomposition with black-box descriptions of the contained blocks, level by level. The Spine keeps that, caps the default at one level, and requires each block to state what it may not depend on, who owns it, which data it writes, and which guardrail enforces the boundary — because the enforceable part is the part that stays true.
Is this the same as a C4 container diagram?
C4 is a notation for the picture and a good one; keep it. The Spine's structure concern is the table beside the picture, and the difference is intent: C4 shows what depends on what, the table records what must not, who owns each block, and the check that defends the rule. Generate the C4 view if your tooling can, and author the table.
Why does structure have no spine.yaml key?
Because the blocks are derived from the decisions and the only enforceable part of the page is already a guardrail entry. A machine layer for structure would be a second copy of the dependency graph, which the build already knows, plus a duplicate of the rules that live in guardrails. Decision-first means the concerns with a machine layer are the ones something acts on directly; structure is read by people and enforced through guardrails.
How do you keep AI coding agents from breaking module boundaries?
Give them the map and enforce the rule. The map is the block list with a path, a one-sentence responsibility and an owner per row, small enough to sit in every session — without it an agent invents a location rather than asking. The enforcement is an import-boundary or ArchUnit check that fails the build, because at fifty plausible-looking changes a week human review stops catching drift and only catches breakage.
The other eleven concerns
Pages are being written one at a time. The ones without a page yet are still in the template, with prompts instead of prose.
- Decisions
- Quality goals
- Guardrails
- Constraints
- Context & scope
- Solution strategy
- Behaviour
- Deployment & operations
- Cross-cutting concepts
- Risks & debt
- Vocabulary
Structure
Take the template, not the idea
The zip, the single-file variant, the schema and the agent bundle. Nothing behind an email address.
Free to fork, modify and use commercially. No attribution required.