Generics vs Interfaces — Senior Level¶
Table of Contents¶
- The four-way decision matrix
- Where interfaces still win
- Where generics still win
- Hybrid patterns
- Dependency injection and plugin systems
- Designing extensible abstractions
- API stability considerations
- Anti-patterns at the senior level
- Summary
The four-way decision matrix¶
A senior engineer faces one of four situations when designing reusable code:
| Situation | Tool |
|---|---|
| Same body, single type per call site, no per-type behaviour | Generics |
| Different bodies, callers want polymorphism | Interfaces |
Same body, but body needs methods on T | Generic + interface constraint |
| Heterogeneous storage, plugin / runtime swap | Interfaces |
The trap is that all four situations look like "I want reuse". The senior's job is to look past the surface and ask: what kind of reuse, and decided when?
Where interfaces still win¶
Despite the appeal of generics, interfaces remain the right answer in several large categories:
1. Heterogeneous collections¶
When a slice or map must hold values of different concrete types, only an interface unifies them under a shared identity:
type Shape interface { Area() float64 }
shapes := []Shape{Circle{1}, Square{2}, Triangle{3, 4}}
total := 0.0
for _, s := range shapes { total += s.Area() }
Generics cannot do this. []Shape[T] is not a thing. Each instantiation Circle, Square, Triangle is a distinct type and they cannot share a slice.
2. Dependency injection¶
Services accept interfaces so they can be swapped in tests:
type UserRepo interface {
Find(ctx context.Context, id int) (*User, error)
}
type UserService struct { repo UserRepo }
func (s *UserService) Get(ctx context.Context, id int) (*User, error) {
return s.repo.Find(ctx, id)
}
The test injects a fake; production injects the real DB-backed version. A generic UserService[R UserRepo] would force every consumer to know about R and propagate the type parameter through every call site. Interfaces erase that surface.
3. Plugin systems¶
Runtime registration of handlers is fundamentally interface-shaped:
type Handler interface { Serve(ctx context.Context, req Request) Response }
var registry = map[string]Handler{}
func Register(name string, h Handler) { registry[name] = h }
The map can hold any concrete Handler. With generics, every handler would need the same T, which defeats the purpose.
4. Standard library protocols¶
io.Reader, io.Writer, error, fmt.Stringer, flag.Value, http.Handler — all interfaces. They will not be replaced by generics. Why?
Because they describe behaviour that varies per implementation: a file reads from disk, a network connection reads from sockets, a bytes.Reader reads from memory. The body of Read is fundamentally different per type. That is exactly the case where interfaces, not generics, are the right tool.
5. Method values¶
You can take a method value off an interface and pass it as a function:
Generics do not give you this kind of "value-level abstraction" — they parametrize types, not values.
6. Late binding¶
Interfaces allow a system to be extended without recompiling the caller. New Handler implementations can be added, registered at runtime, swapped via configuration. Generics require the type to be known at compile time at every instantiation site.
Where generics still win¶
Generics dominate when the body is identical and per-type behaviour is not the point:
1. Algorithmic code over collections¶
Map, Filter, Reduce, Sort, Find, Index, Contains. The body is one shape. Different types only change what is being iterated. Generics are correct.
2. Type-safe containers¶
Stack[T], Queue[T], Set[T comparable], LRUCache[K, V], RingBuffer[T]. The container's logic is the same regardless of element type; only the type of element varies.
3. Numeric utilities¶
Min, Max, Abs, Clamp, Sum, Mean. Operations differ on the type but the algorithm is the same.
4. Wrapping primitives¶
atomic.Pointer[T], sync.OnceValue[T], Result[T], Page[T]. Tiny wrappers that add type safety without changing the underlying primitive's behaviour.
5. Hot-path inner loops¶
If a slice of int is summed a million times per second, the cost of interface{} boxing dwarfs the gain of polymorphism. Use generics so the loop stays flat.
Hybrid patterns¶
The post-1.18 idiom that has emerged is to use both tools at different layers.
Pattern 1 — Generic function over interface constraint¶
type Comparator[T any] interface { Compare(other T) int }
func Sort[T Comparator[T]](s []T) {
sort.Slice(s, func(i, j int) bool { return s[i].Compare(s[j]) < 0 })
}
The function is generic for type safety on the slice; the constraint requires a method, giving per-type comparison logic.
Pattern 2 — Interface for the API, generics inside¶
// Public surface — interface, stable
type Cache interface {
Get(key string) (any, bool)
Set(key string, val any)
}
// Internal — generic, performant
type typedCache[V any] struct{ m map[string]V }
func (c *typedCache[V]) get(k string) (V, bool) { v, ok := c.m[k]; return v, ok }
Library users see the interface; the implementation uses generics for fast typed access.
Pattern 3 — Generic helpers feeding interface-shaped pipelines¶
type Stage interface { Run(in []byte) ([]byte, error) }
func Parallel[T Stage](stages []T, in []byte) ([]byte, error) {
// generic over a typed slice of stages — but each stage is interface-shaped
...
}
The slice is typed (no boxing on element access); the body still calls into per-stage behaviour through the interface methods.
Pattern 4 — Repository with generic helpers and interface methods¶
type Repository[T any] interface {
Find(ctx context.Context, id int) (*T, error)
Save(ctx context.Context, v *T) error
}
func FindOrCreate[T any](ctx context.Context, r Repository[T], id int, factory func() *T) (*T, error) {
if v, err := r.Find(ctx, id); err == nil { return v, nil }
nv := factory()
return nv, r.Save(ctx, nv)
}
Repository[T] is a generic interface. FindOrCreate is a generic helper that uses it. Two different aggregates (User, Order) get different concrete repositories, but FindOrCreate is written once.
Dependency injection and plugin systems¶
Why DI is interface-shaped¶
Dependency injection swaps an implementation at runtime. The site that uses the dependency must not be parametrized by the dependency's type — otherwise every test, mock, and configuration change ripples through.
// Good — interface, runtime swap
type Mailer interface { Send(to, body string) error }
type Service struct { mail Mailer }
// Painful — generic, propagates everywhere
type Service[M Mailer] struct { mail M }
func New[M Mailer](m M) *Service[M] { ... }
// every call site, every type signature, every test now has [M] noise
For DI specifically, the compile-time gain of generics is small (the dependency is rarely on a hot path) and the API friction is large.
Plugin systems¶
A plugin system is the extreme of DI: implementations are not even known at compile time. Generics cannot express this. Interfaces are the only option.
type Plugin interface {
Name() string
Init(cfg map[string]any) error
}
var pluginRegistry = map[string]Plugin{}
A user dropping in a new .so file (or compiling a new binary with extra packages) extends the system without touching the core.
A useful test¶
When in doubt, ask: could a third party provide an implementation, after my code ships?
- Yes → interface.
- No → generic is fine if the body is identical.
Designing extensible abstractions¶
Open for extension via interfaces¶
The classic Go API style is "small interfaces". An interface with one method is the minimum surface needed for users to add their own implementation:
type Stringer interface { String() string }
type Reader interface { Read(p []byte) (n int, err error) }
A new package can provide a new Reader without anyone recompiling. This is how io, net/http, database/sql evolved.
Closed by generics¶
A generic API closes the type set to "whatever satisfies the constraint". That is fine for utilities (Map, Sort) but uncomfortable for evolving systems where new types must be added by callers.
Choosing the open/closed axis¶
| Quality wanted | Tool |
|---|---|
| Open to new implementations | Interfaces |
| Closed, type-safe across known operations | Generics |
| Open in protocol, closed in implementation detail | Interface API + generic internals |
A senior thinks about evolution. Will users provide their own types? If yes, interfaces. If no, generics may be cleaner.
API stability considerations¶
Generics propagate¶
A generic public API forces every caller to mention T. Once published, removing or renaming T breaks consumers. Adding a constraint also breaks consumers — types that used to satisfy any may not satisfy a tighter constraint.
Interfaces are usually additive¶
Adding a method to a public interface breaks callers that implement it externally. But interfaces with method sets that grow rarely is a stable API surface.
Strategy¶
- For shared utility functions, generics with the loosest constraint that compiles. Tighten only with major-version bumps.
- For public protocols (Reader, Writer, Notifier), interfaces. Keep the method set tiny.
- For internal speed-critical code, generics. Internal callers can change.
- For DI-shaped boundaries, interfaces. Tests and configuration depend on it.
Evolving an existing API¶
Migrating an interface-shaped public API to generics is rarely worth it. The Go team itself kept sort.Slice (interface) alive even after introducing slices.Sort (generic). Both exist; old callers do not break; new callers pick the new style. This is the right model.
Anti-patterns at the senior level¶
Anti-pattern 1 — Generic interface explosion¶
type Repository[T any, ID comparable, Q any, R any, F any] interface {
Find(F) (R, error)
Save(T) error
Query(Q) ([]R, error)
...
}
Five type parameters in a public interface is a smell. Split the interface or accept that some operations belong to a sub-interface.
Anti-pattern 2 — Forcing polymorphism into generics¶
// Bad: type switch hides interface dispatch
func Process[T any](v T) {
switch x := any(v).(type) {
case Email: x.Send()
case Slack: x.Send()
}
}
This is a Notifier interface in costume. Replace with func Process(n Notifier).
Anti-pattern 3 — Forcing parametricity into interfaces¶
// Bad: interface with one method per type
type IntCache interface { GetInt(string) (int, bool) }
type StringCache interface { GetString(string) (string, bool) }
The bodies are identical. Replace with Cache[V any].
Anti-pattern 4 — Generic for "future flexibility"¶
A function that takes one concrete type but is written func F[T concrete](v T) "in case we need other types later" pays the cognitive cost now for a benefit that may never come. YAGNI applies to generics too.
Anti-pattern 5 — Interface with one implementation¶
type UserRepo interface { Find(int) (*User, error) }
type pgUserRepo struct{ ... }
// no other implementation, ever
If there is no second implementation and no plan for one, the interface is noise. Use the concrete type. Add the interface only when the second implementation arrives — usually for tests.
Anti-pattern 6 — Generic constraint built from union types when an interface would suffice¶
type Notifier interface {
Notify(string) error
}
// vs
type NotifierConstraint interface {
Email | Slack | SMS
Notify(string) error
}
The second form is closed. New notifier types require updating the constraint. The first form is open. Choose the closed form only when the closed set is a deliberate guarantee.
Summary¶
A senior Go engineer chooses between generics and interfaces based on decision time (compile vs runtime), shape (homogeneous vs heterogeneous), and openness (will third parties add implementations?).
- Interfaces win at architectural seams: DI, plugin systems, heterogeneous collections, evolving APIs, stdlib-style protocols.
- Generics win at the leaves: algorithms, containers, numeric utilities, hot-path inner loops, type-safe wrappers.
- Hybrids are the modern idiom: a generic function over an interface constraint gives compile-time safety and per-type behaviour at the same time.
Three habits that mark a senior:
- Default to interfaces at architectural boundaries, default to generics at leaves, resist abstraction in the middle.
- Resist generic public APIs unless the value is overwhelming. Public generics propagate type parameters everywhere.
- Resist single-implementation interfaces. Add the interface when the second implementation arrives, not before.
Move on to professional.md to see how mature library authors apply these heuristics in real migrations.