Hermes Wiki
Developer/Languages/Go/StandardLibraryIdioms/Fundamentals/interface-design-and-generics-idioms-in-go

Interface Design and Generics Idioms in Go

Concept

Go interfaces are satisfied structurally — a type implements an interface simply by having the right methods, with no implements keyword and no import dependency from the implementing type back to the interface. That structural, implicit satisfaction is what makes the community idiom "accept interfaces, return structs" work: a function should take the narrowest interface it actually needs as a parameter, defined in the consuming package at the point of use, so any caller can pass a real implementation, a test double, or an in-memory fake without either side importing the other. The same function should return a concrete struct, not an interface, so the caller gets full access to every field and method without a type assertion, and so adding a new exported method later isn't a breaking change to the interface contract. Declaring the interface next to the type that implements it (as in Java or C#) inverts this: every caller ends up depending on one large interface whether it needs two methods or twenty, and the interface can't shrink without breaking every implementer.

Generics (type parameters, Go 1.18+) solve a distinct problem: writing one algorithm that behaves identically across concrete types — a Filter/Map, a Min/Max, a thread-safe generic cache. Before generics, this meant code generation, reflect (slow, and type errors only surface at runtime), or interface{} parameters with type assertions scattered through the body (loses compile-time type safety and adds allocation from boxing, see Profiling, Escape Analysis, and Allocation-Conscious Go). A type parameter is constrained by an interface that describes what operations it must support (constraints.Ordered for anything usable with </>, or a custom constraint listing an allowed type set) — the compiler checks that constraint at every call site, so a type that doesn't satisfy it is a compile error, not a panic three calls deep in production.

Generics and interfaces are not competing solutions to the same problem: a type parameter is resolved once at compile time into one concrete instantiation per type used, with no runtime dispatch cost, and it's the right tool when the varying part is purely which type of data is being handled the same way. An interface is the right tool when the varying part is behavior — different implementations doing genuinely different things behind the same method signature (a real database vs. an in-memory fake, a real API client vs. a mock).

Tradeoffs

Choice Benefit Cost
Accept a narrow interface (defined at point of use) Callers substitute any implementation (mock, fake, alternate backend) without touching the function; no import cycle risk Interface has to be introduced and kept in sync with what the function actually calls — pure boilerplate if there's genuinely only ever one implementation
Accept a concrete type Simpler call sites, no vtable/interface-dispatch indirection Caller can't substitute a test double without either a real instance or restructuring the function later
Generics (type parameter) Compile-time type safety, one implementation reused across types, zero interface-dispatch overhead Only helps when the operation is purely structural; adding per-type special cases inside a generic function via type switches erodes the benefit fast
interface{}/any + type assertion (pre-1.18 pattern) Works on any Go version, sometimes less code for a genuine one-off Type errors surface at runtime as panics, not at compile time; each assertion is a place a wrong type can slip through review
Large interface defined by the implementer One declaration covers every method the concrete type has Every consumer depends on the whole interface regardless of how much of it they actually use, and it can't be trimmed without breaking all implementers

When to use / when not to

  • Reach for generics when the operation is purely structural — it works identically regardless of the concrete type (sort a slice, compute a min/max, implement a generic container) — and doesn't need to branch on which type it received.
  • Prefer an interface when what actually varies between callers is behavior, not just the type of data flowing through — a repository that might be Postgres or an in-memory fake, a notifier that might be email or Slack.
  • Declare interfaces in the package that consumes them, sized to exactly what that caller uses (often one to three methods), not in the package that implements them.
  • Don't reach for a generic implementation to avoid three near-identical concrete functions if those functions are likely to diverge in behavior over time — a generic function that grows type-switches per type case has lost the reason to be generic.

Common pitfall

Defining a large interface in the package that implements it — a Repository with fifteen methods declared right next to its own concrete struct — and requiring every consumer to depend on the whole interface even when a given caller only ever calls two of those methods. This inverts the Go idiom (interfaces belong to consumers, sized to what they use) and makes testing harder in a very concrete way: a test that needs to exercise one code path has to implement or mock all fifteen methods to satisfy the interface, not just the two the code under test actually calls.

Engineering Lens

Both idioms reduce to the same review question in different clothing: does this abstraction exist because a caller genuinely needs to substitute something (a different implementation, a test double, a different concrete type doing the identical structural operation), or was it introduced out of habit — "always code to an interface," carried over from a language where interfaces are the only form of polymorphism? Go's implicit interface satisfaction makes over-abstracting cheap to write and easy to miss in review, since there's no implements Repository declaration flagging that a type has taken on a broad contract; the cost shows up later, as every test for that type discovering it has to stand up the entire interface just to exercise a fraction of it.

Sources

Hermes Wiki