Enforce your architecture's import boundaries — deterministically, in CI, from a file.
You drew the layers once: the domain doesn't import infrastructure, the API doesn't reach into the database directly, modules talk only through their allowed edges. Then, commit by commit, a synchronous call sneaks in, a helper imports the wrong package, and the diagram on the wiki quietly stops being true. archlint keeps it true.
You declare the layers and their allowed dependencies in an architecture.json. archlint
maps every Go, TypeScript, and Python file and every import to a layer and fails the build
on any import that crosses a forbidden boundary — no AI, no probabilistic guess, the same
answer every run.
go install github.com/muhammetsafak/archlint/cmd/archlint@latest{
"module": "github.com/acme/app",
"layers": {
"domain": ["internal/domain"],
"db": ["internal/db"],
"http": ["internal/http"]
},
"rules": {
"domain": [],
"db": ["domain"],
"http": ["domain", "db"]
}
}module— your Go module path, used to turn an import into a repo-relative path.layers— a layer name → the repo-relative path prefixes that belong to it (longest prefix wins, sointernal/db/migrationscan be its own sub-layer).rules— a layer → the layers it is allowed to import. A same-layer import is always allowed;[]means "may import no other layer." Every declared layer needs a rule (be explicit on purpose).
Here domain may import nothing internal, db may import domain, and http may import
both. Any other internal edge is a violation.
For TS/JS, drop module and declare your path aliases instead. archlint resolves relative
imports against the importing file, and aliases the way your tsconfig paths would:
{
"aliases": { "@/": "src/" },
"layers": { "domain": ["src/domain"], "db": ["src/db"] },
"rules": { "domain": [], "db": ["domain"] }
}.ts .tsx .mts .cts .js .jsx .mjs .cjs are all scanned; bare (npm) imports are ignored.
Python needs no module: relative imports (from . import x, from ..db import repo) are
resolved against the importing file's package, and absolute imports map dotted→slash
(app.db → app/db). For a src/ layout, add an alias ({"app/": "src/app/"}).
A single config can govern a mixed Go + TypeScript + Python repo at once.
Architecture Decision Records are where a team writes down why a boundary exists — and then
the architecture.json somewhere else is what actually gets enforced. The two drift apart.
archlint lets the ADR be the rule: drop a fenced ```archlint block into an ADR and its
directives are compiled into the rule set, merged with architecture.json, and enforced in CI.
# ADR 0007: The domain layer is infrastructure-free
The domain holds the business model and may not depend on persistence — so a refactor
can swap the database without touching a single domain type.
```archlint
# declare (or extend) a layer's repo-relative path prefixes
layer domain = internal/domain
layer db = internal/db, internal/dao
# allowed and forbidden edges (same semantics as architecture.json's rules)
allow db -> domain # the repository depends on the model
deny domain -> db # the model must never reach into persistence
```The directives, one per line inside the block:
| Directive | Meaning |
|---|---|
layer <name> = <prefix>[, <prefix>...] |
declare or extend a layer's path prefixes |
allow <from> -> <to> |
<from> may import <to> |
deny <from> -> <to> |
<from> may not import <to> (the default — made explicit) |
# starts a comment (whole-line or trailing); blank lines are ignored. Prose outside the
```archlint block — including an allow-looking sentence — is never parsed.
Point archlint at the ADR directory with --adr-dir (default docs/adr); when it doesn't
exist, archlint behaves exactly as before, so adopting this is opt-in.
archlint check # also reads ./docs/adr if present
archlint check --adr-dir .ssot/adr ./svcHow the two sources merge — the ADR set and architecture.json are unioned:
- layers — an ADR may add prefixes to an existing layer or introduce a new one.
- allow — an edge allowed by either source is allowed.
- deny — authoritative. If an ADR
denys an edge thatarchitecture.json(or another ADR)allows, archlint errors with the ADRfile:lineand the conflicting rule rather than silently picking a winner — the whole point is that the record and the gate can't disagree.
Every violation reports the ADR it came from, so a failing check points straight at the decision it broke:
internal/domain/bad.go:5 domain → db is not allowed (import "…/internal/db") [docs/adr/0007-domain-is-infra-free.md]
archlint check answers a yes/no question: did any import cross a forbidden boundary? Some
architectural risk is more graduated — a layer everything depends on is expensive to change
even when no rule forbids it; a context reached into through the back door is fragile even when
the layer rule "allows" the edge; a dependency that crosses three team boundaries will be slow
to ship even when it compiles. archlint metrics computes these signals on the same import
graph check already builds, prints them, and can gate CI on a breach — without ever changing
a lint verdict. It is opt-in and backward-compatible: a default check run is unchanged, and
metrics with no extra config is a read-only report.
archlint metrics # coupling table for .
archlint metrics --contexts contexts.json ./svc # + bounded-context boundary checks
archlint metrics --teams teams.json --max-instability 0.8For every declared layer, on the cross-layer dependency graph:
| Metric | Formula | Meaning |
|---|---|---|
| Ca (afferent) | count of distinct layers that import this layer | who depends on me (incoming) |
| Ce (efferent) | count of distinct layers this layer imports | who I depend on (outgoing) |
| I (instability) | I = Ce / (Ca + Ce) |
0 = maximally stable (only depended upon), 1 = maximally unstable. A layer with no edges is I = 0 by definition. |
| A (abstractness) | declared (see below) | fraction of the layer that is abstract: 1.0 or 0.0 |
| D (distance) | D = |A + I − 1| |
distance from the "main sequence" (A + I = 1); 0 is ideal, near 1 is the zone of pain (stable + concrete) or uselessness (unstable + abstract) |
archlint has no per-symbol abstract/concrete data, so A is a coarse, explicit proxy: a layer
listed in a context's "abstract" array (see below) gets A = 1.0; every other layer is
A = 0.0. D is reported so you can spot a heavily-depended-upon layer (I → 0) that is also
concrete (A = 0, so D → 1) — exactly the layer a refactor will hurt.
Stable-Dependencies Principle (SDP). An edge L → M is flagged when I(L) < I(M): a
more-stable layer depending on a less-stable one. Stable code should not hang off volatile
code, because the volatile side drags churn into the stable one. SDP breaches gate CI under
--fail-on-violation (on by default).
The instability gate is separate and explicit: --max-instability F (in 0..1) fails the
run when any layer's I exceeds F. It is off unless you pass it (the default is -1).
A bounded context groups layers and publishes a narrow public API/port: the only layers another context is allowed to import. An import that lands on a non-public (internal) layer of another context bypasses the port — a boundary breach, even when the layer rule permits the edge.
{
"contexts": [
{ "name": "ordering", "layers": ["domain", "app"], "public": ["app"], "abstract": ["domain"] },
{ "name": "billing", "layers": ["ledger", "gateway"], "public": ["gateway"] }
]
}layers— the layer names (same names asarchitecture.json) that belong to the context. Contexts must not overlap (a layer has at most one context); layers in no context are ignored.public— the subset oflayersother contexts may import. A layer in the context but absent frompublicis internal. An emptypubliccloses the context: every inbound cross-context import is a breach (declare a port to open it).abstract— optional; marks layers abstract for theAmetric (socontexts.jsondoubles as the abstractness declaration).
A cross-context import to a public layer is fine; to an internal layer it is reported (and gates
CI under --fail-on-violation):
ordering/domain → billing/ledger is not a published port at internal/ordering/domain/order.go:7
Conway's law says a system's structure mirrors the org that built it. The Conway Mismatch Index (CMI) quantifies how much the code's dependencies cut across the org's team boundaries. Declare which team owns each layer:
{ "teams": { "commerce": ["domain", "app"], "platform": ["ledger", "gateway"] } }Let E be the set of distinct cross-layer import edges whose both endpoints' layers are owned by some team, and X ⊆ E the subset whose two endpoints belong to different teams. Then:
CMI = |X| / |E| (0 when |E| = 0)
CMI = 0 means every code dependency stays inside one team; CMI = 1 means every dependency
crosses a team boundary. A per-layer mismatch is reported too: for each owned layer, the fraction
of its owned incident edges (in or out) that cross a team boundary — surfacing the modules whose
dependencies fight the org chart.
A layer owned by no team is excluded from the index (CMI is only defined over owned edges).
When no ownership mapping is present (teams.json absent and no --teams), Conway is simply
skipped — never an error. CODEOWNERS ingestion is a planned follow-up; teams.json is the
default today.
| Flag | Default | Effect |
|---|---|---|
--config |
architecture.json |
the layer/rule config (resolved like check's) |
--adr-dir |
docs/adr |
executable-ADR rules merge first, exactly as in check |
--contexts |
contexts.json |
bounded-context map; absent ⇒ no context analysis |
--teams |
teams.json |
team-ownership map; absent ⇒ Conway skipped |
--max-instability |
-1 (off) |
fail when any layer's I exceeds this 0..1 ceiling |
--fail-on-violation |
true |
exit 1 on an SDP or bounded-context breach |
--format |
auto | text, or github (inline ::error/::notice) — auto-github under Actions |
Exit codes match check's convention: 2 on a usage/config error, 1 when a gated
breach is found (an over-instability layer, or — under --fail-on-violation — an SDP or context
violation), 0 otherwise. The default run with no contexts, no teams, and no gate is purely
observational and always exits 0.
archlint metrics examples/metrics-sample
# Coupling table (Ca/Ce/I/A/D per layer), a cross-context bypass
# (ordering/domain → billing/ledger), and a Conway Mismatch Index of 0.50.
# `check` on the same tree is clean — the layer rules permit the edge; metrics is what catches it.archlint check # uses ./architecture.json, scans .
archlint check ./service # scans ./service, finds ./service/architecture.json
archlint check --config arch.json ./svcTry the bundled examples, each of which plants a deliberate violation:
archlint check examples/sample # Go
# examples/sample/internal/domain/bad.go:5 domain → db is not allowed (import "…/internal/db")
archlint check examples/ts-sample # TypeScript (resolves the "@/" alias)
# examples/ts-sample/src/domain/bad.ts:4 domain → db is not allowed (import "@/db/repo")
archlint check examples/py-sample # Python (relative import)
# examples/py-sample/app/domain/bad.py:3 domain → db is not allowed (import "..db.repo")
archlint check examples/adr-sample # the deny rule lives in an ADR (executable ADR)
# examples/adr-sample/internal/domain/bad.go:6 domain → db is not allowed (import "…/internal/db") [docs/adr/0001-domain-is-infra-free.md]
archlint metrics examples/metrics-sample # coupling + DDD + Conway (check on this tree is clean)
# ordering/domain → billing/ledger is not a published port ... / Conway Mismatch Index: 0.50Use the bundled action — under GitHub Actions, violations show up as inline annotations on the PR diff (file + line) and a failing check blocks the merge:
- uses: muhammetsafak/archlint@v0.1.0
with:
config: architecture.json # optional
dir: . # optionalOr run the binary directly:
- uses: actions/setup-go@v5
with: { go-version: stable }
- run: go install github.com/muhammetsafak/archlint/cmd/archlint@latest
- run: archlint checkarchlint check auto-detects GitHub Actions and emits ::error annotations plus a one-line
fix hint; pass --format text to force plain output, or --format github to force it on.
Both check and metrics share one convention:
| Code | check |
metrics |
|---|---|---|
0 |
no boundary violations | no gated breach |
1 |
one or more imports cross a forbidden boundary | a gated breach (over-instability layer, or — under --fail-on-violation — an SDP or bounded-context violation) |
2 |
usage / config error | usage / config error |
- Go, TypeScript/JavaScript, and Python. Go imports are read with the standard library's
go/parser(exact). TS/JS and Python imports are extracted with regex scanners covering the standard forms (import … from,export … from,require(), dynamicimport(); Pythonimport a.bandfrom … import, including relative imports) — these are not full parsers, so an import-like string inside a comment or string literal can be a false positive. - Import boundaries, plus structural metrics.
checkgoverns the dependency graph between layers;archlint metricsadds coupling (Ca/Ce/I/A/D), bounded-context, and Conway signals on that same graph (see Coupling metrics & Conway/DDD analysis). Both work on the static import graph: detecting a synchronous call where an async one was required, or a direct DB query across a domain boundary, comes from correlating runtime traces — a later phase. Abstractness (A) is a declared proxy, not symbol-level analysis; team ownership is ateams.json(CODEOWNERSingestion is a follow-up). - Dependency-free (Go stdlib only). Rules come from
architecture.jsonand/or```archlintblocks in your ADRs (see Executable ADRs); both are parsed with the standard library. JSON today; YAML support is a follow-up (it adds one dependency). - Deterministic by design. The point is a guardrail you can gate CI on: same diff, same verdict, no model in the loop.
MIT