# archlint: Enforcing Architecture Boundaries in CI, Deterministically

> Architecture decisions stay true on the wiki and rot in the code. archlint enforces the boundaries in architecture.json on every commit, no model.

- Published: 2026-06-18
- Category: Journal
- Tags: Go, GitHub Actions, Git
- Reading time: 2 min read
- Source: https://muhammetsafak.com/blog/archlint-enforcing-architecture-boundaries-in-ci/
- Language: en-US
- Author: Muhammet Şafak

---
I wrote separately about [why architecture decisions rot](https://sade.dev/en/journal/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:

```json
{
  "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 the imports of every Go, TypeScript and Python file. Go imports are read 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:

```text
$ 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 is exact, TypeScript and Python are best-effort.** `go/parser` gives Go imports exactly. TypeScript/JavaScript and Python imports are extracted with regex scanners that cover the standard forms, so they are not full parsers.

## In CI

From here on it's the usual two lines of [the workflows I build with GitHub Actions](/blog/did-someone-say-github-actions/); all `archlint` asks is to be able to fail the job by returning a non-zero exit code on a violation.

```yaml
- run: go install github.com/muhammetsafak/archlint/cmd/archlint@latest
- run: archlint check
```

Or with the packaged [GitHub Action](https://github.com/muhammetsafak/archlint):

<!--email_off-->

```yaml
- uses: muhammetsafak/archlint@v0.3.0
```

<!--/email_off-->

## Limits — the honest list

- **Regex scanners for TS/JS and Python.** Go imports are read with `go/parser` (exact). TS/JS and Python imports come from regex scanners, so an import-like string inside a comment or string literal can be a false positive.
- **Static import graph.** `check` governs the dependency graph between layers, and `archlint metrics` (added in v0.3.0) adds coupling, bounded-context and Conway signals on that same graph. 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

- [github.com/muhammetsafak/archlint](https://github.com/muhammetsafak/archlint)

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, I'll queue it.
