Architecture in Go: Small Interfaces, Explicit Dependencies
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.
Summary
- Go architecture works best when dependencies are explicit (passed in constructors) and contracts are small (one- or two-method interfaces defined at the call site).
- Insight: Large frameworks hide wiring; Go's toolchain rewards flat import graphs, fast builds, and packages you can reason about in a single review.
- Key Concepts: accept interfaces, return structs, dependency direction,
internal/, composition, constructor functions, package API surface. - When to Use: Designing a new service, splitting a monolith package, publishing a library, or refactoring globals and
init()side effects. - Limitations/Trade-offs: More boilerplate at
main; no magical cross-cutting wiring; architecture discipline is enforced by convention and review, not the compiler alone. - Related Topics: Layered handlers, hexagonal ports, dependency injection tools, module layout, and ADRs for boundary decisions.
Foundations
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.
Mechanics & Interactions
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}
}Advanced Considerations & Applications
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.
Common Misconceptions
- "Every type needs its own interface file." - Interface pollution creates wide mocks; define interfaces at the consumer when abstraction earns its keep.
- "Embedding structs is inheritance." - Embedding promotes methods for convenience; it does not substitute Liskov-style hierarchies or protected state.
- "DI frameworks are required for large Go apps." - Many production codebases use plain
mainwiring orwire; runtime containers are optional, not idiomatic defaults. - "internal/ blocks all external use." - It blocks imports outside the parent tree; published libraries still need a deliberate public API in non-internal packages.
- "More packages always means better architecture." - Tiny packages with churny APIs increase friction; boundaries should follow team ownership and stability needs.
FAQs
Why define interfaces at the consumer instead of the provider?
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.
How small should an interface be?
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.
Where should dependency construction live?
In cmd/<app>/main.go or a dedicated internal/wiring package called only from main.
Libraries should expose constructors; applications choose concrete implementations.
How do explicit dependencies interact with context?
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.
When is a global variable acceptable?
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.
Does Go need a domain layer package name?
No mandated name.
Teams use domain, core, or feature folders (billing, shipping).
Consistency inside your module matters more than the label.
How do small interfaces help microservice extraction?
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.
What is wrong with init() for wiring?
init() order is implicit and hard to test.
Side effects at import time surprise library consumers and slow binaries that import the package accidentally.
Should repositories return domain types or DTOs?
Prefer domain types inside services; map driver rows at the repository edge.
Leaking sql.NullString into handlers couples HTTP to storage schema.
How does internal/ relate to multi-module repos?
internal/ enforcement is per module tree.
Splitting modules changes which import paths are "outside"; plan paths before publishing shared libraries.
Are function types interfaces?
Yes - a function with the right signature satisfies a function type interface.
Useful for hooks (http.HandlerFunc, small callbacks) without declaring a named interface.
How do I prevent import cycles with interfaces?
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.
Related
- Go Architecture Basics - Runnable layout and layering examples
- Handler-Service-Repository Layering - HTTP edge patterns
- Clean & Hexagonal Architecture in Go - Ports and adapters
- Dependency Injection: wire, dig & Manual Wiring - Wiring trade-offs
- Refactoring & Architecture Audit Checklist - Red flags to fix early
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).