- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
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)
}
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
}
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
}
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
}
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
}
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)
}
}
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
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)
}
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.

Top comments (0)