DEV Community

Cover image for Go Interfaces: Why Less Is Almost Always More
Prasad Ekke
Prasad Ekke

Posted on • Edited on • Originally published at Medium

Go Interfaces: Why Less Is Almost Always More

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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{...}
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)