Interfaces
Consumer-defined
Define interfaces where they are used, not where they are implemented – it keeps code simpler, more flexible, and easier to test.
Advantages of consumer-defined interfaces:
- Decoupling – The producer (e.g., a library) doesn't need to know or define every interface. The consumer defines only what it needs.
- Minimal surface – You can declare tiny interfaces (e.g.,
type Reader interface { Read(p []byte) (int, error) }
) that fit your exact use case, instead of depending on a large one. - Improved testability – You can easily create mocks or stubs that satisfy the consumer's interface without pulling in the full producer implementation.
- Flexibility – Different producers can be swapped in as long as they satisfy the consumer-defined interface, even if they were never designed to work together.
- Avoids overdesign – Producers don't have to anticipate all future use cases; consumers define what they actually need.
- Better maintainability – Changes in the producer's API don't force breaking changes on consumers as long as the consumer's small interface contract still holds.
Producer-defined
Producer-defined interfaces are the better choice over consumer-defined ones in these cases:
1. Frameworks, plugins, and callbacks
When your package calls user code, you must specify the hook/handler contract. Examples: http.Handler, sort.Interface, testing.TB, database drivers (database/sql/driver).
type Handler interface {
Handle(Event) error
}
2. Cross-package capability standards
When an interface is meant to be a shared ecosystem contract so different packages interoperate consistently. Examples: io.Reader/Writer, encoding.TextMarshaler, fmt.Stringer.
type Stringer interface { String() string }
3. Hiding implementation + stable return types
When you want to return an abstraction and reserve the right to swap internals without breaking users. Return an interface from constructors/factories to avoid leaking concrete types.
func NewStore(dsn string) (Store, error) { /* ... */ }
type Store interface {
Get(key string) (val []byte, err error)
Put(key string, val []byte) error
}
4. Enforcing invariants / semantics
When correctness depends on semantic guarantees (not just method shapes) that the producer must define and document.
5. Multiple implementations shipped by the producer
When your package exposes several interchangeable implementations behind one API (e.g., different storage backends).
6. Generic constraints (Go 1.18+)
When you need producer-owned interface constraints to guarantee lawful behavior across generic functions (e.g., a Hasher or Comparable law).
7. Preventing interface fragmentation
When many consumers would otherwise invent slightly different tiny interfaces, a single canonical producer interface improves ecosystem cohesion.
Trade-offs:
- Adding a method to a public interface is breaking. Keep producer interfaces small and stable.
- Returning interfaces can reduce discoverability of concrete-type helpers; provide optional accessors or doc clear guidance.