# Architecture As Code

> Stack-agnostic pattern for declaring and enforcing component boundaries via per-module architecture configs merged into import-graph linter rules. TRIGGER when designing/auditing dependency-rule enforcement, deciding what a per-module file should say, placing a rule, debugging a forbidden edge, or extending the assembler. Consume explicit `Enforcement` handoffs from `architecture-guidelines` or `morphogenetic-architecture` by turning enforceable dependency constraints into architecture config rules. SKIP routine edits inside a governed module. For stack implementations see `architecture-as-code-javascript` or `architecture-as-code-python`.

- Skill: `l-gevity/architecture-as-code` (Agent Skill)
- Install (CLI): `npx skillmds@latest add l-gevity/architecture-as-code`
- Raw SKILL.md: https://api.skillmd.com/api/skills/l-gevity/architecture-as-code/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: l-gevity (https://skillmd.com/u/l-gevity)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/l-gevity/architecture-as-code

---


# 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-boundaries` for JS, `import-linter` for Python). Does NOT
> prescribe what the graph should look like — that's `architecture-guidelines`
> and `morphogenetic-architecture`. Does NOT govern code style — that's your
> project's coding-style convention.

> **Input Contract.** Consume only explicit `Enforcement` handoffs from
> `architecture-guidelines` or `morphogenetic-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, return
> `Decision: Defer` or `Reject rule`.

> **Core Directives**
>
> 1. **Module = directory** (or a single-file unit for a facade). Files belong
>    to a module by living under its path / dotted path.
> 2. **One optional config file per module** — declaratively lists this
>    module's components and its outbound rules. Repo root has one too, same
>    structure.
> 3. **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>-x` names, and the anonymous `*`
>    may appear; any other module name is a violation.
> 4. **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.
> 5. **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 in
>    `components`. This holds *inside* a module, where the siblings it must
>    not shadow sit at the same depth.
> 6. **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.
> 7. **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.
> 8. **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.
> 9. **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-` for `core-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.
- [ ] `components` ordered 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, `mode`
      included.
- [ ] 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.

