Go Interfaces: Why Less Is Almost Always More
If you’re coming to Go from Java or C++, interfaces look familiar at first glance. You define a set of methods, types implement them, you write code against the interface. Same idea, right?
Not quite. Go interfaces have one property that changes everything about how you should design them: a type implements an interface implicitly, without declaring it. There’s no implements keyword. If your type has the right methods, it satisfies the interface — whether it knows about the interface or not.
That single property pushes you toward a design philosophy that trips up most engineers coming from other languages: interfaces should be small, and they should be defined by the consumer, not the producer.
What the standard library is trying to tell you
Look at the most-used interfaces in Go’s standard library:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
One method each. That’s not laziness — that’s a deliberate design decision. Because interfaces are satisfied implicitly, a one-method interface can be satisfied by an enormous range of types: files, network connections, buffers, custom types you haven’t written yet. The smaller the interface, the more things satisfy it, the more reusable your code becomes.
io.ReadWriter combines two:
type ReadWriter interface {
Reader
Writer
}
And io.ReadWriteCloser combines three. Notice that the standard library builds up complexity by composing small interfaces, not by defining large ones upfront.
This is the pattern worth internalizing.
The fat interface trap
Here is how engineers from Java or C++ backgrounds tend to define interfaces in Go. Say you’re building a job processing system and you want to abstract over different job stores:
// ❌ The fat interface
type JobStore interface {
AddJob(job Job) error
GetJob(id string) (Job, error)
DeleteJob(id string) error
ListJobs(filter Filter) ([]Job, error)
UpdateJob(job Job) error
CountJobs(filter Filter) (int, error)
MarkComplete(id string) error
MarkFailed(id string, reason error) error
}
This feels complete. It covers everything the store can do. But now ask: how many types in your codebase will ever implement all 8 methods? Probably one — your real database implementation. And your test mock has to implement all 8 even if the function under test only calls GetJob.
// Every test mock must implement all 8 methods even if irrelevant
type mockStore struct{}
func (m *mockStore) AddJob(job Job) error { return nil }
func (m *mockStore) GetJob(id string) (Job, error) { return Job{}, nil }
func (m *mockStore) DeleteJob(id string) error { return nil }
func (m *mockStore) ListJobs(filter Filter) ([]Job, error) { return nil, nil }
func (m *mockStore) UpdateJob(job Job) error { return nil }
func (m *mockStore) CountJobs(filter Filter) (int, error) { return 0, nil }
func (m *mockStore) MarkComplete(id string) error { return nil }
func (m *mockStore) MarkFailed(id string, reason error) error { return nil }
Eight methods of boilerplate for a test that calls one. And if you add a 9th method to the interface, every mock in the entire codebase breaks.
The Go way: consumer-defined, minimal interfaces
Instead of defining one big interface at the store level, define small interfaces at each use site — the function that actually needs the behavior.
// ✅ Each function declares exactly what it needs
type JobGetter interface {
GetJob(id string) (Job, error)
}
type JobAdder interface {
AddJob(job Job) error
}
type JobCompleter interface {
MarkComplete(id string) error
MarkFailed(id string, reason error) error
}
func processJob(id string, store JobGetter) error {
job, err := store.GetJob(id)
if err != nil {
return err
}
// process...
return nil
}
func submitJob(job Job, store JobAdder) error {
return store.AddJob(job)
}
Now processJob only depends on JobGetter. Its test mock is one method:
type mockGetter struct {
job Job
err error
}
func (m *mockGetter) GetJob(id string) (Job, error) {
return m.job, m.err
}
Your real PostgresJobStore still implements all 8 methods. It satisfies every one of these small interfaces automatically, without any change, because Go interfaces are implicit. The store doesn’t know or care about JobGetter or JobAdder — it just has the methods, and that’s enough.
Accepting interfaces, returning structs
There’s a corollary rule in Go that follows directly from this: accept interfaces, return concrete types.
// ✅ Accept an interface — flexible, testable
func drainQueue(queue <-chan Job, store JobAdder) error {
for job := range queue {
if err := store.AddJob(job); err != nil {
return err
}
}
return nil
}
// ❌ Return an interface — forces callers to type-assert, hides information
func newJobStore(cfg Config) JobStore {
return &PostgresJobStore{...}
}
// ✅ Return a concrete type — callers get the full picture
func newJobStore(cfg Config) *PostgresJobStore {
return &PostgresJobStore{...}
}
When you return a concrete type, callers can always choose to assign it to a narrower interface themselves. When you return a fat interface, you’ve already made that decision for them, and they’re stuck with it.
The one exception: returning error is an interface, and that’s intentional — it lets you return any error type, including custom ones. But error is a one-method interface, so the rule still holds in spirit.
Composing small interfaces when you need more
Sometimes a function genuinely needs multiple behaviors. Rather than reaching for a fat interface, compose:
type JobProcessor interface {
JobGetter
JobCompleter
}
func runWorker(id string, store JobProcessor) error {
job, err := store.GetJob(id)
if err != nil {
return err
}
if err := doWork(job); err != nil {
return store.MarkFailed(id, err)
}
return store.MarkComplete(id)
}
JobProcessor is defined at the call site, composed from two smaller interfaces, and PostgresJobStore satisfies it without modification. The test mock now only needs three methods.
Summary
Interface size — Avoid: 8-method interfaces defined upfront. Prefer: 1–3 method interfaces defined at the use site.
Where to define — Avoid: the producer side (the package that has the struct). Prefer: the consumer side (the package that calls the function).
What to return — Avoid: interfaces from constructors. Prefer: concrete types from constructors.
Complexity — Avoid: a single large interface. Prefer: composition of small interfaces.
Go’s implicit interface satisfaction is not just a syntactic convenience — it’s the mechanism that makes all of this work without coordination between packages. A type in one package can satisfy an interface defined in a completely different package, written years later, with no coupling between them.
The standard library’s one-method interfaces aren’t minimal because Go is simple. They’re minimal because the designers understood what implicit satisfaction makes possible.
Next in this series: Three Go concurrency mistakes I see in almost every worker pool.
Top comments (0)