Architecture linter for Go projects. Enforce layer dependencies, naming conventions, DDD patterns, and structural rules through static analysis.
- 41 built-in rules across 5 categories (dependency, naming, interface, structure, DDD)
- Tier system — rules declare their config requirements; only applicable rules run
- Presets — start with
clean-archor build your own configuration - Location strategies — map your project structure (
nested-domain,flat-pkg) to architectural layers - Test integration — run as
go testfor CI-friendly checks - Multiple output formats — text, JSON, GitHub Actions annotations
go install github.com/channel-io/cht-go-lint/cmd/cht-go-lint@latestDownload binaries from GitHub Releases.
# Create a default config file
cht-go-lint init
# Edit .cht-go-lint.yaml for your project, then:
cht-go-lint check
# Or use a presetMinimal .cht-go-lint.yaml:
module: github.com/your-org/your-project
extends:
- clean-arch
# Override or add rules
rules:
naming/file-naming: error| Rule | Tier | Description |
|---|---|---|
dependency/layer-direction |
layer-aware | Enforce allowed import direction between layers |
dependency/module-isolation |
component-aware | Enforce that components don't import each other's internals |
dependency/forbidden-imports |
universal | Ban specific import path patterns |
dependency/di-isolation |
domain-specific | DI framework code should be isolated in companion files |
dependency/infra-in-core |
layer-aware | Core layers must not import infrastructure packages |
dependency/handler-placement |
layer-aware | Handler layer files should only import allowed layers |
dependency/public-service-isolation |
layer-aware | Public Service files must not import repo/infra/client/event/handler layers |
dependency/app-service-mixing |
layer-aware | App Service files must not mix repo and infra/client/event imports |
dependency/cross-boundary |
component-aware | Cross-component imports must use public interfaces only |
dependency/subdomain-isolation |
component-aware | Sub-components within a component must not import each other |
dependency/handler-infra-isolation |
layer-aware | Handler layer must not import infrastructure layers directly |
| Rule | Tier | Description |
|---|---|---|
naming/file-naming |
universal | Source file names must follow a naming convention |
naming/no-stutter |
universal | Type or function name should not repeat the package name |
naming/constructor-naming |
universal | Constructor functions (New*) should return the type they construct |
naming/impl-naming |
universal | Implementation structs should follow naming convention relative to their interface |
naming/forbidden-names |
universal | Types with certain prefixes or suffixes are forbidden |
naming/filename-matches-type |
universal | The primary type in a file should match the filename |
naming/layer-type-pattern |
layer-aware | Enforce type naming conventions per layer/tag |
naming/no-domain-prefix |
component-aware | Exported types should not be prefixed with the component name |
| Rule | Tier | Description |
|---|---|---|
interface/constructor-return |
universal | Constructor functions should return an interface type, not a concrete struct |
interface/impl-pattern |
universal | Files with an exported interface should also have a private implementation struct |
interface/colocation |
universal | Interface and its implementation should be co-located |
interface/one-per-file |
universal | Each file should have at most one primary exported interface |
interface/required-embedding |
domain-specific | Certain interfaces must embed a base interface |
| Rule | Tier | Description |
|---|---|---|
structure/forbidden-dirs |
universal | Forbid certain directory names anywhere in the project |
structure/required-dirs |
component-aware | Validate that required directories exist in each component |
structure/required-files |
domain-specific | Validate that required files exist in directories matching patterns |
structure/required-declarations |
domain-specific | Validate that specific files contain required declarations |
structure/file-content |
domain-specific | Restrict what declarations a specific file may contain |
structure/file-placement |
domain-specific | Enforce that certain files can only exist in specific directories |
structure/declaration-order |
universal | Enforce ordering of declarations within a file |
structure/import-alias |
universal | Import aliases should follow a naming convention |
structure/delegation-only |
layer-aware | Methods in target layers should only delegate to another type's method |
| Rule | Tier | Description |
|---|---|---|
ddd/aggregate-boundary |
domain-specific | Aggregate roots must not directly reference other aggregates |
ddd/repository-per-aggregate |
domain-specific | Each aggregate root should have exactly one repository interface |
ddd/entity-identity |
domain-specific | Entity types should have an ID field |
ddd/value-object-immutable |
domain-specific | Value objects should not have setter methods |
ddd/domain-event-naming |
domain-specific | Domain events should follow naming conventions |
ddd/bounded-context-isolation |
domain-specific | Bounded contexts should not directly import each other |
ddd/no-domain-to-infra |
layer-aware | Domain layer must not import infrastructure packages |
ddd/service-layer |
layer-aware | Enforce separation between domain services and application services |
Rules declare the minimum configuration they need:
| Tier | Requires | Examples |
|---|---|---|
| universal | Nothing | naming/file-naming, structure/forbidden-dirs |
| layer-aware | layers defined |
dependency/layer-direction |
| component-aware | components defined |
dependency/module-isolation |
| domain-specific | Rule-specific options |
ddd/aggregate-boundary |
Rules are silently skipped if the config doesn't satisfy their tier.
# Go module path (required)
module: github.com/your-org/your-project
# Inherit from presets
extends:
- clean-arch
# Exclude paths from architecture rule scanning
# (golangci-lint uses its own config for scope)
exclude_paths:
- lib
- cmd
- test
# golangci-lint integration (requires golangci-lint in PATH)
go_lint:
enabled: true
config: .golangci.yaml # optional: path to config file
args: [] # optional: extra arguments
# Location strategy maps file paths to architectural layers
location:
strategy: flat-pkg # or "nested-domain"
options:
# flat-pkg options:
roots: ["internal", "pkg"]
# nested-domain options:
# domain_root: "internal/domain"
# subdomain_dir: "subdomain"
# saga_root: "internal/saga"
# Architectural layers and allowed imports
layers:
- name: model
may_import: []
- name: repo
may_import: [model]
- name: service
aliases: [svc]
may_import: [model, repo]
- name: handler
may_import: [model, service]
# Component isolation
components:
- name: user
path: internal/domain/user
- name: order
path: internal/domain/order
# Rules: string shorthand or object form
rules:
naming/file-naming: warn # shorthand
dependency/layer-direction: error
ddd/aggregate-boundary: # object form
severity: error
options:
root_marker: "Aggregate"Excludes directories from architecture rule scanning. This does not affect golangci-lint — use its own config for scope.
Paths are relative to the project root and matched as prefixes:
exclude_paths:
- lib # skips lib/ and all subdirectories
- cmd
- testRun golangci-lint as part of cht-go-lint check for a single-command lint experience:
go_lint:
enabled: true
config: .golangci.yaml # optional
args: # optional extra args
- --new-from-merge-base=origin/mainViolations from golangci-lint use go/<linter> rule names (e.g., go/errcheck, go/staticcheck).
CLI flags for dynamic control:
--skip-go-lint— disable golangci-lint for this run--go-lint-args "<args>"— pass extra arguments to golangci-lint
A minimal Clean Architecture preset with 4 layers and 8 rules:
- Layers:
model→repo→service(alias:svc) →handler - Strategy:
flat-pkg - Rules:
dependency/layer-direction(error), plus 7 naming/structure rules (warn)
extends:
- clean-archPresets can be extended and overridden — any rule or layer you define takes precedence.
Location strategies map file paths to architectural positions (component, layer, sub-component).
Simple structure where layers are directories under a root:
internal/
user/
model/
repo/
service/
handler/
For larger projects with subdomains:
internal/domain/
user/
subdomain/
membership/
model/
repo/
svc/
model/
repo/
svc/
Options: domain_root, subdomain_dir, saga_root.
Add architecture checks to your Go tests:
package arch_test
import (
"testing"
lint "github.com/channel-io/cht-go-lint"
_ "github.com/channel-io/cht-go-lint/preset"
_ "github.com/channel-io/cht-go-lint/rules"
)
func TestArchitecture(t *testing.T) {
// Quick: load .cht-go-lint.yaml from project root
lint.QuickCheck(t, "../..")
// Or with explicit config:
// cfg, _ := lint.LoadConfig("../..")
// lint.Run(t, cfg)
}Usage: cht-go-lint <command> [options]
Commands:
check Run architecture lint checks
list-rules List all available rules
init Create a default configuration file
Options for 'check':
--config <path> Config file path (default: auto-detect .cht-go-lint.yaml)
--format <fmt> Output format: text, json, github (default: text)
--rule <names> Run specific rules (comma-separated)
--fix Auto-fix fixable violations before checking
--dry-run Show what --fix would change without writing
--skip-go-lint Skip golangci-lint integration
--go-lint-args <args> Extra args to pass to golangci-lint (space-separated)
--fix runs both built-in fixers (e.g. declaration_order) and golangci-lint's --fix (e.g. goimports, gofmt), then reports any remaining violations.
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install lint tools
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0
go install github.com/channel-io/cht-go-lint/cmd/cht-go-lint@latest
- run: cht-go-lint check --format githubThe github format emits ::error / ::warning annotations that show inline on PRs.
With go_lint.enabled: true in config, this single command runs both architecture and golangci-lint checks.
Implement the Rule interface and register in init():
package myrules
import lint "github.com/channel-io/cht-go-lint"
func init() {
lint.Register(&MyRule{})
}
type MyRule struct{}
func (r *MyRule) Meta() lint.Meta {
return lint.Meta{
Name: "custom/my-rule",
Description: "My custom architecture rule",
Category: "custom",
Tier: lint.TierUniversal,
}
}
func (r *MyRule) Check(ctx *lint.Context) error {
return ctx.Analyzer.WalkGoFiles(func(path string, file *lint.ParsedFile) error {
// your logic here
return nil
})
}Then import your package for side-effect registration:
import _ "your-module/myrules"Apache License 2.0 - see LICENSE for details.