DEV Community

Cover image for Define Interfaces Where You Use Them: The Go Rule That Shrinks Your API
Gabriel Anhaia
Gabriel Anhaia

Posted on

Define Interfaces Where You Use Them: The Go Rule That Shrinks Your API


You open a Go repo and find a package called interfaces. Inside it,
a UserStore interface with fourteen methods. Every package in the
codebase imports it. The concrete Postgres type implements all
fourteen, even though the email service that depends on it only ever
calls one: GetUser.

That shape comes from Java, C#, and most other object-oriented
languages, where interfaces live next to the class that implements
them. Go inverts it. The idiom, stated in the standard library's own
comment style and repeated across the Go team's talks, is: define the
interface in the package that consumes it, not the package that
produces the concrete type.

This is not a style preference. It changes how big your API is, how
easy your fakes are, and whether your packages can even compile
without import cycles.

The producer-side habit and what it costs

Here is the Java-shaped version in Go. A store package exports both
the interface and the implementation.

package store

type UserStore interface {
    GetUser(ctx context.Context, id string) (User, error)
    CreateUser(ctx context.Context, u User) error
    UpdateUser(ctx context.Context, u User) error
    DeleteUser(ctx context.Context, id string) error
    ListUsers(ctx context.Context) ([]User, error)
    // ...nine more methods
}

type PostgresUserStore struct{ db *sql.DB }

func (s *PostgresUserStore) GetUser(
    ctx context.Context, id string,
) (User, error) {
    // real query
}
// ...and the other thirteen methods
Enter fullscreen mode Exit fullscreen mode

Now the email package needs one user's address:

package email

import "myapp/store"

type Sender struct {
    users store.UserStore // depends on all 14 methods
}

func (s *Sender) SendWelcome(
    ctx context.Context, id string,
) error {
    u, err := s.users.GetUser(ctx, id)
    if err != nil {
        return err
    }
    return s.send(u.Email)
}
Enter fullscreen mode Exit fullscreen mode

Sender uses exactly one method. Its declared dependency is fourteen.
Every test fake for Sender has to implement fourteen methods to
satisfy the type, thirteen of them returning nil and never getting
called. When someone adds a fifteenth method to UserStore, every
fake in the codebase breaks compilation, including the ones in packages
that never touch the new method.

Move the interface to the consumer

The email package cares about one thing. Let it declare exactly that.

package email

type UserGetter interface {
    GetUser(ctx context.Context, id string) (User, error)
}

type Sender struct {
    users UserGetter
}
Enter fullscreen mode Exit fullscreen mode

The store package no longer exports an interface at all. It exports
the concrete type:

package store

type PostgresUserStore struct{ db *sql.DB }

func NewPostgresUserStore(db *sql.DB) *PostgresUserStore {
    return &PostgresUserStore{db: db}
}

func (s *PostgresUserStore) GetUser(
    ctx context.Context, id string,
) (User, error) {
    // real query
}
Enter fullscreen mode Exit fullscreen mode

Nothing connects the two by name. *PostgresUserStore has a GetUser
method with the right signature, so it satisfies email.UserGetter
automatically. Go interfaces are satisfied structurally, at compile
time, with no implements keyword. The producer does not import the
consumer, and the consumer does not import the producer's interface.
Wiring happens in main, where the concrete type is passed to the
constructor:

package main

func main() {
    db := openDB()
    users := store.NewPostgresUserStore(db)
    sender := email.NewSender(users) // fits UserGetter
    _ = sender
}
Enter fullscreen mode Exit fullscreen mode

The dependency Sender declares is now one method wide. That number
is the whole point.

Fakes stop being a chore

With the one-method interface, the test fake is four lines. No mock
framework, no code generation.

package email

type fakeUsers struct {
    user User
    err  error
}

func (f fakeUsers) GetUser(
    _ context.Context, _ string,
) (User, error) {
    return f.user, f.err
}
Enter fullscreen mode Exit fullscreen mode

The test reads straight through:

func TestSendWelcome(t *testing.T) {
    s := NewSender(fakeUsers{
        user: User{Email: "a@b.com"},
    })
    if err := s.SendWelcome(
        context.Background(), "u1",
    ); err != nil {
        t.Fatalf("got error: %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Compare that to faking a fourteen-method interface. You either
hand-write thirteen stub methods you never call, or you reach for a
generated mock and carry a go:generate line plus a dependency. The
narrow consumer-side interface removes the reason to generate anything.
The surface you depend on is the surface you fake, and it is small
because you only asked for what you use.

There is a second effect. When a test fake is trivial to write, people
write more tests. When it means fourteen stub methods, they skip the
test or reach for the real Postgres in a container. The size of the
interface quietly sets the cost of testing.

Import cycles disappear

This is the argument that turns the pattern from "nice" into
"sometimes the only thing that compiles."

Say store needs to emit an event through email, and email needs
to read a user through store. With producer-side interfaces, both
packages import each other:

package store
import "myapp/email" // store -> email

package email
import "myapp/store" // email -> store, cycle
Enter fullscreen mode Exit fullscreen mode

Go rejects that at build time: import cycle not allowed. There is no
flag to turn it off. The compiler stops.

Consumer-side interfaces break the cycle because the abstraction lives
with the caller. email declares its own UserGetter and imports
nothing from store. store declares its own Notifier and imports
nothing from email. Both depend on local interface types they own.
main imports both concretes and wires them together. The dependency
graph points inward toward main, and a graph that only points one
direction cannot form a cycle.

package store

type Notifier interface {
    UserCreated(ctx context.Context, id string) error
}

func (s *PostgresUserStore) CreateUser(
    ctx context.Context, u User, n Notifier,
) error {
    // insert, then:
    return n.UserCreated(ctx, u.ID)
}
Enter fullscreen mode Exit fullscreen mode

The interface is defined where it is called, so the package that calls
it never has to import the package that eventually satisfies it.

When the producer should still export the interface

The rule is a default, not a law. There are cases where the interface
belongs with the producer, and the standard library shows them.

Look at io.Reader and io.Writer. They live in io, the producer
side, and thousands of packages consume them. That is correct: an
interface used by half the ecosystem should be declared once, in a
neutral package, not redefined in every consumer. The signal is
many independent consumers sharing the exact same shape.

database/sql/driver is the other case. It defines interfaces that
third-party drivers must implement so the sql package can drive any
of them. Here the producer is publishing a contract on purpose, for
implementers it will never see.

So the checklist is short. Export the interface from the producer when
it is a stable, widely shared contract or a plugin point for outside
implementers. Otherwise, and this covers most application code, define
it in the consumer, keep it to the methods that consumer calls, and let
the concrete type satisfy it structurally.

The one-line version

Keep interfaces small and keep them next to the code that calls them.
A Go interface is a description of what a caller needs, not a manifest
of what a type provides. When you write it from the caller's side, the
description stays honest: one method, because that is all you used.

Go's own convention says the same thing in fewer words: "The bigger the
interface, the weaker the abstraction." Define it where you use it and
it stays small on its own.


If this was useful

Consumer-side interfaces are one of those Go decisions that look like a
style rule until you trace an import cycle back through three packages
and realize the interface was in the wrong place the whole time. The
Complete Guide to Go Programming
covers how interface satisfaction
actually works in the compiler and runtime, down to the itab and why
structural typing costs you nothing at the call site. Hexagonal
Architecture in Go
takes the same idea up a level, showing where these
ports belong when a service has to survive years of framework churn.

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

Top comments (0)