Линтер архитектуры для Go (go-arch-lint): описываете слои и
зависимости на type-safe Go DSL — линтер находит нарушения в импортах
и инъекциях зависимостей. Подходит для clean architecture, hexagonal,
onion и DDD.
go install github.com/vsfedorenko/go-arch-lint/v3/cmd/arch-lint@latestИли через Docker:
docker run --rm -v ${PWD}:/app ghcr.io/vsfedorenko/go-arch-lint:latest check --project-path /appИли бинарник из релизов.
Путь модуля —
github.com/vsfedorenko/go-arch-lint/v3(суффикс обязателен для мажорных версий Go-семвера). Если вы обновляетесь с v2.x: замените импорты в.go-arch-lint/на/v3и мигрируйте спеку (см. «Миграция с v2.x» ниже). Проще начать с нуля:rm -rf .go-arch-lint && go-arch-lint initсгенерирует свежую спеку со всеми каталогами проекта (init не перезаписывает существующий каталог, а v2-спека всё равно несовместима).
Спека — это дерево зависимостей, записанное в порядке «зависимости раньше». Два канонических шаблона:
cmd/
└── app/ # точка входа: собирает всё
internal/
├── domain/ # сердце: сущности, ноль внешних зависимостей
├── core/ # логика приложения: использует только domain
└── adapter/ # периферия: доставляет запросы в core
├── http/
└── db/
var build = Spec(func() {
domain := Path("internal/domain")
core := Path("internal/core", func() { Use(domain) })
http := Path("internal/adapter/http", func() { Use(core) })
db := Path("internal/adapter/db", func() { Use(core, domain) })
Path("cmd", func() {
Path("app", func() { Use(core, db, http) }) // точка входа собирает всё
})
})Стрелки смотрят внутрь: адаптеры знают про core, core — только про domain.
Импорт domain из adapter/http проверка забракует: в правиле Use его нет.
internal/
├── entity/ # Enterprise-правила: сущности
├── usecase/ # сценарии приложения
├── adapter/ # интерфейсы наружу: repo, web
│ ├── repo/
│ └── web/
└── framework/ # драйверы: БД, http-сервер
var build = Spec(func() {
entity := Path("internal/entity")
usecase := Path("internal/usecase", func() { Use(entity) })
repo := Path("internal/adapter/repo", func() { Use(usecase, entity) })
web := Path("internal/adapter/web", func() { Use(usecase, entity) })
pg := Vendor("pgx", "github.com/jackc/pgx/v5")
chi := Vendor("chi", "github.com/go-chi/chi/v5")
Path("internal/framework", func() { Use(repo, web, usecase, entity, pgx, chi) })
Path("cmd", func() {
Path("app", func() { Use(usecase, entity, repo, web, pgx, chi) })
})
})Вендоры (pgx, chi) живут в одном внешнем слое — framework и точка входа;
внутренние слои не знают о них ничего. Попытка импортировать pgx из
usecase — нарушение на файл:строке.
Все три примера — живые проекты в examples/ (basic — слои, ddd —
bounded contexts, hexagonal — порты и адаптеры), и CI проверяет каждый:
job examples прогоняет go-arch-lint check по всем трём на каждом PR.
- Go 1.25+ (поддерживаются две последние мажорные версии: 1.25 и 1.26; CI тестирует обе).
goдолжен быть доступен наPATH— CLI компилирует.go-arch-lint/arch.goчерезgo run(кэшируется, повторные запуски ~2s).- Go workspaces (
go.work). Проект с корневымgo.modработает полностью — включая соседние модули-члены workspace:initисключает их из архитектуры по умолчанию, а при желании их можно объявить компонентами (Path("two/y")) и писать кросс-модульные правилаUse. Модуль-член может иметь любой собственный module path (example.com/y, не обязательноexample.com/root/two/y) — импорты членов классифицируются как project-код, а не vendor. Workspace из модулей-сиблингов без корневогоgo.modпока не поддерживается — CLI подскажет это при запуске.
Конфигурация — это Go-файл. Не YAML, не JSON — обычный Go-код с типобезопасностью и автодополнением в IDE.
cd ~/code/my-project
go-arch-lint initСоздаёт .go-arch-lint/ с тремя файлами. Спека и раннер разделены: arch.go —
ваш файл, main.go — служебный, его трогать не нужно:
// arch.go — здесь вы описываете архитектуру
package main
import (
. "github.com/vsfedorenko/go-arch-lint/v3/dsl"
)
var build = Spec(func() {
// Каждый каталог с Go-кодом объявлен, и каждый существующий
// импорт разрешён: каркас зеркалит код как есть. Ужесточайте
// правила Use по мере роста архитектуры:
//
// domain := Path("internal/domain")
// Path("internal/core", func() { Use(domain) })
domain := Path("internal/domain")
handlers := Path("internal/handlers", func() { Use(domain) })
Path(".", func() { Use(domain, handlers) })
Path("cmd/app", func() { Use(domain, handlers) })
})// main.go — сгенерирован, не редактируйте: пробрасывает флаги CLI в проверку
package main
import (
"os"
"github.com/vsfedorenko/go-arch-lint/v3"
)
func main() {
archlint.MustRunCLI(build, os.Args[1:])
}Что здесь происходит:
— init сканирует проект и объявляет каждый каталог с Go-кодом как компонент (Path), каждый внутренний импорт становится правилом Use, а каждая сторонняя библиотека — декларацией Vendor + Use — свежий каркас зеркалит код как есть и сразу проходит проверку. Ужесточить правило — значит удалить строку Use и посмотреть, какие нарушения всплывут.
— Use — единственное правило: «этот путь использует эти цели». Без явного Use запрещено всё.
— Vendor(name, import) — внешняя библиотека как легальная цель; стандартная библиотека разрешена всегда.
— Порядок объявления = направление зависимостей: сослаться вперёд нельзя, это ошибка компиляции Go.
Полный список функций DSL — в документации синтаксиса или через go doc github.com/vsfedorenko/go-arch-lint/v3/dsl.
Не хотите начинать с чистого листа — возьмите пример из каталога examples/: скопируйте .go-arch-lint/ из basic (слои), ddd (bounded contexts) или hexagonal (порты и адаптеры) в свой проект и поправьте пути. Каждый пример — законченный проект со спекой на актуальном DSL.
Флаг --recipe существовал в v1/v2 и удалён в v3: init больше не принимает ни его, ни других неизвестных флагов — молчаливый откат к дефолтной спеке был бы хуже ошибки.
go-arch-lint checkЛинтер строит граф импортов из реального кода, сравнивает с графом из конфигурации и выводит нарушения:
| Код возврата | Значение |
|---|---|
| 0 | Нарушений нет |
| 1 | Найдены нарушения |
| 2 | Сломана конфигурация: arch.go не компилируется или проект не читается |
Код 2 означает «проверка не выполнялась» — чините конфигурацию, а не код.
Флаг --json переключает вывод в машиночитаемый формат для CI.
Для инкрементального внедрения на legacy-коде известное нарушение можно пометить в исходнике директивой — проверка пройдёт, а новые нарушения по-прежнему будут фейлить сборку:
//go-arch-lint:ignore // подавить любое нарушение на строке
//go-arch-lint:ignore beta // только если цель зависимости — beta
import _ "example.com/app/internal/beta"
//go-arch-lint:ignore-file // подавить все нарушения в файле
package legacyДиректива ставится на самой строке нарушения или строкой выше. Аргумент
(через пробел, можно несколько) фильтрует по цели: имени компонента
(beta) или последнему сегменту импорт-пути (internal/beta → beta).
Подавленные нарушения не исчезают бесследно: в конце отчёта выводится
suppressed: N (by //go-arch-lint:ignore directives), а в JSON — поле
SuppressedCount, так что технический долг остаётся видимым.
Когда нарушений сотни и размечать исходники директивами нереалистично, запишите их в baseline-файл — проверка будет падать только на НОВЫХ нарушениях («не чинить всё сразу, но и не добавлять нового»):
# 1. Записать текущие нарушения (файл коммитится в репозиторий):
go-arch-lint check --baseline .go-arch-lint/baseline.json --baseline-update
# 2. В CI — только сравнение: известный долг толерируется,
# новое нарушение фейлит сборку (exit 1):
go-arch-lint check --baseline .go-arch-lint/baseline.jsonBaseline — JSON с версией схемы и отпечатками (fingerprints) нарушений:
kind|rule|file без номеров строк, поэтому правки выше по файлу не
«воскрешают» старый долг как новый. Сводка в текстовом выводе показывает
baseline: N new, M known (tolerated), в JSON — поля BaselineNewCount
и BaselineKnownCount. Отсутствующий baseline-файл в режиме сравнения —
ошибка конфигурации (exit 2), а не молчаливый пропуск. Когда долг
починен — просто перезапишите baseline (устаревшие отпечатки игнорируются).
Под капотом команды check/mapping/graph/explain/selfInspect делегируются в
.go-arch-lint/ через go run — как устроена маршрутизация флагов, выходные
коды и кэширование (холодный старт ~45 с, рабочий режим ~2 с), см.
docs/delegation.md.
go-arch-lint graph --format=mermaidgraph LR
handler --> service
service --> repository
handler -.-> n0["3rd-cobra"]
Четыре формата вывода:
--format |
Куда | Зачем |
|---|---|---|
svg |
файл (по умолчанию) | готовое изображение |
d2 |
stdout | исходник d2 для ручной доработки |
plantuml |
stdout | рендер через PlantUML или CI |
mermaid |
stdout | Markdown, GitHub, GitLab |
Дополнительно: --type=di (обратный граф, DI), --focus=handler (только один компонент), --include-vendors (показать сторонние библиотеки).
go-arch-lint — не только CLI, но и библиотека. Спека описывается Path-DSL — весь язык умещается в четыре вызова:
import (
"github.com/vsfedorenko/go-arch-lint/v3"
. "github.com/vsfedorenko/go-arch-lint/v3/dsl"
)
func runArchCheck() error {
build := Spec(func() {
domain := Path("shop/domain")
pgx := Vendor("pgx", "github.com/jackc/pgx/v5")
Path("shop/core", func() {
Use(domain, pgx) // core использует domain и pgx
})
})
return archlint.Run(build, archlint.WithProjectPath("."))
}Обе формы равнозначны: точечный импорт с Spec(func() {...}) — компактная,
а явная Spec(func(s *dsl.SpecBuilder) {...}) с методами — без глобального
роутинга. Стили можно миксовать в одной спеке.
Правила простые:
Path("a/b")— каталог с Go-кодом, то есть компонент.Path("a/b/**")— всё поддерево как один компонент.Path(".")— корень модуля (пакет в корне, без подкаталогов).Use(...)— единственное правило: «этот путь использует эти цели». Цели — толькоPath/Vendorпеременные, миксуются свободно. Импорты внутри объявленного пути к его собственным подкаталогам всегда разрешены.Vendor(name, imports...)— внешний пакет (можно несколько путей на имя), легальная цель дляUse. Стандартная библиотека (fmt,strings, …) разрешена всегда иVendorне требует.Exclude(paths...)—glob-пути вне архитектуры: тестовые фикстуры, examples, вложенные модули. Необъявленный вPathи не исключённый каталог — ошибкаnot attached to any component.- По умолчанию запрещено всё, что явно не разрешено
Use.
Порядок объявления = направление зависимостей: сослаться вперёд нельзя, это ошибка компиляции Go. Опечатки и некорректные спеки паникуют при сборке с file:line. Каталоги проверяются по диску: несуществующий путь — ошибка конфигурации с подсказкой «did you mean».
archlint.MustRun(build) — то же, но с конвенциональными кодами возврата:
1 при нарушениях, 2 при ошибке конфигурации (см. archlint.ExitCode).
archlint.RunCLI(build, os.Args[1:]) — точка входа для скаффолда .go-arch-lint/main.go:
маршрутизирует делегированные команды (check, mapping, graph, explain, self-inspect)
по их собственному поведению, а не молча выполняет check. Диалект лаунчера
(-p, --no-colors, selfInspect) переводится автоматически; запуск без команды
по умолчанию — check. MustRunCLI — вариант с конвенциональными кодами возврата.
Первое поколение DSL (Version/Workdir/Component/Deps/CommonComponents)
удалено. Механическая замена:
| v2.x (удалено) | v3 |
|---|---|
Component("n", "a/b") |
n := Path("a/b") |
Deps("n", …MayDependOn("m")) |
Path("a/b", func() { Use(m) }) |
CommonComponents("n") |
Use(n) у каждого, кому нужен |
AnyProjectDeps(true) |
перечислить цели явно в Use |
Vendor(name, imp) (один путь) |
s.Vendor(name, imp1, imp2) |
Workdir("internal") |
убрать: пути в Path — от корня модуля |
Проще всего — перезапустить go-arch-lint init: он сгенерирует спеку со всеми
каталогами проекта. RunV2/MustRunV2/RunCLIV2/MustRunCLIV2 переименованы
в Run/MustRun/RunCLI/MustRunCLI. Naming, Tiers, Visibility,
Interfaces правила первого поколения в v3 не входят — кандидаты на возврат
отдельными расширениями.
| Команда | Назначение |
|---|---|
init |
Создать каркас .go-arch-lint/ (единственный флаг — --project-path/-p; шаблоны архитектур — в examples/) |
check |
Проверить архитектуру |
explain |
Объяснить, как спецификация относится к одному импорту |
graph |
Сгенерировать граф зависимостей |
mapping |
Показать соответствие пакетов и компонентов |
selfInspect |
Проверить архитектуру самого go-arch-lint |
version |
Вывести версию (version, --version, -v) |
Глобальные флаги: --project-path (кратко -p), --max-warnings N (лимит показа нарушений, по умолчанию 512; код выхода отражает полное число — подробности в docs/json-schema.md), --format text|json|sarif|junit|github-actions|html (check), --baseline <file> + --baseline-update (инкрементальное внедрение: известные нарушения толерируются, проверка падает только на новых — см. Baseline / инкрементальный режим), --output-type (ascii/json; неизвестное значение — ошибка конфигурации), --json (сокращение для --output-type=json), --output-json-one-line (однострочный JSON; без json-вывода — ошибка конфигурации, а не молчаливое игнорирование), --output-color / --no-colors (выключить ANSI-цвета).
Visibility(func(){ VisibleTo(...) }) — ограничивает, какие компоненты могут потреблять API указанной компоненты:
Visibility(func() {
VisibleTo("services") // полностью внутренняя: никто не может импортировать
VisibleTo("models", "services", "container") // только services и container
})- Правило работает по фактическому импорт-графу: импорт компоненты вне allow-листа — нарушение
- Сама компонента всегда может использовать свой код (self неявно разрешён)
- Несколько правил на одну компоненту аккумулируются (allow-листы объединяются)
- Сообщение указывает файл-свидетель первого импорта и видимый список
В гексагональной архитектуре интерфейс-порт живёт с потребителем, а не рядом с реализацией. Правило включается одной декларацией:
Interfaces(func() {
MustLiveWithConsumer()
})Чекер находит интерфейсы, которые использует ровно один другой компонент, и требует перенести декларацию к потребителю:
interface 'Iface' must live with its consumer 'alpha' (declared in component 'beta')
- Интерфейс, используемый двумя и более компонентами, — законно общий, остаётся где объявлен.
- Использование внутри своего компонента не считается.
- Анализ чисто синтаксический (один полный проход парсера, без загрузки типов) — быстро и без сети.
- Собственная спека go-arch-lint включает это правило.
Бесмысленные имена пакетов (utils, helpers, common, …) — свалки, в
которые со временем утекает весь код. Правило включается одной
декларацией в спеке:
Naming(func() {
ForbiddenPackages("utils", "helpers", "common", "misc", "stuff")
})Чекер сверяет имена всех просканированных пакетов проекта (фактические
package X в файлах, не пути) и репортит каждое нарушение один раз на
пакет с числом файлов:
Package name utils is forbidden internal/utils (3 file(s)): first at internal/utils/a.go
В JSON-выводе (--format json) нарушения имеют тип naming. Собственная
спека go-arch-lint уже баннит utils, helpers, common, misc,
stuff.
mapping -s grouped показывает для каждого компонента метрики по фактическому графу импортов:
services:
coupling: out 3 | in 2 | stability 0.60
out(Ce) — на сколько компонентов компонент ссылается (fan-out);in(Ca) — сколько компонентов ссылаются на него (fan-in);stability— I = Ce / (Ca + Ce) по Роберту Мартину:0— максимально стабильный (на него опираются, сам ни на кого не завязан),1— максимально нестабильный. Ценные для переиспользования компоненты должны иметь низкий stability.
В JSON-выводе метрики приходят в MappingGrouped[].Coupling
(omitempty — для компонентов без зависимостей поле отсутствует).
go-arch-lint explain <import> разбирает один путь импорта против текущей
спецификации — команда отвечает на вопрос «почему check ругается (или не
ругается) на этот импорт»:
$ go-arch-lint explain github.com/external/dep/pkg
Import: github.com/external/dep/pkg
Type: vendor
Module: example.com/m
Verdicts (component -> decision, rule):
DENY internal/core — no matching vendor rule: the import is not covered by any Vendor used by this component
fix: declare the dependency and allow it: vendor := Vendor("<name>", "github.com/external/dep/pkg") in arch.go, then Use(vendor) inside this component's Path(...)
DENY internal/util — no matching vendor rule: the import is not covered by any Vendor used by this component
fix: declare the dependency and allow it: vendor := Vendor("<name>", "github.com/external/dep/pkg") in arch.go, then Use(vendor) inside this component's Path(...)
Usages (actual import sites):
/app.go:6 ()
Выводит:
- классификацию импорта (
std/project/vendor) — ровно ту же, которой пользуетсяcheck; - владеющую компоненту (для project-импортов);
- вердикт allow/deny для каждой объявленной компоненты с точным
правилом, которое его породило, и конкретным фиксом для запрета
(
Vendor(...)+Use(...)вarch.go); - фактические места использования (файл:строка, компонента), до 20 —
остальные считаются в
OmittedUsages.
Вердикты выдаёт тот же код, что работает в check (единая точка решения
VerdictForImport) — объяснение не может разойтись с тем, что реально
проверяется. С --format json возвращает полную модель (ImportType,
OwnerComponent, Verdicts[], Usages[], OmittedUsages) для CI
и редакторных интеграций.
check --format json печатает плоский массив нарушений (стабильный
порядок, схема и примеры интеграции — в docs/json-schema.md):
GitHub Actions-аннотации, GitLab CI-artifacts, семантика exit-кодов.
Для сканеров кода (GitHub Code Scanning, DefectDojo, SARIF-дашборды) есть
check --format sarif — лог SARIF 2.1.0 с ruleId, уровнями и координатами
нарушений: docs/json-schema.md → SARIF.
Для CI-дашбордов тестов (GitLab CI test reports, Jenkins JUnit plugin,
Buildkite) есть check --format junit — JUnit-отчёт в XML: одно нарушение =
один упавший testcase, у чистого проекта один зелёный arch-check.
Рецепт: docs/json-schema.md → JUnit.
Для людей и архивов есть check --format html — самодостаточный
HTML-отчёт (инлайн-CSS, без скриптов и внешних ассетов): карточки счётчиков
по типам правил, таблица нарушений с file:line, экранирование враждебных
путей, тёмная тема по prefers-color-scheme. Артефакт CI открывается
напрямую в браузере.
Схема: docs/json-schema.md → HTML.
Для pull request'ов есть официальный GitHub Action: аннотации ::error
прямо на строках диффа, установка бинарника из релиза — без JS-скриптов:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with: { go-version: '1.25.13' }
- uses: vsfedorenko/go-arch-lint@v3 # moving major tag: always the latest v3.xПодробности и все inputs: docs/json-schema.md → GitHub Action.
| Код | Значение |
|---|---|
0 |
Нарушений нет |
1 |
Найдены нарушения архитектуры |
2 |
Ошибка конфигурации/системы (спека не собирается, проект не читается) |
CI может различать «нашли нарушения» (падение сборки) и «сломан конфиг» (конфиг ничего не проверяет — звать мейнтейнера).
В каталоге examples/ — три демонстрационных проекта:
- basic — слоистая архитектура (handler → service → repository).
- ddd — domain-driven design (domain → application → infrastructure → interfaces).
- hexagonal — ports and adapters (core → adapters → domain).
Каждый пример содержит .go-arch-lint/ с конфигурацией arch-lint (arch.go + main.go).
Линтер сопоставляет Go-пакеты с компонентами по glob-шаблонам, извлекает импорты из AST, строит фактический граф зависимостей и сравнивает его с желаемым графом из конфигурации. Несовпадения — это нарушения архитектуры.
Режим deep scan анализирует вызовы методов и инъекции зависимостей — не только импорты, но и структурное использование типов между компонентами.
MIT. Форк проекта go-arch-lint © fe3dback.