Skip to content
Muhammet Şafak
tr
Journal 2 min read

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 way tsconfig paths would.
  • 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.

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