Architecture-as-Code (Pattern)
Scope. Stack-agnostic pattern: file format, discovery, assembly, and rule-placement discipline that turn a directory tree into an enforced dependency graph. Implementation is delegated to a per-stack linter (e.g.
eslint-plugin-boundariesfor JS,import-linterfor Python). Does NOT prescribe what the graph should look like — that'sarchitecture-guidelinesandmorphogenetic-architecture. Does NOT govern code style — that's your project's coding-style convention.
Input Contract. Consume only explicit
Enforcementhandoffs fromarchitecture-guidelinesormorphogenetic-architecture(or an equivalent architecture decision). Translate the named constraint into components and forbidden edges. Do not restate or reinterpret architecture doctrine here; if a constraint is not enforceable as an import/dependency rule, returnDecision: DeferorReject rule.
Core Directives
- Module = directory (or a single-file unit for a facade). Files belong to a module by living under its path / dotted path.
- One optional config file per module — declaratively lists this module's components and its outbound rules. Repo root has one too, same structure.
- A module knows itself, not its context. Its own file governs internals (sub-tiers, layering) and outbound dependencies ("what I import") — never inbound ("who imports me") or its place in the wider system, which it does not and should not know. Mechanically: only
<own-prefix>-*, specific<own-prefix>-xnames, and the anonymous*may appear; any other module name is a violation.- Composition lives on the level that does the composing. Constraints between a module and its peers (afferent — "who may import me" — and cross-module sibling-isolation) live higher up. Constraints among a module's own sub-tiers (internal layering, sub-tier sibling-isolation) live in its own file. Higher-level rules accumulate.
- Every module with rules ends with a catch-all bucket. Files matching no component are invisible to the linter and silently bypass forbidden edges. A
<dir>/**(or whole-package) entry MUST be last incomponents. This holds inside a module, where the siblings it must not shadow sit at the same depth.- The same catch-all at the repository root inverts. Most import-graph linters match a pattern against a file's path ancestors by default, so a root-level
**matches at the shallowest segment and claims files that deeper, more specific components already own. Declaration order does not break the tie — the catch-all wins from last position against dozens of specific components. Declare the root's real files explicitly with exact-file matching (mode: 'file',single = true) instead.- Coverage is two independent gates: the registry and the rule's file scope. A registry that classifies every file proves nothing when the emitted rule block runs on a subset of the linted source set. The emitted rule MUST cover every linted source file. Narrowing that scope is a bypass, and it is invisible — the tool reports zero violations either way.
- A file-existence rule is what catches an undeclared directory. Dependency rules judge edges; a file with no imports, or one reached only from a script tag, template, or config, has no edge to judge. Emit the stack's "every file must match a declared component" rule at error severity next to the dependency rules.
- Recursion via discovery. Assembler walks the tree; deeper files are processed first.
1. File schema
Each architecture config declares two optional top-level arrays:
components: [ ... ] # one entry per module
forbidden: [ ... ] # one entry per dependency edge
Concrete encoding (.mjs, .toml, .yaml, …) is stack-specific. Schema is
not. Most modules don't need their own file — they're declared once in the
components list higher up in the tree.
[!NOTE]
<own-prefix>is the shared prefix of a module's component names — e.g.core-forcore-facade,core-tier1,core-other. Single-component modules just use the bare name.
2. Components — modules declared as patterns
| Field | Required | Purpose |
|---|---|---|
name |
yes | Module id referenced from forbidden edges. |
pattern |
yes | Selector for the module's files (stack-specific syntax). |
mode / single |
no | Switches from ancestor matching to exact-file / exact-module matching (a facade, a repository-root file). |
capture |
no | Path-segment captures for parametric rules. |
Order matters within a file: narrowest first (file-mode → sub-directories → catch-all). Across files: deeper-first (so a module's own file overrides its ancestor's catch-all).
[!IMPORTANT] Matching mode decides what ordering can do. Order only breaks ties between candidates the matcher considers together. Under the default ancestor matching a shallow pattern wins at its own shallow segment, before any deeper component is tried — so a shallower entry declared last still beats a specific entry declared first. A file that a shallower pattern would otherwise swallow needs exact-file matching, not a better position in the list. Repository-root files always do.
3. Forbidden — dependency edges
{ from: <spec>, to: <spec>, except?: [...], except_to?: [...], why: '...' }
from / to accepts |
Meaning |
|---|---|
"service" |
Single module name. |
["app", "service"] |
Multiple module names. |
"*" |
Every registered module. |
"core-*" |
Prefix wildcard — every module starting with core-. |
{ captured = ... } |
Parametric (uses captures from a capture-enabled component). |
except subtracts from a wildcard from; except_to from a wildcard to.
Strings in either may be prefix wildcards. why is the violation message
emitted to developers.
Canonical examples (encoding-neutral)
# Afferent — higher level. Only the orchestrator may import the facade.
{ from: '*', except: ['orchestrator', 'core-*'], to: 'core-facade',
why: 'Only the orchestrator may import the core facade.' }
# Efferent — own file. Self-contained.
{ from: 'core-*', to: '*', except_to: ['core-*'],
why: 'Core purity: no imports outside the core module.' }
# Internal layering — own file. Sub-tier names share the prefix.
{ from: 'core-tier3', to: 'core-tier1',
why: 'Tier 3 must go through tier 2; direct tier-1 access is forbidden.' }
# Parametric — higher level. Sibling sub-domains may not import each other.
{ from: { type: 'domain-handler', captured: { domain: '*' } },
to: { type: 'domain-handler', captured: { domain: '!{from.captured.domain}' } },
why: 'Cross-domain import: extract shared helpers to a sibling shared/ module.' }
4. Where each rule lives
| Rule type | Lives in |
|---|---|
| Afferent ("who may import me?") | Higher level (composer). |
| Efferent ("what may I import?") | Own file. |
| Cross-module sibling-isolation | Higher level (composer). |
| Internal layering | Own file. |
| Sub-tier sibling-isolation | Own file. |
Higher-level rules accumulate. Place each rule where the composition it expresses lives — sub-tier sibling-isolation in the module's own file (it composes its sub-tiers); encapsulation between the module and its peers higher up (where the module is composed with peers).
[!IMPORTANT] A module's own file MUST reference only its own-prefix names (
<own-prefix>-*or<own-prefix>-x) and*. Naming any other module is a violation — that knowledge belongs higher up.
5. The assembler
A small script invoked at lint time (CI / pre-commit / editor). Concept is identical across stacks; encoding is not.
# 1. Discover — recursive walk, skipping the configured ignore-list
# (vendor / build / cache / virtualenv dirs).
files = walk(REPO_ROOT, name = "<config-filename>")
files.sort(by_depth, descending = True) # deeper-first
# 2. Concat
components = []
forbidden = []
for f in files:
data = parse(f)
components.extend(data.components)
forbidden.extend(data.forbidden)
# 3. Expand wildcards against the live registry.
# Turn a spec ('foo' | 'foo-*' | '*' | list | parametric) into
# a concrete list of component names, with `except` subtracted.
names = [c.name for c in components]
def expand(spec, except_):
if spec is parametric: return spec # passthrough
types = resolve_to_names(spec, names) # handles *, prefix-*, lists
if except_: types = types - resolve_to_names(except_, names)
return types
# 4. Emit the stack's native lint config from `components` + expanded `forbidden`.
# 4a. Forward EVERY field the component schema defines — name, pattern,
# mode / single, capture. A field the emitter drops is unexpressible in
# every architecture file in the repo; `mode` is the usual casualty, and
# it is the one the repository root needs (Directive 6).
# 4b. Scope the emitted rule block to the ENTIRE linted source set — never a
# subdirectory allowlist (Directive 7).
# 4c. Emit the file-existence rule ("every file matches a declared
# component") at error severity alongside the dependency rules
# (Directive 8).
# 5. Invoke the stack's lint tool against the emitted config.
The discovery + merge + wildcard-expansion pipeline is the same everywhere. Steps 4 and 5 are the only stack-specific parts.
[!NOTE] The generated lint config is a build artifact — git-ignored, regenerated on every run. The source of truth is the per-module architecture files.
6. Timing — rules first for new modules
When introducing a new module on a stack that supports this pattern, write its architecture file (plus any afferent rules in the parent) before its implementation code. Catching the first wrong import on day 1 is the point; retrofitted rules either rubber-stamp accidents or trigger unbounded refactors. The PR that adds the module contains the rules first, the implementation second.
Spike escape-hatch. Code explicitly marked as a spike or throwaway prototype may skip rules. The spike must be deleted or rewritten rules-first before merging to main — a spike that crosses the merge boundary ungoverned becomes the next round of "we'll add the rules later" code that never gets the gate.
7. Anti-patterns + pre-merge audit
| Anti-pattern | Fix |
|---|---|
| A module's own file names another module. | Move higher, or rewrite with <own-prefix>-* + *. |
| Hardcoded list of "all other modules". | Use '*' + except / except_to. |
| Renaming a module without updating consumers. | Use prefix wildcards (<prefix>-*) so renames stay local. |
| Module has rules but no catch-all bucket. | Add the whole-module entry as the last components row. |
| Dynamic / unresolved imports evade rules. | Make imports static and resolvable, or document the loophole and ban the dynamic style where possible. |
| Rule block scoped to a subset of the linted source set. | Apply the rule to every linted source file; the registry cannot fire on a file the rule never sees. |
A ** catch-all at repository root in ancestor/folder matching mode. |
It captures files at the shallowest segment and overrides specific components regardless of order. Declare the root's files with file-mode components instead. |
| Relying on dependency rules to catch an undeclared directory. | Use the file-existence rule; a file with no imports has no edge to judge. |
| Assembler maps a subset of the schema's component fields. | Forward every field, mode included — a dropped field silently deletes that part of the schema. |
Before merge:
- No other-module name appears in any module's own architecture file.
-
componentsordered narrowest-first; constrained modules end with a catch-all. - The emitted rule block's file scope equals the linted source set.
- Every file at repository root belongs to a declared component.
- The assembler forwards every field the component schema defines,
modeincluded. - Lint violation count matches baseline (or new violations reflect intentional changes).
[!IMPORTANT] A passing lint is not evidence of coverage. The violation count does not move when a directory the linter cannot see is added — it stays at zero because the question was never asked. The scope, root-file, and assembler-field checks above all fail this way, so verify them against the generated config and a positive signal: count the files the emitted rule classifies and compare that with the file count of the linted source set. Reading the lint result proves nothing about either gate.
[!NOTE] The "no other-module name" check is mechanical — a small AST/TOML walk over each architecture file could enforce it as a meta-lint. Until then, the manual checklist is the gate.
8. Output Contract
When designing or auditing rules, emit a coder-facing decision record:
Scope: <repo / package / module path>
Stack: JavaScript | Python | Other
Input: <Enforcement handoff consumed, or none>
Decision: Add config | Update config | Reject rule | Defer | Blocked
Config files: <eslint.architecture.mjs / architecture.toml / generated config>
Components: <component names or patterns added/changed>
Forbidden edges:<from -> to rules added/changed>
Verification: <lint command / meta-lint / Not run + reason>
Next action: <specific edit, rule, test, or owner question>
9. Implementations
This skill defines the pattern. Concrete implementations live in sibling skills:
| Stack | Config file | Lint tool | Skill |
|---|---|---|---|
| JavaScript | eslint.architecture.mjs |
ESLint + eslint-plugin-boundaries |
architecture-as-code-javascript |
| Python | architecture.toml |
import-linter (over Grimp) |
architecture-as-code-python |
Adapting to a new stack: pick an import-graph linter that supports forbidden edges between named module sets, then write a small assembler that emits its native config. Everything in §§ 1–6 transfers; only step 4 of §5 (emit) and step 5 (invoke) are stack-specific.
10. See also
architecture-guidelines— first-principles rules this pattern enforces.morphogenetic-architecture— the placement, locality, interface-direction, and static-topology rationale this enforces.defect-shift-left— §6.2 (ADR → executable architectural rule) names this pattern.