Go Idioms: Composition Over Inheritance
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.
Summary
- Go programs share behavior through structs, methods, embedding, interfaces, and functions - never through subclassing or virtual inheritance trees.
- Insight: Services stay testable when dependencies are interfaces you can fake, and packages stay readable when APIs expose constructors plus behavior instead of mutable global state.
- Key Concepts: embedding, interface satisfaction, accept interfaces / return structs, functional options, constructor functions, middleware chains, sync.Once.
- When to Use: Designing package boundaries, HTTP/gRPC handlers, repositories, plugins, and any API where multiple implementations must coexist without framework lock-in.
- Limitations/Trade-offs: Composition can look repetitive before generics; embedding can hide promoted methods; over-abstracted interfaces slow onboarding; clever patterns (singletons, option structs) add cost when plain functions suffice.
- Related Topics: Functional options, constructor APIs, repository layers, middleware decorators, sync.Once initialization, common anti-patterns.
Foundations
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.
Mechanics & Interactions
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.
Advanced Considerations & Applications
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.
Common Misconceptions
- "Embedding is inheritance" - Embedding promotes methods but does not provide Liskov substitutability or protected fields; the outer type is a distinct type.
- "I need a base struct for shared fields" - Shared data belongs in named helper types or constructor wiring, not faux parent classes with overridden methods.
- "Interfaces should be declared next to implementations" - Consumer-side interfaces stay smaller and more stable; producer-side interfaces often become kitchen-sink contracts.
- "Functional options are always better than config structs" - Options shine for libraries with many orthogonal settings; a single config struct is clearer when fields are always set together.
- "sync.Once means I should use singletons everywhere" -
sync.Onceguards one-time initialization; dependency injection with explicit parameters remains the default for testability.
FAQs
Does Go have any form of inheritance?
No class inheritance or method overriding across a type hierarchy.
Go offers embedding for method promotion and interface polymorphism for behavior substitution.
When should I embed versus wrap?
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.
How small should an interface be?
Often one method for infrastructure boundaries (Store, Publisher, Clock).
Add methods only when callers truly need them together.
Where do I define interfaces?
Define them where they are consumed (the package that calls the behavior).
Implementations in other packages satisfy them implicitly.
Are functional options required for professional APIs?
They are common in stdlib-style packages (grpc, otel) but optional.
Use them when constructors would otherwise sprawl across many parameters.
How does composition interact with testing?
Pass interfaces or function parameters into constructors.
Tests supply fakes without subclassing production types.
Is middleware the same as the decorator pattern?
In Go, yes in practice: a function wraps an http.Handler (or gRPC handler) to add cross-cutting behavior before delegating inward.
When is a repository layer worth it?
When business services need stable tests without a database, or when storage may change (Postgres, cache, remote API).
Why do Go developers distrust singletons?
Package-level mutable state hides dependencies, complicates parallel tests, and encourages init-order bugs.
Prefer explicit constructor injection.
Can generics replace interfaces?
Generics help shared algorithms over comparable types.
Runtime polymorphism and external integration points still lean on interfaces.
What makes code feel non-idiomatic?
Java/C# style hierarchies, exported struct fields on library types, wide interfaces, and panic for expected errors are common signals.
How do I choose among patterns in this section?
Start with the simplest composition that solves the problem.
Reach for options, repositories, or middleware when a second axis of change appears.
Related
- Go Patterns Basics - hands-on pattern primitives
- Functional Options Pattern - configurable constructors
- Constructor Functions & Package APIs - New/Open idioms
- Middleware & Decorator Patterns - handler chaining
- Common Anti-Patterns in Go Codebases - what to avoid
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).