Refactoring & Architecture Audit Checklist
Red flags in package coupling, globals, and init() abuse.
Walk this checklist before a major release, after onboarding spikes, or when compile times and incident rates climb together.
How to Use This Checklist
- Score each tier during a one-hour architecture review or blameless post-incident retro.
- Fix Critical items before adding features on top of the debt.
- Track findings in tickets linked to ADRs when the fix changes boundaries.
- Re-run quarterly or when module count or team size doubles.
Critical - Stop the Bleeding
-
Global mutable state for DB, config, or clients: Replace with constructor injection from
main.- Globals:
var db *sql.DBused across packages - Injection:
NewService(repo Repository)wired once at startup
- Globals:
-
Import cycles between production packages: Extract shared contracts or invert dependency direction.
- Cycle:
handlerimportsserviceimportshandler - Fix: Move interfaces to consumer or
internal/contracts
- Cycle:
-
init()performs I/O or panics on misconfiguration: Fail inmainwith clear errors instead.- init side effect: Opens DB in
init() - main fail-fast:
cfg, err := Load(); if err != nil { log.Fatal(err) }
- init side effect: Opens DB in
-
Business rules only in HTTP handlers: Move validation and workflows to services with unit tests.
- Fat handler: 200-line
ServeHTTPwith SQL - Service:
Register(ctx, email)tested withouthttptest
- Fat handler: 200-line
-
Secrets or endpoints hardcoded in library packages: Load from env/flags in composition root only.
- Hardcoded:
const apiURL = "https://prod..." - Config struct:
Config.APIURLvalidated at boot
- Hardcoded:
High - Coupling and Boundaries
- God package (
util,common,helpers): Split by capability (internal/timeutil,internal/validate). - Domain packages import
net/http, gin, or gRPC generated code: Move adapters tointernal/adapter/*. - Wide interfaces (10+ methods) consumed by one caller: Split or define consumer-side one-method ports.
- Exported identifiers that no external module uses: Unexport or move to
internal/to freeze API surface. cmd/binaries duplicating wiring divergently: Extract sharedProvide*helpers or Wire set.- Repositories return HTTP DTOs or ORM models with framework tags: Map to domain structs at repository boundary.
- Missing
context.Contexton I/O functions: Addctxas first parameter; plumb from handlers.
Medium - Maintainability Signals
- Test suite requires Docker for pure business logic tests: Introduce fakes at service boundaries.
- Duplicate error strings instead of sentinels: Use
var ErrNotFound = errors.New(...)anderrors.Is. - Panics for expected errors in libraries: Return errors; reserve panic for programmer bugs.
- Blank imports (
_) without comment explaining side effect: Document registration purpose or remove. - Packages named after layers only (
models,controllers) with cross-imports: Reorganize by feature slice. - More than three levels of nested packages without ownership: Flatten or align to team boundaries.
replacedirectives committed for local laptop paths: Usego.worklocally; keepgo.modportable.- No ADR for last major boundary change (split, ORM adoption, RPC): Write ADR before the next change piles on.
Low - Polish and Future Cost
- Inconsistent logging (fmt, log, slog mixed): Standardize on
slogor team logger at edges. - Handlers without request size limits or timeouts: Add
http.Servertimeouts andMaxBytesReader. - Public module lacking LICENSE and tagged releases: Add LICENSE; tag semver for consumers.
- Generated code edited by hand: Regenerate from
go generate,sqlc, orbufin CI. - Comments describe obsolete architecture ("temporary until v2") years later: Update docs or finish migration.
Applying the Checklist in Order
- Critical (1-5): Address before scaling traffic or team - incidents and data corruption risk dominate.
- High (6-12): Schedule in the current quarter - each item slows every feature afterward.
- Medium (13-20): Batch into refactors adjacent to feature work - boy scout rule per package.
- Low (21-25): Tie to platform chores sprint - reduces onboarding friction.
FAQs
How long should a full audit take?
One to two hours for a medium module with go mod graph and package import review.
Deeper dives need per-feature owners.
What tools automate red-flag detection?
go mod graph, golangci-lint (depguard, forbidigo), custom go:generate import cycle checks, and go vet ./....
Should I fix everything at once?
No.
Fix Critical, then strand High items into feature tickets - big-bang rewrites rarely ship.
What is a acceptable god package size?
No fixed line count - if three teams edit it weekly and imports fan out, split regardless of LOC.
How do I audit init() usage?
grep -R "func init()" internal/ cmd/ and classify each as register-only vs I/O.
Ban I/O inits in lint rules.
When is util/ still OK?
Tiny, stable helpers with no business meaning and one owner team.
Rename to specific purpose when it grows.
How does this relate to microservice splits?
Items 1-12 must be green before RPC extraction - otherwise you distribute spaghetti.
What evidence goes in an ADR after audit?
Import graph screenshot, incident IDs, compile time trend, and test flake rate.
Can interns run this checklist?
Yes with a senior sponsor - great onboarding exercise reading package imports.
How often do globals sneak back?
After quick hacks - add depguard linter rule blocking var .* \*sql.DB outside cmd/.
Related
- Architecture in Go: Small Interfaces, Explicit Dependencies - Target architecture
- Handler-Service-Repository Layering - Layer refactor destination
- Architecture Decision Records for Go Teams - Record outcomes
- Common Anti-Patterns in Go Codebases - Code-level smells
- Architecture & Design Best Practices - Prevention habits
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).