Architecture in Go: Small Interfaces, Explicit Dependencies
Go does not ship inheritance, annotations, or a runtime dependency injection container.
Search across all documentation pages
Go does not ship inheritance, annotations, or a runtime dependency injection container.
Those absences are features: they push teams toward small interfaces, constructor wiring, and package boundaries that compile cleanly and stay testable as services grow.
internal/, composition, constructor functions, package API surface.init() side effects.main; no magical cross-cutting wiring; architecture discipline is enforced by convention and review, not the compiler alone.Go packages are the primary unit of reuse.
A package exports a set of identifiers; everything else stays private.
There is no subclassing: behavior is shared through composition (embedding structs) and interfaces (behavioral contracts).
Interfaces in Go are implicit.
A type satisfies an interface by implementing its methods - no implements keyword.
That encourages defining interfaces where they are consumed, not where types are declared.
The classic rule is accept interfaces, return structs.
Callers depend on narrow behavior; implementations stay concrete and evolvable behind constructors like NewStore(cfg).
Explicit dependencies mean a function or struct receives what it needs as parameters or fields.
Globals, package-level singletons, and heavy init() chains obscure data flow and make tests order-dependent.
cmd/<binary>/main.go is the composition root: the only place that should know about databases, queues, and concrete HTTP routers.
Dependency direction should point inward toward domain logic.
HTTP handlers, gRPC servers, and CLI commands sit at the edge.
They translate wire formats into domain types and call services that know nothing about JSON tags or status codes.
Repositories and clients talk to the outside world (SQL, S3, other HTTP APIs).
Domain packages should not import net/http, database/sql drivers, or vendor SDKs unless the domain truly owns that concern.
HTTP / gRPC / CLI (adapters)
|
v
application services (use cases)
|
v
domain types + rules
^
|
repositories / gateways (ports implemented by adapters)
Small interfaces keep mocks honest.
If Store has twelve methods, every test double implements twelve methods - most unused.
Split interfaces by caller need: Reader, Writer, or a single PutObject function type.
The compiler still checks satisfaction; tests swap fakes without codegen.
Package boundaries use directory names and internal/ to prevent "friend imports" across teams.
A service module might expose example.com/billing publicly while keeping example.com/billing/internal/postgres private to the module.
Import cycles are compile errors, not warnings - architecture mistakes surface early.
// Consumer defines the contract (often 1-2 methods).
type Ledger interface {
Post(ctx context.Context, entry Entry) error
}
// Service depends on the interface, not postgres or mysql.
type Service struct {
ledger Ledger
}
func New(ledger Ledger) *Service {
return &Service{ledger: ledger}
}Microservices written in Go still benefit from the same in-process rules before you split binaries.
Clear packages and interfaces become the seams where you later extract a gRPC boundary.
Premature service extraction without package discipline usually copies tangled imports into network latency.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Manual constructor wiring | Obvious graph, zero magic, fast builds | Verbose main, refactor touch points | Most services and libraries |
Compile-time DI (wire) | Generated wiring, still explicit | Codegen step, learning curve | Large graphs with stable constructors |
Runtime containers (dig, fx) | Convenient for plugin-style apps | Hidden dependency order, harder static analysis | Long-lived processes with many optional components |
| Global singletons | Fast first commit | Hidden coupling, brittle tests | Prototypes only |
Frameworks (Gin, Echo, chi) belong at the edge.
Register routes in main or a thin internal/http package; keep business packages framework-agnostic so tests call plain functions.
Observability crosses layers through middleware and context, not globals.
Pass context.Context for cancellation; attach loggers and trace IDs with slog or OpenTelemetry APIs at the boundary.
Generics (Go 1.18+) reduce helper duplication but do not replace interface boundaries for I/O and external systems.
main wiring or wire; runtime containers are optional, not idiomatic defaults.Consumers know the minimal behavior they need.
Providers stay concrete and can add methods without breaking callers that never imported a wide interface.
This pattern is idiomatic Go, not a testing hack.
Often one method for I/O boundaries (Store, Publisher).
Two or three methods when a single caller truly needs a cohesive group.
If tests stub many unused methods, the interface is too wide.
In cmd/<app>/main.go or a dedicated internal/wiring package called only from main.
Libraries should expose constructors; applications choose concrete implementations.
context.Context carries request-scoped values and cancellation.
It replaces thread-local globals for deadlines and trace metadata.
Do not hide databases behind context; pass repositories explicitly.
Rarely: immutable registries loaded once, standard library patterns, or process-wide metrics with clear init order.
Mutable globals for DB handles or config are a red flag.
No mandated name.
Teams use domain, core, or feature folders (billing, shipping).
Consistency inside your module matters more than the label.
A narrow Ledger port in the monolith becomes a natural gRPC service boundary.
Wide structs that mix HTTP, SQL, and metrics do not split cleanly.
init() order is implicit and hard to test.
Side effects at import time surprise library consumers and slow binaries that import the package accidentally.
Prefer domain types inside services; map driver rows at the repository edge.
Leaking sql.NullString into handlers couples HTTP to storage schema.
internal/ enforcement is per module tree.
Splitting modules changes which import paths are "outside"; plan paths before publishing shared libraries.
Yes - a function with the right signature satisfies a function type interface.
Useful for hooks (http.HandlerFunc, small callbacks) without declaring a named interface.
Move shared contracts to a lower package both sides import, or invert dependency so only one direction references the other.
Cycles mean the boundary drawing is wrong, not that you need a workaround import.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026