archlint: Enforcing Architecture Boundaries in CI, Deterministically
Architecture decisions stay true on the wiki and rot in the code. I built archlint: a standalone Go CLI that enforces the layer boundaries in architecture.json on every commit — with go/parser, no model, in CI. Here's why I built it this way.
I wrote separately about why architecture decisions rot: an ADR says “the domain must not import infrastructure,” and two years later you find it importing the database in a hundred places — because nothing was watching the rule. This post is the tooling side of that problem: a small, standalone Go CLI — archlint.
architecture.json: put the rule in code
You declare the layers and their allowed dependencies in one file:
{
"module": "github.com/acme/app",
"layers": {
"domain": ["internal/domain"],
"db": ["internal/db"],
"http": ["internal/http"]
},
"rules": {
"domain": [],
"db": ["domain"],
"http": ["domain", "db"]
}
}
domain may import nothing internal, db may import only domain, http may import both. Any other internal edge is a violation. A same-layer import is always allowed; [] means “may import no other layer.”
How it works
archlint check extracts every .go file’s imports with the standard library’s go/parser — no regex guessing, no compilation needed, exact. It maps each file and each import (after stripping the module path) to a layer, and reports the edge that breaks the rule, with file and line:
$ archlint check examples/sample
Scanned examples/sample against examples/sample/architecture.json — 2 layer(s).
1 boundary violation(s):
internal/domain/bad.go:6 domain → db is not allowed (import "github.com/acme/app/internal/db")
A violation exits 1 → the build goes red. The offending import is caught before it reaches main, not in an archaeology session two years later.
Design decisions
- Deterministic, no model. The whole point is a guardrail you can gate CI on: same diff, same verdict, no model in the loop. That’s the difference from telling an LLM to “review the architecture.”
- Zero dependencies (Go stdlib). The config is JSON for now — YAML adds a dependency, so it’s a follow-up.
- Go first.
go/parsergives Go imports exactly; a sharp MVP is done well in one language. TypeScript and Python, via their own parsers, are the next step.
In CI
- run: go install github.com/muhammetsafak/archlint/cmd/archlint@latest
- run: archlint check
Or with the packaged GitHub Action:
- uses: muhammetsafak/archlint@v0.1.0
Limits — the honest list
- Go, for now. Imports are read with
go/parser(exact); TS/Python via their own parsers is next. - Import boundaries, for now. It governs the dependency graph between layers. A synchronous call where an async one was required, or a direct DB query across a domain boundary, needs correlating runtime traces — a later phase.
- JSON config (YAML is a follow-up). The deterministic design is deliberate: it has to be gate-able.
Try it
Write your architecture.json, run archlint check — or run archlint check examples/sample to watch it catch a planted violation. If you tell me which boundary you want next (TS? Python?), I’ll queue it.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.