Skip to content
Muhammet Şafak
tr
Journal 2 min read

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/parser gives 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.

Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind