# 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://www.muhammetsafak.com.tr/en/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 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:

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

From here on it's the usual two lines of [the workflows I build with GitHub Actions](/en/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

- **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

- [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 (TS? Python?), I'll queue it.
