# One architecture rule, three languages: archlint now does Go + TypeScript + Python

> In a polyglot repo the rule is the same in every language; one architecture.json now enforces Go, TypeScript and Python boundaries in CI.

- Published: 2026-06-18
- Category: Journal
- Tags: Go, TypeScript, Python
- Reading time: 2 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/archlint-one-rule-three-languages/
- Language: en-US
- Author: Muhammet Şafak

---
When I [first built archlint](/en/blog/archlint-enforcing-architecture-boundaries-in-ci/) 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 through the `aliases` map in `architecture.json`.
- **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:

```json
{
  "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:

```sh
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](https://sade.dev/en/journal/architecture-decisions-rot): 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.

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