Architecture-as-Code (Pattern)

SkillFiles & storage

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`.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Architecture-as-Code (Pattern) skill

What this skill tells your AI

The instructions your AI receives, as published by l-gevity/l-gevity-skills in .agents/skills/architecture-as-code/SKILL.md and read by ahel’s review.

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

FieldRequiredPurpose
nameyesModule id referenced from forbidden edges.
patternyesSelector for the module's files (stack-specific syntax).
mode / singlenoSwitches from ancestor matching to exact-file / exact-module matching (a facade, a repository-root file).
capturenoPath-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 acceptsMeaning
"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 typeLives in
Afferent ("who may import me?")Higher level (composer).
Efferent ("what may I import?")Own file.
Cross-module sibling-isolationHigher level (composer).
Internal layeringOwn file.
Sub-tier sibling-isolationOwn 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-patternFix
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:

StackConfig fileLint toolSkill
JavaScripteslint.architecture.mjsESLint + eslint-plugin-boundariesarchitecture-as-code-javascript
Pythonarchitecture.tomlimport-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.

Signals

GitHub stars
43
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
architecture-as-code
Source
github.com/l-gevity/l-gevity-skills