Self-hosted service
fe3dback/go-arch-lint avatar
fe3dback/go-arch-lint

go-arch-lint checks your Go imports against the layers you declared

GoLang architecture linter (checker) tool. Will check all project import path and compare with arch rules defined in yml file. Useful for hexagonal / onion / ddd / mvc and other architectural patterns. Tool can by used in your CI

615 stars29 forksGoMIT

At a glance

What is it?
go-arch-lint is a Go linter that matches packages to architectural components from a YAML file, builds a dependency graph from the real imports, and fails when the actual graph differs from the intended one. It targets hexagonal, onion, DDD and MVC layouts, and it is designed to run in CI.
Who is it for?
Adopt go-arch-lint if your Go codebase has layers you already believe in but cannot keep, and you want the check to run in CI rather than in a review comment. Do not adopt it as a general code quality linter, since it only reasons about import paths between declared components and says nothing about naming, error handling or test coverage.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 46 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A linter for import direction, not for style

go-arch-lint is described as a linter used to enforce some good project structure and validate top level architecture, specifically the code layers. The description in the repository topics is blunter still: it checks all project import paths and compares them with architecture rules defined in a YAML file, and it is useful for hexagonal, onion, Ddd and MVC patterns among others.

That narrows the scope usefully. This is not a style linter, and it is not a complexity checker. It answers one question: does the dependency direction in this codebase match the one you said you wanted?

The problem it solves is the one every layered architecture eventually has. The layers are drawn in a document, the code drifts away from them over a few sprints, and by the time someone notices, the diagram and the repository are two different systems. A linter turns the diagram into a machine checkable file, and then into a CI job that fails when the two drift apart again.

The mechanism is package level, not file level. The tool works with Go packages, using the packages.Load feature of the Go AST parser to understand the project, and it does that by matching package paths to the components you declared. That is why the Dockerfile in the repository is built on a full golang image rather than a scratch or distroless base: the linter needs a go binary and parts of the go setup to parse the project. The Dockerfile comments on this directly, and it leaves a todo to remove the unneeded files and to replace packages.Load with something else.

The licence is MIT, the primary language is Go, and the release history shows v1.17.0, v1.18.0 and v1.19.0 in August and September 2026, with the last push on 2026-09-07.

The YAML file is where your architecture actually lives

The configuration is a small YAML document, and the README's example is the clearest statement of what the tool expects. The idea it illustrates is the onion layout from clean architecture, with the layers written down as components:

yaml
version: 3
workdir: internal
components:
  handler:    { in: handlers/* }           # wildcard one level
  service:    { in: services/** }          # wildcard many levels
  repository: { in: domain/*/repository }  # wildcard DDD repositories
  model:      { in: models }               # match exactly one package

commonComponents:
  - models

deps:
  handler:
    mayDependOn:
      - service
  service:
    mayDependOn:
      - repository

Three sections do the work. The components section maps a name to a package path pattern, with the pattern syntax commented inline: a single star matches one level, a double star matches many levels, and an exact path matches one package. The repository pattern, domain/*/repository, is the DDD case where the repository layer lives under a domain package.

The commonComponents list is the exception list. models is listed there because a shared model package is legitimately imported by more than one layer, and without this the linter would report every model import as a violation.

The deps section is the actual architecture. Each component declares what it mayDependOn, so handler may depend on service, and service may depend on repository. Everything not listed is forbidden, which is the point: the tool enforces the rules you wrote rather than a set of assumptions about what good layering looks like.

The workdir key says where the code lives, here internal, and the version key is 3, which tells you the config format has been through revisions and that the tool versions its own config schema.

Package matching, a dependency graph, and a diff

The README describes the algorithm in five steps, and the sequence matters for understanding what the tool can and cannot see.

The linter matches or marks Go packages with components, using the path patterns from the config. It then finds all dependencies between components, builds a dependency graph, and compares the actual, code derived, graph with the desired, config derived, graph. If the difference is non-empty, the project has some issues.

That is a graph diff, not a rule list. The difference is that the tool does not check one import at a time in isolation. It knows that handler depends on service and service depends on repository, and it knows whether the code agrees. The output of the diff is the set of violations, which is why the same kind of violation reported from two different directions appears once rather than twice.

The example the README gives is the classic case. A main.go constructs a handler and passes a repository into it directly:

go
func main() {
  // ..
  repository := booksRepository.NewRepository()
  handler := booksHandler.NewHandler(
    service,
    repository, // !!!
  )
  // ..
}

The handler now reaches past the service layer to the repository layer, which the config forbids, and the linter finds it. The interesting part is not that the example is bad, it is that a human reviewer might not notice the extra argument in a constructor call, while a graph check notices it every time.

The repository also has a graph command, which is a genuinely useful companion to the checker. Running go-arch-lint graph produces a dependency graph, and the repository ships its own.go-arch-lint.yml as the example config, with the full graph documentation in docs/graph/README.md. The dependency list shows how the graph is rendered, with oss.terrastruct.com/d2 as the diagram library.

Installing it and running the first check

There are three ways to run the linter, and the Docker one is the easiest to try because it needs nothing installed on your machine:

bash
docker run --rm -v ${PWD}:/app fe3dback/go-arch-lint:latest-stable-release check --project-path /app

The volume mount is what makes this work, because the linter reads your source through the mounted path and the config file has to be found relative to the project path. The Docker Hub page lists the other tags and versions.

From source, the requirement is Go 1.25 or newer, and the install is:

bash
go install github.com/fe3dback/go-arch-lint@latest

After that you can point it at a project directory or run it from inside one:

bash
go-arch-lint check --project-path ~/code/my-project
# or
cd ~/code/my-project
go-arch-lint check

The default project path is the current directory, and the default config file name is .go-arch-lint.yml, which is the file the tool looks for at the root of the project.

Precompiled binaries are also published on the releases page, which is the option to use when you do not want Go in your CI image but do not want to pull the Docker image either.

One note on the CI side, from the repository's own Makefile, which has targets to compare the current build against the previous stable release. The arch-next target runs the local code with go run main.go check, and arch-prev runs the released Docker image against the same working tree, so you can see whether a change in behaviour came from your code or from the tool.

Exit codes and JSON output for a CI job

The linter's contract with a CI system is short and explicit. The README gives a table of two status codes: 0 for a project with correct architecture, and 1 when warnings were found.

There is no third state for a configuration error in that table, which is worth knowing before you wire this into a pipeline. If your config is wrong, or the project path is wrong, you need to read the output to find out what happened, because the exit code alone will not distinguish that from a genuine architecture violation.

The flags for the check command are documented in full. The arch-file flag sets the config path and defaults to .go-arch-lint.yml. The project-path flag is the absolute path to the project directory where that config lives, defaulting to the current directory. The max-warnings flag caps how many warnings are printed, with a default of 512, which is a sensible safety valve for a project that has drifted a long way from its intended architecture.

Output is controlled by the global flags. There is a json flag which is an alias for --output-type=json, and the output-type flag itself accepts ascii and json, with default as the third option. The output-color flag defaults to true, so ANSI colours are on unless you turn them off, and --output-json-one-line formats the JSON payload as a single line without line breaks, which is what you want when the output is going to be parsed line by line or stored in a single-line log field.

The combination of a machine readable output type and a non zero exit code is what makes this usable as a gate rather than as a report. The JSON form is what you would collect as an artifact from the pipeline, and the exit code is what fails the job.

The intended adoption path is legalize, then fix

The README is unusually clear about how to introduce this linter to an existing project, and the sequence it describes is the most useful thing in the document.

It starts with the current state of the project. Then you create a .go-arch-lint.yml file describing the ideal project architecture. Then the linter finds some issues, and the instruction is not to fix them right now, but to legalize them by adding them to the config and marking them todo with a label. Then, in your free time, you fix the technical debt. And after the fixes, you clean up the config to target state.

That is an admission that the tool will immediately report the architecture you already have, not the one you want. Encoding the target state first and then fixing the code is the wrong order, because the first run of the linter would produce a wall of violations that nobody can act on, and the temptation would be to delete the config.

The todo label is the mechanism that makes the intermediate state workable. A violation that is known and accepted can be recorded in the config as an exception rather than as a failure, so the linter becomes green and keeps watching for new violations, while the backlog is tracked separately in the code.

The repository has a pre-commit hook configuration in .pre-commit-hooks.yaml, so the same check can run before a commit rather than only in CI, and the README suggests adding the linter to a CI workflow for the best experience.

What it does not check, and where the limit is

The scope is the limit, and it is worth being precise about it.

The linter reasons about import paths between declared components. It does not reason about function calls, object graphs, or runtime behaviour. A component that never imports another component is clean by definition, however tangled its internal wiring is, and a project with a beautiful package layout but services reaching into each other's packages through an interface defined in a third package will pass if the config permits that path.

The config is the specification, and the quality of the check is the quality of the config. The patterns are path globs, so a component whose packages are spread across several directories needs a pattern that covers them, and a codebase that does not follow a directory naming convention will need patterns that describe reality rather than the tidy structure in the architecture document.

There is also a practical constraint in the toolchain. Because the linter uses packages.Load from the Go AST parser, it needs a real Go toolchain and a module context to resolve the project, which is why the Docker image is built from golang:1.27 with the full setup rather than a minimal base, and why the Dockerfile carries an explicit todo about replacing that approach. The go.mod declares Go 1.25.0 and the tool depends on golang.org/x/tools for the package loading.

The other piece of tooling worth knowing about is the dependency on the D2 diagram library for graph rendering Both are well known components, and the overall dependency set is small and mainstream, with cobra for the command line, testify and go-cmdtest for the tests, and a JSON schema validator, gojsonschema, which is presumably what validates the config file against the version 3 schema.

Editorial conclusion

Adopt go-arch-lint if your Go codebase has layers you already believe in but cannot keep, and you want the check to run in CI rather than in a review comment. Do not adopt it as a general code quality linter, since it only reasons about import paths between declared components and says nothing about naming, error handling or test coverage. Before you start, write the architecture file to match the code you have rather than the code you wish you had, because the tool's first run against a realistic project will report a long list of violations, and the exit code of 1 on any warning is what makes the CI wiring strict.

Frequently asked questions

How do I install go-arch-lint?

Run `go install github.com/fe3dback/go-arch-lint@latest`, which requires Go 1.25 or newer, or use the Docker image with `docker run --rm -v ${PWD}:/app fe3dback/go-arch-lint:latest-stable-release check --project-path /app`. Precompiled binaries are also published on the repository's releases page.

What does the .go-arch-lint.yml config file contain?

A version, a workdir, a components section mapping component names to package path patterns, a commonComponents list for packages legitimately shared between layers, and a deps section where each component declares what it mayDependOn. The README's example describes a clean architecture layout with handler, service, repository and model components.

How does go-arch-lint decide a project has issues?

It matches Go packages to components, finds the dependencies between components, builds a dependency graph, and compares that actual graph with the desired graph from the config. If the difference is not empty, the project has issues and the linter returns exit code 1, while a clean project returns 0.

How should I introduce go-arch-lint to a codebase that already violates its own architecture?

The README describes the sequence: describe the architecture you have, let the linter find the issues, legalize the known ones in the config with a todo label, fix the debt over time, then clean the config up to the target state. Encoding the target architecture first would produce a wall of violations on the first run.

Official sources

  1. fe3dback/go-arch-lint on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/fe3dback-go-arch-lint.svg)](https://hysenlabs.com/projects/fe3dback-go-arch-lint)

Was this useful?