Go Idioms: Composition Over Inheritance
Go code feels native when it favors composition over inheritance-shaped designs.
Search across all documentation pages
Go code feels native when it favors composition over inheritance-shaped designs.
That means small structs, explicit constructors, narrow interfaces, and middleware-style function wrapping instead of deep class hierarchies copied from other languages.
Languages with class inheritance encourage "is-a" modeling: a CachedUserService is a UserService is a BaseService.
Go deliberately omits that mechanism.
A struct holds data; methods attach behavior with receivers.
When two types need similar capabilities, you either embed another type to promote its methods, or you program to a shared interface defined at the consumer.
Embedding looks like inheritance but behaves like delegation.
An outer struct with an anonymous http.Handler field gets ServeHTTP promoted onto itself, yet the outer type is not substitutable for the inner type in all contexts.
Interfaces are satisfied implicitly: if your type has the methods, it implements the interface without declaration.
That enables the idiom accept interfaces, return structs - callers depend on the smallest behavior they need; your package returns concrete types so callers are not locked into your interface shapes.
Native Go APIs also favor constructor functions (NewServer, OpenDB) that return initialized values with unexported fields, plus optional functional options for configuration without telescoping New(a, b, c, ...) signatures.
Day-to-day Go architecture stacks composition primitives in predictable layers:
HTTP request
│
▼
middleware (func wrapping func)
│
▼
handler (concrete struct, depends on interface)
│
▼
service (business rules, accepts Repository interface)
│
▼
repository (postgres / mock implementation)Middleware is composition via functions: func(http.Handler) http.Handler wraps behavior without subclassing.
Each layer adds logging, auth, or metrics, then calls the inner handler.
Service and repository boundaries use consumer-defined interfaces (type UserStore interface { Get(ctx, id) (User, error) }) so tests swap fakes without code generation.
Functional options compose configuration at construction time: NewClient(WithTimeout(2*time.Second), WithRetries(3)) builds a valid client without exported mutable fields.
sync.Once composes safe lazy initialization into a package without init() ordering traps.
| Pattern | Strength | Weakness | Best Fit |
|---|---|---|---|
| Small interfaces | Easy fakes, stable tests | Many named types | Domain services, libraries |
| Embedding | Fast reuse of defaults | Hidden promoted API | Decorators, thin wrappers |
| Functional options | Extensible constructors | Slight learning curve | Clients, servers, SDKs |
| Function middleware | Minimal ceremony | Harder with multi-method APIs | HTTP, gRPC interceptors |
| Global singletons | Convenient access | Hidden deps, test pain | Avoid; prefer explicit wiring |
Framework routers (chi, gin, echo) and gRPC interceptors all compose through functions and interface-shaped hooks.
Your domain packages should still expose domain interfaces, not router types, so swapping infrastructure does not rewrite business logic.
Mature Go teams encode composition rules in review checklists, not folklore.
Prefer plain functions until a second implementation appears; then extract an interface at the consumer package.
Keep interfaces to one or two methods when possible - io.Reader, io.Writer, and http.Handler are the scale to imitate.
Avoid interface pollution: declaring interfaces beside every concrete type "for testing" often produces wide, unstable contracts.
Use embedding for true delegation (wrapping http.ResponseWriter to capture status codes), not to simulate inheritance hierarchies.
Functional options belong on exported constructors with several orthogonal settings.
Do not use them for internal helpers with two parameters.
Repository and service layers are composition at the package boundary: services orchestrate; repositories isolate SQL/HTTP/SDK details.
Controllers stay thin.
Anti-patterns to watch: god packages that mix HTTP, SQL, and business rules; panic-driven control flow; init() that dials production databases; singletons that hide dependencies from signatures.
Go 1.18+ generics reduce repetitive collection helpers but do not replace interface composition at system boundaries.
Reach for generics inside algorithms; keep external APIs idiomatic structs and interfaces.
sync.Once guards one-time initialization; dependency injection with explicit parameters remains the default for testability.No class inheritance or method overriding across a type hierarchy.
Go offers embedding for method promotion and interface polymorphism for behavior substitution.
Embed when you want promoted methods on a thin decorator (for example, wrapping http.Handler).
Wrap in a named field when you need to hide or selectively expose inner behavior.
Often one method for infrastructure boundaries (Store, Publisher, Clock).
Add methods only when callers truly need them together.
Define them where they are consumed (the package that calls the behavior).
Implementations in other packages satisfy them implicitly.
They are common in stdlib-style packages (grpc, otel) but optional.
Use them when constructors would otherwise sprawl across many parameters.
Pass interfaces or function parameters into constructors.
Tests supply fakes without subclassing production types.
In Go, yes in practice: a function wraps an http.Handler (or gRPC handler) to add cross-cutting behavior before delegating inward.
When business services need stable tests without a database, or when storage may change (Postgres, cache, remote API).
Package-level mutable state hides dependencies, complicates parallel tests, and encourages init-order bugs.
Prefer explicit constructor injection.
Generics help shared algorithms over comparable types.
Runtime polymorphism and external integration points still lean on interfaces.
Java/C# style hierarchies, exported struct fields on library types, wide interfaces, and panic for expected errors are common signals.
Start with the simplest composition that solves the problem.
Reach for options, repositories, or middleware when a second axis of change appears.
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 at build).
Reviewed by Chris St. John·Last updated Jul 19, 2026