Essay

Your architecture rules deserve a linter

Use Go’s syntax tree to turn dependency direction, naming rules, and yesterday’s production bug into CI checks.

In this article
  1. Make the arrows executable
  2. Four useful checks
  3. The smallest useful architecture linter
  4. Put the gate where nobody can forget it
  5. References

An architecture rule written only in a document is a wish with headings. Reviewers cannot memorize every constraint and compare every import against it on every pull request. If a rule can be checked mechanically, write it as a program and let CI be the colleague who never gets tired of repeating itself.

This is especially useful for clean architecture’s most frequently violated rule: dependency direction.

Make the arrows executable #

A practical Go backend might use four layers:

handler → usecase → repository port / infrastructure → domain

The important constraints are negative:

  • handlers do not call persistence adapters directly;
  • use cases do not import gin.Context or net/http;
  • the domain depends on no outer layer;
  • use cases know repository interfaces, not their concrete adapters.

Allowed dependency direction and prohibited shortcuts between clean-architecture layers.

The red arrows in the diagram are precisely the shortcuts someone writes under deadline pressure. “Just this once” has an excellent promotion path to “critical legacy behavior.”

Go’s standard go/parser and go/ast packages can enforce project-specific constraints in roughly 100–200 lines, without adding a third-party analyzer.

Four useful checks #

archlint inspects imports. A handler importing a persistence adapter, or a use case importing Gin, fails with a file, line number, and exit code 1.

naminglint enforces the project’s use-case shape. If the convention says every exported XxxUseCase has a NewXxxUseCase constructor and an Execute method, the checker verifies both. Consistency reduces the amount of archaeology needed to read unfamiliar code.

slicelint came from a real bug. A nil Go slice serializes to JSON null; a frontend expecting an array then calls .map() and falls over precisely for the newest users with no data. The rule flags persistence methods that return a slice declared as var items []T instead of initializing make([]T, 0). A good custom linter is a scar that runs in CI.

apispec-lint compares Gin route registrations with @Router declarations used by swaggo. It catches endpoints that exist in code but disappeared from generated API documentation.

The smallest useful architecture linter #

This complete check rejects Gin imports inside internal/usecase:

package main
 
import (
    "fmt"
    "go/parser"
    "go/token"
    "os"
    "path/filepath"
    "strings"
)
 
func main() {
    violations := 0
    root := "internal/usecase"
 
    filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
        if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") {
            return err
        }
 
        fset := token.NewFileSet()
        file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
        if err != nil {
            return err
        }
 
        for _, imported := range file.Imports {
            if strings.Contains(imported.Path.Value, "gin-gonic/gin") {
                pos := fset.Position(imported.Pos())
                fmt.Printf("%s:%d: usecase must not import gin\n", pos.Filename, pos.Line)
                violations++
            }
        }
        return nil
    })
 
    if violations > 0 {
        os.Exit(1)
    }
}

parser.ImportsOnly avoids parsing work the rule does not need. token.FileSet gives editor-friendly positions. From here, replace the single forbidden import with a table describing allowed relationships between directories.

Put the gate where nobody can forget it #

Run all checks behind one local command, then run the same command for every pull request:

verify:
	go vet ./...
	go build ./...
	go test ./...
	go run ./cmd/archlint
	go run ./cmd/naminglint
	go run ./cmd/slicelint
	go run ./cmd/apispec-lint

Add go test -race and a coverage floor if they fit the project. The point is not to assemble the world’s most judgmental Makefile. It is to move deterministic feedback before human review, leaving reviewers time for design, behavior, and the inconvenient questions machines cannot answer.

Local and CI checks form a quality gate before human review.

When a bug teaches a durable lesson, encode it. When an architecture boundary can be recognized from imports, check it. A convention becomes dependable when remembering it is no longer part of the acceptance criteria.

References #

Related articles