Interface Anti-Patterns — Professional Level¶
Table of Contents¶
- Introduction
- Damage in Large Codebases
- Inventory: Detecting Anti-Patterns at Scale
- Refactoring Strategy: Strangler vs Big-Bang
- Refactoring Recipe: Header Interface Removal
- Refactoring Recipe: Mock Explosion Cleanup
- Refactoring Recipe: Typed-Nil Audit
- Refactoring Recipe:
interfaces.goDispersion - Governance: Style Guide Entries
- Governance: Linter Pipeline
- Code Review Checklist
- Cheat Sheet
- Summary
Introduction¶
In a young codebase a bad interface is one annoying file. In a 500-thousand-line monorepo it is a tax on every commit. This file covers:
- The shapes anti-patterns take when they spread across teams.
- How to inventory them with
grep,staticcheck, and AST tooling. - Refactoring strategies that keep services shipping while abstractions are repaired.
- Governance — style guides, linter configuration, and PR review patterns that prevent the next round.
Damage in Large Codebases¶
Damage 1 — Compilation amplification¶
A package containing 12 interfaces, each with 8 methods, used by 30 consumers, is recompiled whenever any single signature changes. CI minutes balloon. Developers wait.
Damage 2 — Test brittleness¶
Mocks generated from header interfaces have to be regenerated on every method tweak. Every PR touches *_mock.go files. Code review fatigue sets in. Genuine bugs hide in noisy diffs.
Damage 3 — Onboarding tax¶
A new engineer reading service.go jumps from interface (file A) to implementation (file B) to mock (file C) to test (file D). The mental model takes weeks instead of days.
Damage 4 — Refactor paralysis¶
A team that wants to add ctx context.Context to a method touches the interface, every implementation, every mock, every consumer call site. Hundreds of files. The PR is too big to review. The change is shelved.
Damage 5 — Production bugs from leaky abstractions¶
The "Cache" interface includes Pipeline(). The Redis impl honors it; the in-memory impl panics. A test environment quietly switches to in-memory and the staging deploy crashes.
Damage 6 — errors.Is/As bypass¶
Custom AppError interfaces propagate; consumers compare with == against package-level variables; wrapping breaks; sentinel checks miss. Production logs become useless.
Inventory: Detecting Anti-Patterns at Scale¶
Step 1 — Header interface count¶
# count interfaces with > 5 methods
grep -rEzo 'type \w+ interface \{[^}]+\}' --include='*.go' . \
| awk -F'\n' '{ if (NF > 6) print $1 }'
A more reliable approach uses go/types:
// quick AST tool sketch
ast.Inspect(file, func(n ast.Node) bool {
if t, ok := n.(*ast.TypeSpec); ok {
if i, ok := t.Type.(*ast.InterfaceType); ok && i.Methods != nil {
if len(i.Methods.List) > 5 {
fmt.Println(t.Name.Name, len(i.Methods.List))
}
}
}
return true
})
Step 2 — Single-implementation interfaces¶
ireturn flags constructors returning interfaces. interfacebloat flags large interfaces. Cross-reference with gopls workspace_symbol to find single-implementation cases.
Step 3 — Pointer-to-interface¶
grep -rE '\*(io\.Reader|io\.Writer|io\.Closer|fmt\.Stringer|error)\b' --include='*.go' .
grep -rE 'func\s+\w+\([^)]*\*[A-Z][A-Za-z0-9_]+er\b' --include='*.go' .
Both surface common pointer-to-interface mistakes.
Step 4 — Mock-to-impl ratio¶
M=$(find . -name '*_mock.go' | wc -l)
P=$(find . -name '*.go' ! -name '*_mock.go' ! -name '*_test.go' | wc -l)
echo "ratio = $M / $P"
If M / P > 0.10 you have mock-driven design.
Step 5 — Typed-nil audit (staticcheck)¶
SA4023 warns "comparison of typed-nil and untyped-nil never equal." It's the mechanical detector for the famous gotcha. Add it to CI.
Step 6 — interfaces.go hubs¶
Each hit is a candidate for dispersion.
Refactoring Strategy: Strangler vs Big-Bang¶
Strangler — preferred for live systems¶
- Add the new struct-returning constructor next to the old interface-returning one. Mark old one
// Deprecated. - Migrate one consumer at a time to the new constructor.
- When all consumers are migrated, delete the old constructor and (if no one needs it) the interface.
// Old
func New() Repo { return &repo{} }
// New, side-by-side
func NewRepo() *Repo { return &Repo{} } // exported struct
// Old kept temporarily, will be deleted
//
// Deprecated: use NewRepo. The interface return is being removed.
func New() Repo { return NewRepo() }
Big-bang — only when the codebase is small or test coverage is rock-solid¶
Touch every consumer in one PR. Easier to review structurally; risky for production. Best on weekend with a freeze.
When to never refactor¶
If the interface is part of your public API, breaking it costs your users a major version bump. Plan around v1/v2 directories or an entirely new package.
Refactoring Recipe: Header Interface Removal¶
Before¶
// service/repo.go
type Repo interface {
Find(id string) (*User, error)
Save(*User) error
Delete(id string) error
List(filter Filter) ([]*User, error)
Count() (int, error)
}
type pgRepo struct{ db *sql.DB }
func (r *pgRepo) Find(...) { /* ... */ }
// ... five methods
Step 1 — export the struct¶
type PGRepo struct{ db *sql.DB }
func NewPGRepo(db *sql.DB) *PGRepo { return &PGRepo{db: db} }
func (r *PGRepo) Find(...) { /* ... */ }
// ... five methods
Step 2 — at each consumer, declare the smallest interface needed¶
// pkg auth
type userFinder interface {
Find(id string) (*User, error)
}
// pkg admin
type userListing interface {
List(filter Filter) ([]*User, error)
Count() (int, error)
}
Step 3 — delete the original Repo interface¶
If anything still imports it, your refactor is incomplete.
Step 4 — delete generated _mock.go¶
Each consumer-side interface has its own tiny fake (often inline in the test file).
Refactoring Recipe: Mock Explosion Cleanup¶
Before¶
billing/
├── service.go
├── service_test.go // 800 lines, 90% mock setup
├── repository.go // header interface
├── pg_repository.go
├── repository_mock.go // 300 lines, generated
├── notifier.go // header interface
├── notifier_smtp.go
├── notifier_mock.go // 200 lines
Step 1 — replace mocks with hand-written fakes¶
type fakeRepo struct {
users map[string]*User
err error // injectable for failure scenarios
}
func (f *fakeRepo) Find(id string) (*User, error) {
if f.err != nil { return nil, f.err }
return f.users[id], nil
}
A 30-line fake replaces a 300-line mock and exercises real code paths.
Step 2 — shrink the interface to what the test consumes¶
If only Find and Save are touched, the test-side interface has only those two methods.
Step 3 — consider integration tests¶
testcontainers-go spins up a Postgres container in seconds. For storage code, integration tests catch bugs that mocks never can. Mocks for HTTP clients can be replaced by httptest.Server.
Step 4 — delete _mock.go files¶
Run CI. Anything that breaks tells you what was secretly relying on the mock-shaped interface.
Refactoring Recipe: Typed-Nil Audit¶
Step 1 — enable SA4023 in CI¶
# .golangci.yml
linters:
enable:
- staticcheck
issues:
exclude-rules: []
linters-settings:
staticcheck:
checks: ["all", "SA4023"]
Step 2 — search for the pattern¶
Each match is a candidate where someone declared a typed pointer of an error type. Inspect every return afterwards.
Step 3 — fix incrementally¶
Replace return err (where err is *MyErr) with explicit branches:
// Before
var err *MyErr
if condition { err = &MyErr{...} }
return err
// After
if condition {
return &MyErr{...}
}
return nil
Step 4 — add a regression test¶
func TestNoTypedNil(t *testing.T) {
if err := work(); err != nil {
t.Fatalf("expected nil, got typed-nil: %v (%T)", err, err)
}
}
Step 5 — write a custom analyzer (large codebases)¶
For a high-stakes service, a custom golang.org/x/tools/go/analysis analyzer can detect the pattern at PR time:
// Analyzer pseudo-code:
// 1. Find functions returning interface I.
// 2. For each return, if the operand is a *T variable that may be nil and T implements I,
// flag it.
Refactoring Recipe: interfaces.go Dispersion¶
Step 1 — list every interface¶
Step 2 — for each interface, find consumers¶
Step 3 — move the interface into its primary consumer's package¶
If two packages share it, consider whether they really do or whether each needs a smaller subset.
Step 4 — delete the hub¶
Once empty, internal/interfaces.go and any related package can go. Run go vet ./... and go build ./....
Governance: Style Guide Entries¶
Add the following rules to your team Go style guide:
- Accept interfaces, return structs. Constructors return concrete types unless the package's documented purpose is to publish an interface (e.g.
io,http,database/sql). - Define interfaces near consumers. A package may export an interface only when it forms part of the package's public contract.
- Don't generate mocks unless you have at least two real implementations. Use hand-written fakes for tests by default.
- Maximum interface size: 5 methods. Larger interfaces require a written justification in the package doc comment.
- Never use
*Interface. A pull request introducing it is auto-rejected. - Errors are
error, not custom interfaces. Domain error types are concrete structs unwrapped viaerrors.As. - Functions returning
erroruse literalnilreturns. Typed-nil is forbidden. String()andError()must not allocate heavily, panic, or recurse. Reviewers check this on every PR.- No
Get/Setinterfaces. Use struct fields or behavioral methods. - No
Animal/Shape/Vehiclestyle hierarchies. Decompose by capability.
Style guide structure example¶
docs/
└── go-style.md
# Section 7 — Interfaces
7.1 Accept interfaces, return structs
7.2 Define interfaces near consumers
7.3 Maximum 5 methods
7.4 No pointer-to-interface
7.5 Mock-driven design forbidden
7.6 Typed-nil forbidden (CI: SA4023)
7.7 Errors are error
Governance: Linter Pipeline¶
# .golangci.yml
linters:
enable:
- errcheck # forces error handling
- staticcheck # SA4023 typed-nil
- revive # unused-parameter, exported, receiver-naming
- ireturn # constructor returning interface
- interfacebloat # > 10-method interface
- gocritic # paramTypeCombine, ifElseChain
- unused # dead code
- errorlint # encourages errors.As/Is
- goconst # repeated literals
- gocyclo # cyclomatic complexity
linters-settings:
ireturn:
allow:
- error
- empty
- anon
- stdlib
interfacebloat:
max: 5
staticcheck:
checks: ["all"]
Add to CI:
# .github/workflows/lint.yml
- run: golangci-lint run ./...
- run: go vet ./...
- run: staticcheck -checks SA4023 ./...
Block merges on any failure.
Code Review Checklist¶
When reviewing a PR involving interfaces, confirm:
- Is there a real consumer that needs polymorphism?
- Is the interface declared at the consumer side?
- Method count ≤ 5?
- No
Get/Setboilerplate? - No
*Interfaceparameters? - Constructor returns the struct, not the interface?
- Functions returning
erroruse literalnil? - Custom errors are concrete structs, used via
errors.As? -
String()/Error()are cheap, panic-free, recursion-safe? - Mock generation justified by ≥ 2 real implementations?
- No "Animal-style" interface dragging multiple unrelated capabilities?
- Interface signatures use primitives or stdlib types — not deep domain types?
Cheat Sheet¶
SCALE DAMAGE
─────────────────────────────
Compile amplification, test brittleness,
onboarding tax, refactor paralysis,
leaky-cache crashes, errors.Is bypass
INVENTORY TOOLS
─────────────────────────────
golangci-lint: ireturn, interfacebloat
staticcheck SA4023 — typed-nil
grep '\*io\.Reader' / '\*Mailer' — pointer-to-interface
mock-to-impl ratio script
REFACTOR STRATEGIES
─────────────────────────────
Strangler — preferred for live systems
Big-bang — only with rock-solid tests
Header interface removal: export struct, declare consumer-side I
Mock cleanup: hand-written fake | testcontainers | httptest
Typed-nil audit: CI SA4023 + manual sweep
GOVERNANCE
─────────────────────────────
Style guide section 7 — interfaces
Linters: errcheck, staticcheck, revive, ireturn, interfacebloat
PR review checklist (12 items)
Summary¶
At professional level, interface anti-patterns are an organizational cost:
- Damage — compilation, tests, onboarding, refactors, production.
- Inventory — automated detection via linters and AST tools.
- Refactoring — strangler patterns; one-by-one consumer migration.
- Governance — style guide rules, linter pipeline, code review checklist.
The lesson: anti-patterns are not "bad code" but bad architecture compounding over time. Stopping them at PR review is cheaper by an order of magnitude than fixing them after deploy.