One architecture rule, three languages: archlint now does Go + TypeScript + Python
In a polyglot repo, 'domain must not import infrastructure' is the same rule in every language — only how the import resolves differs. I added TypeScript and Python to archlint: one architecture.json enforces all three in CI.
When I first built archlint it only spoke Go. But a real system isn’t one language: a Go API, a TypeScript frontend, Python workers. All three share the same architectural intent — “the domain layer must not import infrastructure” — yet there was no single tool enforcing that rule across all of them. Now archlint governs all three, from one architecture.json.
The boundary isn’t language-specific; the syntax is
At the layer level the rule is identical in every language: domain → db is forbidden. What differs between languages isn’t the rule, it’s how an import is written and which file it resolves to:
- Go: an absolute package path —
github.com/acme/app/internal/db. Resolve it by stripping the module prefix →internal/db. - TypeScript: relative (
../db/repo) or aliased (@/db/repo). Resolve the relative one against the file’s location, the alias the waytsconfigpathswould. - Python: dotted and relative —
from ..db.repo import find_order. Resolve the leading dots against the file’s package, then map the module to a path.
archlint reduces all of them to one thing: a repo-relative path → a layer. From there the engine is the same: “which layer is this file in, which layer does this import reach, do the rules allow it?”
One config, mixed repo
The nice part: a single file can govern a three-language monorepo. Layer names are shared; the paths are language-specific:
{
"module": "github.com/acme/app",
"aliases": { "@/": "web/src/" },
"layers": {
"domain": ["internal/domain", "web/src/domain", "workers/app/domain"],
"infra": ["internal/infra", "web/src/infra", "workers/app/infra"]
},
"rules": { "domain": [], "infra": ["domain"] }
}
The domain layer exists in all three services (Go internal/, TS web/src/, Python workers/app/) and none of them may import infrastructure. One rule, three languages, one CI gate.
How it works: a pluggable-language design
Inside there’s a Language interface: each language knows only two things — how to extract a file’s imports and how to resolve an import to a repo-relative path. The rest of the linter is language-agnostic. Adding a language = adding one Language implementation.
The discipline held: instead of tree-sitter (a heavy CGo dependency), Go is read with the stdlib go/parser (exact), and TS and Python with regex scanners. The honest limit: those aren’t full parsers — an import-like line inside a comment or string can be a false positive. The README says so plainly.
Try it: three examples
Each plants a deliberate violation:
archlint check examples/sample # Go → internal/domain/bad.go
archlint check examples/ts-sample # TS → src/domain/bad.ts (resolves the "@/" alias)
archlint check examples/py-sample # Python → app/domain/bad.py (resolves "..db.repo")
All three print the same thing: domain → db is not allowed, exit 1, build red.
Why this beats a wiki page I argued separately on sade.dev: a written architecture decision rots unless something checks it on every commit. Now that “something” runs in three languages at once. Tell me which language the next boundary should be — I’ll queue it.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.