DEV Community

Cover image for Accept Interfaces, Return Structs: The Go Idiom, Correctly Applied
Gabriel Anhaia
Gabriel Anhaia

Posted on

Accept Interfaces, Return Structs: The Go Idiom, Correctly Applied


You've read the advice a hundred times. "Accept interfaces, return
structs." It shows up in code review comments, in style guides, in
the Go proverbs talk Rob Pike gave
in 2015. Most people repeat it. Fewer can say why it holds, and
fewer still know the cases where following it blindly makes the code
worse.

The rule is not decoration. It comes straight out of how Go's type
system works: interfaces are satisfied implicitly, and concrete
types carry more information than the interfaces they happen to
match. Once you see those two facts, the rule stops being a mantra
and starts being a consequence.

What the rule actually says

Two halves, and they pull in opposite directions on purpose.

Accept interfaces. A function parameter should ask for the
smallest behavior it needs, not a specific type. If your function
reads bytes, take an io.Reader, not an *os.File.

func CountLines(r io.Reader) (int, error) {
    sc := bufio.NewScanner(r)
    n := 0
    for sc.Scan() {
        n++
    }
    return n, sc.Err()
}
Enter fullscreen mode Exit fullscreen mode

CountLines doesn't care whether the bytes come from a file, a
network socket, a bytes.Buffer, or a strings.Reader. It asks for
one method, Read, and every one of those types provides it. The
caller decides where the data lives.

Return structs. A constructor or factory should hand back the
concrete type, not an interface that hides it.

type Cache struct {
    mu    sync.Mutex
    items map[string]entry
}

func NewCache() *Cache {
    return &Cache{items: make(map[string]entry)}
}
Enter fullscreen mode Exit fullscreen mode

NewCache returns *Cache, not some Cacher interface. The caller
gets the full type, every exported method and field, and can decide
for itself which narrow interface to view it through later.

Why callers want the concrete type

Hand back an interface and you make a decision on the caller's
behalf: you decide which methods they get to see. That decision
almost always ages badly.

Say version one of your library returns an interface with three
methods. Version two adds a Stats() method to the concrete type.
The interface doesn't have it, so callers can't reach it without a
type assertion. You've built a wall between the caller and your own
code, and the only way through is the exact thing the idiom is
trying to avoid:

c := library.NewThing() // returns SomeInterface
real, ok := c.(*library.Thing)
if !ok {
    // now what?
}
real.Stats()
Enter fullscreen mode Exit fullscreen mode

Return the struct and there's no wall. The caller sees Stats() the
moment you add it. If the caller wants to narrow the type down to an
interface, that's their call to make, at their boundary, with an
interface they define. That last part matters, and it's the piece
most write-ups skip.

Interfaces belong to the consumer, not the producer

This is the load-bearing idea. In Go, the package that uses a
behavior should define the interface for it, not the package that
provides it.

Because interfaces are satisfied implicitly, the consumer can define
an interface after the fact, and any existing concrete type that
happens to have the right methods satisfies it. No import, no
implements keyword, no coordination.

// package report — the consumer
type lineCounter interface {
    CountLines(io.Reader) (int, error)
}

func Summarize(lc lineCounter, r io.Reader) string {
    n, _ := lc.CountLines(r)
    return fmt.Sprintf("%d lines", n)
}
Enter fullscreen mode Exit fullscreen mode

The report package owns lineCounter. It lists exactly the one
method Summarize needs. The provider package never mentions this
interface and doesn't need to know it exists. That's the opposite of
the Java habit, where the library ships the interface and every
consumer bends to it.

When you return a struct instead of an interface, you leave that
door open. Every consumer defines the narrow interface it wants.
When you return an interface, you've pre-decided the shape for
everyone, and pre-decided it at the wrong end of the dependency.

The testing payoff

Here's where the two halves pay off together, and it's the reason
the idiom earns its place rather than just sounding tidy.

Because your function accepts an interface, tests can pass a fake.
Because your constructor returns a struct, tests of that struct
use the real thing with no ceremony.

Take a service that sends notifications:

type sender interface {
    Send(ctx context.Context, to, body string) error
}

type Notifier struct {
    s sender
}

func NewNotifier(s sender) *Notifier {
    return &Notifier{s: s}
}

func (n *Notifier) Welcome(ctx context.Context, u User) error {
    return n.s.Send(ctx, u.Email, "welcome aboard")
}
Enter fullscreen mode Exit fullscreen mode

NewNotifier accepts the sender interface, so the test wires in a
fake that records the call:

type spySender struct {
    to, body string
}

func (s *spySender) Send(_ context.Context, to, body string) error {
    s.to, s.body = to, body
    return nil
}

func TestWelcome(t *testing.T) {
    spy := &spySender{}
    n := NewNotifier(spy)
    err := n.Welcome(context.Background(),
        User{Email: "a@b.com"})
    if err != nil {
        t.Fatal(err)
    }
    if spy.to != "a@b.com" {
        t.Fatalf("got %q", spy.to)
    }
}
Enter fullscreen mode Exit fullscreen mode

No mocking framework, no code generation, no DI container. The
interface is small enough to fake by hand in six lines. That's the
direct result of accepting a narrow interface instead of a fat one.
The narrower the interface you accept, the cheaper the fake.

Where the rule breaks

Treat "return structs" as an absolute and you'll hit three cases
where it's wrong.

1. The standard library returns interfaces on purpose. Look at
net.Dial. It returns net.Conn, an interface, because the concrete
type depends on the network: TCP gives you a *net.TCPConn, Unix
sockets a *net.UnixConn. The return type is genuinely
polymorphic, so an interface is the honest signature. Same with
sql.DB drivers and hash.Hash. When the concrete type legitimately
varies at runtime, return the interface.

2. Error returns are interfaces, and that's correct. error is
an interface. Nobody returns a concrete *myError. You return
error so callers unwrap with errors.Is and errors.As rather
than binding to your type. The idiom's "return structs" half was
never about error.

3. A single method set with many implementations. If you're
writing a plugin system where every implementation is genuinely
interchangeable and selected at runtime, a factory returning the
interface is reasonable. The key word is runtime. If the type is
known at compile time, return the struct.

There's also a quieter failure mode: defining an interface with one
implementation and one consumer, right next to each other, "for
testing." If the only reason the interface exists is a fake you
could write against the real type, you added indirection for
nothing. Add the interface when a second consumer or a real fake
needs it, not before.

The mental model

Reduce it to one sentence and it's this: give the caller everything
you know, and ask the caller for as little as you can.

Returning a struct gives them everything you know about the type.
Accepting an interface asks them for the least you can get away with.
The asymmetry is the point. Information flows out of your function at
full width and into it through the narrowest slot that still does the
job.

That's not a style preference. It falls out of implicit satisfaction
and the fact that concrete types are strictly more informative than
the interfaces they match. Follow it where those facts hold, break it
where the standard library breaks it, and you'll write Go that reads
like the standard library instead of like a framework bolted onto it.


Getting this boundary right is half taste and half knowing how Go's
type system actually resolves interfaces under the hood. The
Complete Guide to Go Programming
digs into interface internals, the
itab, and why implicit satisfaction changes how you design packages.
Hexagonal Architecture in Go takes the same idea up a level and
shows where these interface boundaries belong in a service that has
to survive a few years of change.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)