So, you've been writing Go for a while, and you've heard the line. Maybe in a talk. Maybe in a code review. Maybe Rob Pike said it at you through a screen. "Accept interfaces, return structs." Five words, no context, repeated like a charm. The first time someone tells you, you nod. The second time, you nod harder. By the third time, you start to wonder if anyone actually knows what it means or if it's just the secret handshake.
Here's the thing - it's a real rule, it pays off in real ways, and most of the people repeating it are right. But the reasons are way more interesting than the slogan. The rule isn't about interfaces being good and structs being bad. It's about where the seams in your program belong, who owns the contract, and what you have to do when it's time to write a test or swap out a dependency.
In this article, we're going to take it apart from the bottom up:
- what the rule actually says, in one tiny example,
- why interfaces in Go belong on the consumer side, not the producer side,
- how "small interfaces" is the part that does the heavy lifting,
- why returning concrete structs gives your callers more, not less,
- how all of this lights up the testing story,
- how it shapes the boundaries between packages, services, and teams,
- and the cases where you should ignore the rule on purpose.
By the end, you should be able to read your own Go code and tell where the seams want to go, instead of guessing.
The maxim in one example
Let's get concrete fast. Say you're writing a function that logs every order placed in your system. The lazy first draft might look like this:
package orders
import "os"
func LogOrder(o Order) error {
_, err := os.Stdout.Write([]byte(o.String() + "\n"))
return err
}
That works. It also welds your function to os.Stdout forever. You can't test it without capturing stdout. You can't redirect it to a file in production without rewriting. You can't fan it out to a buffered channel later. You painted yourself into a one-pixel corner.
Now look at the same function written the Go way:
package orders
import "io"
func LogOrder(w io.Writer, o Order) error {
_, err := w.Write([]byte(o.String() + "\n"))
return err
}
One change. os.Stdout is no longer a hard-coded thing in the function - it's a parameter, and not even a specific type. The function just needs something that can be written to. io.Writer is the smallest possible promise you could ask for. Pass os.Stdout, it works. Pass os.Stderr, it works. Pass a bytes.Buffer in your test, it works. Pass a gzip.Writer, a bufio.Writer, a net.Conn, an http.ResponseWriter - all of them satisfy io.Writer and all of them now plug into your function for free.
That's the "accept interfaces" half of the rule, in 12 lines. The function takes the least specific thing it can get away with, and the universe of valid inputs expands to "anything in Go that knows how to be written to."
Now the other half. Say your orders package also exposes a constructor for an OrderService:
package orders
type OrderService struct {
repo Repository
clock Clock
log io.Writer
}
func NewOrderService(repo Repository, clock Clock, log io.Writer) *OrderService {
return &OrderService{repo: repo, clock: clock, log: log}
}
Notice what NewOrderService returns: *OrderService. A concrete struct pointer. Not an interface like OrdersAPI or Service. The caller gets the actual thing back, with its full set of methods - Place, Cancel, Refund, future methods we haven't written yet - visible in their IDE, documented on the type, all there.
That's "return structs." Don't hide the type behind an interface on the way out. Let the caller see exactly what they're holding.
So the rule, expanded:
Take the smallest interface you can on the way in, so any caller can plug in. Return the concrete type on the way out, so callers can see what you gave them.
The slogan compresses both halves. Both halves matter.
Why the interface belongs on the consumer side
This is the part that trips up engineers coming from Java, C#, or TypeScript-with-classes. In those languages, you usually define an interface near the thing that implements it. class UserRepository implements IUserRepository. Producer-side declaration. The interface lives in the same file or directory as the concrete class, and consumers import it.
Go does the opposite. The interface lives where it's used, not where it's implemented.
Re-read the LogOrder example. The orders package consumes "something writable." It imports io.Writer. The io package doesn't know orders exists. And os.Stdout - the value that ends up being passed in - was written years before io.Writer was even on anyone's mind, in a different package entirely. Nobody had to declare os.Stdout implements io.Writer. It just does, because it has a Write([]byte) (int, error) method, which is the whole shape of io.Writer.
That's structural typing, and in Go it's load-bearing. An interface is a contract the consumer asks for. Any type with the right method set satisfies it, automatically, without ever knowing the interface exists. This is the foundation that lets the rule work, and it changes how you should think about where to put things.

UserRepository for a fake in tests, you have to plan for it up front - extract an interface, refactor the class, update the call sites. In Go, you just define a one-method interface in your test file or in the consuming package, and you're done. The original *postgres.Client doesn't change. The original package doesn't even know.
Here's that, concretely. Say your orders package needs to look up a user before placing an order:
package orders
type userStore interface {
GetUser(id int64) (User, error)
}
type OrderService struct {
users userStore
}
func (s *OrderService) Place(o Order) error {
user, err := s.users.GetUser(o.UserID)
if err != nil {
return fmt.Errorf("loading user: %w", err)
}
// ... do stuff with user
return nil
}
userStore is defined right there in the orders package. It's lowercase - unexported - because nothing outside this package cares. It's one method. The real implementation lives in a postgres package that knows nothing about orders:
package postgres
type Client struct { /* ... */ }
func (c *Client) GetUser(id int64) (User, error) { /* ... */ }
func (c *Client) SaveUser(u User) error { /* ... */ }
func (c *Client) DeleteUser(id int64) error { /* ... */ }
*postgres.Client has nine methods. The orders package only knows about one of them. From orders's point of view, the whole "user store" universe collapses down to "a thing with GetUser". That's a deliberately narrow window. We'll get to why it matters in the testing section.
The mental shift, in one sentence: in Go, an interface is the shape of a need, not the shape of an implementation. Define it where the need is.
Small interfaces are the whole game
If you remember one thing from this article past the slogan itself, make it this: the smaller the interface, the more useful it is. There's a Pike quote for this too, and it's the one that actually does the work. "The bigger the interface, the weaker the abstraction."
Look at the standard library. The most-used interfaces in Go are tiny:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Stringer interface {
String() string
}
type error interface {
Error() string
}
One method each. And these four interfaces compose into roughly half of all Go I/O, formatting, and error code in existence. Why? Because the smaller the contract, the more types can satisfy it without conscious effort, and the more places you can plug them in.
Compare that to what happens when interfaces grow. Imagine an OrderProcessor that started reasonable:
type OrderProcessor interface {
Process(o Order) error
}
Then someone needed validation, so it grew:
type OrderProcessor interface {
Validate(o Order) error
Process(o Order) error
Refund(orderID int64) error
Cancel(orderID int64) error
History(userID int64) ([]Order, error)
ExportCSV(w io.Writer) error
}
Now the interface is doing six things. To satisfy it in a test, you have to stub six methods. To swap implementations, your new implementation has to support all six even if your code path only calls Process. Every consumer that asks for an OrderProcessor is implicitly demanding a fully-featured order subsystem, when they probably just wanted one operation.
This is what "the bigger the interface, the weaker the abstraction" cashes out to in practice. An interface with six unrelated methods doesn't promise much, because the only way to write something that matches it is to write the full thing. A one-method interface promises almost nothing - and that's exactly why it's so easy to satisfy and so easy to substitute.
The Go way to handle the bloated case isn't to make the interface bigger. It's to split it:
type OrderPlacer interface { Process(o Order) error }
type OrderRefunder interface { Refund(orderID int64) error }
type OrderCanceler interface { Cancel(orderID int64) error }
type OrderHistory interface { History(userID int64) ([]Order, error) }
type OrderExporter interface { ExportCSV(w io.Writer) error }
Each consumer asks for only the slice they actually use. A handler that only places orders depends on OrderPlacer - one method. A reporting job that only exports CSVs depends on OrderExporter - one method. Your *OrderService struct still has all six methods on it (because it's a real, fat, useful thing), and it satisfies all five interfaces structurally, automatically, with zero ceremony.
Testing - where the rule pays off
This is where "accept interfaces, return structs" stops being style advice and starts being load-bearing.
Take the earlier OrderService that depends on userStore. Imagine writing a test for Place without the interface. You'd need a real Postgres connection. You'd need a clean schema. You'd need to seed a user. You'd need to tear it all down. Now imagine running 200 of those tests in CI. Now imagine that one of them is flaky because the test before it inserted a row with the same ID. Welcome to the integration-test mud pit.
With the interface, the test is a paragraph:
package orders
import (
"errors"
"testing"
)
type fakeUserStore struct {
users map[int64]User
err error
}
func (f *fakeUserStore) GetUser(id int64) (User, error) {
if f.err != nil {
return User{}, f.err
}
return f.users[id], nil
}
func TestPlace_UserNotFound(t *testing.T) {
svc := &OrderService{users: &fakeUserStore{err: ErrUserNotFound}}
err := svc.Place(Order{UserID: 42})
if !errors.Is(err, ErrUserNotFound) {
t.Fatalf("expected ErrUserNotFound, got %v", err)
}
}
func TestPlace_Happy(t *testing.T) {
svc := &OrderService{users: &fakeUserStore{
users: map[int64]User{42: {ID: 42, Name: "Ada"}},
}}
if err := svc.Place(Order{UserID: 42}); err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
No database. No fixtures. No teardown. The fake is twelve lines and it lives in the same file as the test. You can write a fake for userStore because userStore is one method long - the surface area is tiny, so the fake is tiny.
This is the place where small-interfaces really pays. If userStore had eight methods, your fake would have to stub eight methods even though Place only calls one. Some of those stubs would be panic("unimplemented") and you'd hope you never accidentally hit them. With one method, there's no temptation to half-stub. There's nothing to half-stub.
A few patterns that fall out of this naturally:
1. The test-only fake lives in the consumer package
You don't need a separate mocks package. You don't need a generated-mock tool unless you're stubbing genuinely fat third-party interfaces. A fakeUserStore struct in service_test.go is enough for 95% of cases, it's transparent (the test reader can see exactly what it does), and there's no mock framework DSL in the way.
2. You can hand-build behaviour fakes, not just method stubs
When the interface is small, you can write fakes that have real behaviour - an in-memory map for a repository, a channel-backed fake for a message queue, a deterministic clock for time. The fake becomes a tiny, trustworthy reimplementation of the contract, not a recording of canned answers. Tests written against behaviour fakes read like product code and break for product-code reasons.
type FakeClock struct {
now time.Time
}
func (f *FakeClock) Now() time.Time { return f.now }
func (f *FakeClock) Advance(d time.Duration) {
f.now = f.now.Add(d)
}
Two methods. Real behaviour. Drop it anywhere your code expects a clock.Clock interface and now your time-dependent code is fully deterministic in tests.
3. The mock library question mostly goes away
People reach for gomock, mockery, testify/mock, and friends because their interfaces are huge and hand-stubbing is exhausting. If your interfaces are one method long, hand-written fakes win on every axis: less generated code, no DSL, fewer indirection layers, and the fake reads top-to-bottom like a real implementation. Generated mocks are fine - but the fact that you reach for them is often a signal that an interface upstream is too fat.
4. The "table test + fake" pattern composes
func TestPlace(t *testing.T) {
cases := []struct {
name string
users map[int64]User
storeErr error
order Order
wantErr error
}{
{"user not found", nil, ErrUserNotFound, Order{UserID: 1}, ErrUserNotFound},
{"happy path", map[int64]User{7: {ID: 7}}, nil, Order{UserID: 7}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := &OrderService{users: &fakeUserStore{users: tc.users, err: tc.storeErr}}
err := svc.Place(tc.order)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("got %v, want %v", err, tc.wantErr)
}
})
}
}
The fake is the same. The test cases iterate the inputs. You can cover six paths through Place in twenty lines.
The pattern this all adds up to: small interfaces aren't just stylistically nicer - they make the unit-test layer of your program possible. The "accept interfaces" half of the rule is what gives you a seam to put the fake at. The "small interfaces" corollary is what makes that fake cheap enough to write.
Dependency boundaries - keeping the edges narrow
Now zoom out one level. The same idea that makes a single function testable also makes a package understandable, and a module maintainable. Every interface you define is a boundary in your program. The narrower it is, the cheaper that boundary is to cross - and the easier it is to move things on either side of it.
Think about what happens when your orders package depends on a fat Database interface:
type Database interface {
// 30+ methods covering every table in the system
GetUser(id int64) (User, error)
SaveUser(u User) error
GetOrders(userID int64) ([]Order, error)
SaveOrder(o Order) error
GetInvoice(id int64) (Invoice, error)
SaveInvoice(i Invoice) error
// ... and so on
}
Sure, the orders package only calls two of those methods. But it depends on the whole interface. If someone adds a method to Database, every package that consumes it has to re-typecheck. Every fake implementation in tests has to grow. Worse - the boundary between orders and the database layer is now thirty methods wide. You can't tell, from looking at orders, what it actually needs from the database. You have to read its code.
Now compare it to a package that defines its own narrow interface:
type ordersDB interface {
GetUser(id int64) (User, error)
SaveOrder(o Order) error
}
Two methods. That's the whole contract between orders and the persistence layer. You can read that interface and know, instantly, what orders requires. You can replace the underlying database - Postgres, MySQL, an HTTP API, a flat file - and as long as the new thing exposes those two methods, the swap is local.

- Inter-team boundaries. If team A's service calls team B's service through a Go client, team A should accept an interface with just the methods they call - not import team B's full client. The thinner the boundary, the less coupling, the easier the on-call rotation.
- Module boundaries. A library shouldn't return its own bespoke interface to callers (more on that below). It should accept narrow ones at its public surface and return concrete types. That way, callers can wrap, swap, or fake any input - but they get the full power of the library's output.
- Refactor boundaries. If you're about to break a giant package in two, narrow interfaces between the halves are the seam. Define them first, satisfy them with the existing code, then move files around. The interfaces hold the shape while the rest moves.
The phrase to remember: an interface is a cost to the producer and a freedom to the consumer. The fewer methods you stuff into it, the cheaper that cost is on both sides.
Where to put the interface
In Go, the convention is:
- The consuming package owns the interface. This is the default and what we've been doing all along.
ordersdefinesuserStore.reportsdefinesordersReader. Each consumer asks for exactly what it needs. - Shared interfaces go in the package whose abstraction they describe, not in a
typesorinterfacespackage.io.Readerlives iniobecauseiois about reading and writing.context.Contextlives incontext. There's no central interface-zoo, and there shouldn't be one in your codebase either. - A producer package may expose an interface when it offers a strategy slot.
sort.Interfaceis the canonical example -sortwants you to plug in your own ordering. So ishttp.Handler. These are real exceptions. The producer is saying: "I'm parameterised over this. Give me a thing that fits." That's the right time to ship an interface from the producer side.
The rule of thumb that comes out of this: if you're tempted to put a package interfaces in your repo, stop. You're trying to recreate Java. Go's interfaces are supposed to be lightweight and local.
Returning structs - and what you give up when you don't
The first half of the rule gets all the attention. The second half - "return structs" - is just as important and gets less of it.
The instinct to return an interface usually comes from one of two places:
- "I want to hide the implementation." (Premature abstraction.)
- "I might swap the implementation later." (Speculative flexibility.)
Both are usually wrong, and both cost the caller.
Here's the cost. Suppose your orders package exposes a service:
package orders
type Service interface {
Place(o Order) error
}
func New(...) Service { /* ... */ }
A caller does:
svc := orders.New(...)
err := svc.Place(o)
That works. But now suppose you ship Cancel next month. You add it to your concrete struct, but it's not on the Service interface - so callers can't use it. To make it usable, you either:
- add it to the interface (and now every fake implementation has to grow with it), or
- have callers downcast -
svc.(*orderService).Cancel(...)- which leaks the concrete type anyway and is brittle.
Compare to returning the struct directly:
func New(...) *OrderService { /* ... */ }
Callers do:
svc := orders.New(...)
err := svc.Place(o)
// next month, with zero changes to consumers' import lines:
err = svc.Cancel(o.ID)
The struct has all its methods. The caller sees them. Adding a method is backwards-compatible by default. No interface to keep in sync. And - critically - the caller can still wrap the concrete type in their own interface if they need to substitute it. They're not stuck.
// in the caller's package:
type orderPlacer interface {
Place(o Order) error
}
// *orders.OrderService satisfies orderPlacer structurally — no producer changes needed.
var p orderPlacer = orders.New(...)
This is the part people miss. Returning a struct doesn't reduce the caller's flexibility, because Go's structural typing means the caller can define their own consumption interface on top of your struct any time they want. Returning an interface, on the other hand, does reduce flexibility - it caps the caller at exactly the methods you decided to expose, forever.
So the slogan, expanded one more time:
Accept the narrowest interface you can, because that maximises what callers can plug in. Return the widest concrete type you have, because that maximises what callers can do with it.
There are real exceptions, of course. Return an interface when:
- You're returning one of several implementations from the same constructor, and the caller really does need to be implementation-agnostic. Example:
database/sql'sOpenreturns*sql.DB, but the driver registration mechanism uses interfaces internally to swap MySQL for Postgres for SQLite. The public API still hands you a concrete*sql.DB. - You're returning a closure-shaped or capability-shaped thing that genuinely has no useful surface beyond one method.
func() erroris technically a function type, but a single-method interface is sometimes nicer when you want a name on it (type Closer interface { Close() error }). - You're implementing a plugin or extension point where the whole point is that callers will substitute.
sort.Interface.http.Handler. These are systems where "the implementation is the contract."
If your case doesn't look like one of those three, return the struct.
Stacking it up - what a small-interface, struct-return package looks like
Let's pull it all together with a realistic mini-package. Say you're building a notifier that sends order confirmation emails:
package notify
import (
"context"
"fmt"
"io"
"time"
)
// What we accept — three small interfaces, defined here, in the consumer package.
type Mailer interface {
Send(ctx context.Context, to, subject, body string) error
}
type Clock interface {
Now() time.Time
}
type Logger interface {
Logf(format string, args ...any)
}
// What we return — a concrete struct.
type Notifier struct {
mail Mailer
clock Clock
log Logger
}
func New(mail Mailer, clock Clock, log Logger) *Notifier {
return &Notifier{mail: mail, clock: clock, log: log}
}
func (n *Notifier) OrderConfirmed(ctx context.Context, w io.Writer, o Order) error {
body := fmt.Sprintf("Order %d confirmed at %s", o.ID, n.clock.Now().Format(time.RFC3339))
if err := n.mail.Send(ctx, o.UserEmail, "Order confirmed", body); err != nil {
n.log.Logf("notify: send failed for order %d: %v", o.ID, err)
return fmt.Errorf("send: %w", err)
}
_, _ = w.Write([]byte(body + "\n")) // also write a copy somewhere
return nil
}
Count the interfaces accepted. Mailer - one method. Clock - one method. Logger - one method. io.Writer (in the function signature) - one method. Four interfaces, four methods total. Each is a separate seam. Each can be faked in three lines. The return type is *Notifier - concrete, all methods visible.
Now consider what this gives you in a test:
package notify
import (
"bytes"
"context"
"errors"
"fmt"
"testing"
"time"
)
type stubMailer struct {
sent []string
failNext bool
}
func (s *stubMailer) Send(_ context.Context, to, _, body string) error {
if s.failNext {
return errors.New("smtp down")
}
s.sent = append(s.sent, to+":"+body)
return nil
}
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
type bufLogger struct{ buf bytes.Buffer }
func (l *bufLogger) Logf(format string, args ...any) {
fmt.Fprintf(&l.buf, format, args...)
}
func TestOrderConfirmed_Happy(t *testing.T) {
m := &stubMailer{}
n := New(m, fixedClock{t: time.Unix(1_700_000_000, 0).UTC()}, &bufLogger{})
var out bytes.Buffer
err := n.OrderConfirmed(context.Background(), &out, Order{ID: 7, UserEmail: "a@b.c"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(m.sent) != 1 {
t.Fatalf("expected 1 mail sent, got %d", len(m.sent))
}
if !bytes.Contains(out.Bytes(), []byte("Order 7 confirmed")) {
t.Fatalf("expected stdout copy, got %q", out.String())
}
}
func TestOrderConfirmed_MailerFails(t *testing.T) {
log := &bufLogger{}
n := New(&stubMailer{failNext: true}, fixedClock{t: time.Now()}, log)
err := n.OrderConfirmed(context.Background(), &bytes.Buffer{}, Order{ID: 9})
if err == nil {
t.Fatal("expected error, got nil")
}
if !bytes.Contains(log.buf.Bytes(), []byte("send failed for order 9")) {
t.Fatalf("expected log entry, got %q", log.buf.String())
}
}
No mock framework. No DI container. No setup file. The test reads top to bottom. Every dependency is plugged in as a tiny purpose-built fake. If you decide tomorrow that Notifier also needs a Metrics interface, you add one method to the struct, define a one-method Metrics interface, and write a four-line stub. The test footprint grows linearly with the surface area, not exponentially.
This is the payoff of the rule, fully cashed in. Each individual decision - accept an interface here, return a struct there, keep this interface one method long - looks small in isolation. Stacked up, they make the package easy to reason about, cheap to test, and pleasant to extend.
When to ignore the rule on purpose
No good rule survives without exceptions, and Go's hand-wavers love to skip this part. Here are the cases where you should not be religious about it.
Returning an interface is correct when the constructor is genuinely polymorphic
fmt.Errorf returns error. context.Background() returns context.Context. net.Listen returns net.Listener. In each case, there are multiple real implementations and the caller fundamentally has to be agnostic. If your constructor is like this - "depending on configuration, this returns one of three real things" - return the interface. Don't fake polymorphism with a wrapper struct.
Accepting a concrete struct is correct at the bottom of the system
Not every function needs an interface for every parameter. A function that operates on a time.Time should take a time.Time, not an interface { ... } over it. A function that needs an *sql.DB to run a transaction can take an *sql.DB - database/sql is the boundary, and going deeper into the abstraction doesn't help. The rule is about contracts between code that might want to be substituted. Leaf-level operations on stable types don't need that flexibility.
One-method interfaces aren't sacred
If your domain genuinely has a two-method or three-method concept - io.ReadWriter (read + write), sort.Interface (Len, Less, Swap), http.ResponseWriter (Header, Write, WriteHeader) - go ahead and group them. The rule is "interfaces should be small," not "interfaces must have exactly one method." Pike's wording is the right tension: as small as it can be while still being useful.
Don't preemptively interface-ify everything
If you have a function that's used in one place, by one caller, with one concrete dependency, and you have no real plan to swap it - just take the concrete type. Adding an interface for "maybe someday" is the same anti-pattern as a Java IFooService that has exactly one implementation forever. You can always extract an interface later, when there's a real second consumer. Go's structural typing means that extraction is cheap. Speculative interfaces are not.
Don't define interfaces in a separate interfaces package
Already mentioned, but worth reinforcing: it's a Java instinct that doesn't survive contact with Go's import graph. Interfaces should be defined where they're used. If multiple packages need the same interface, define it in the package that owns the concept (like io owns Reader), not in a junk-drawer package whose only purpose is to hold interfaces.
Don't return interfaces "to hide the implementation"
Hiding the implementation is what unexported fields and methods are for. If your *OrderService has internal state you don't want callers touching, lowercase it. The struct itself can stay public; its guts are still private. Wrapping the whole thing in a public interface to hide the type is heavier-handed than it needs to be and costs the caller flexibility.
The shorter version
If you want to compress everything in this article down to a single mental checklist, here it is:
When you're writing a function or constructor, look at each parameter and ask: "What's the smallest method set my function actually needs?" Define an interface with exactly that set, in the consuming package. Take that interface as the parameter type. Don't take more.
When you're writing a constructor's return type, look at what you're handing back and ask: "Is there a real reason this couldn't be the concrete struct?" If there isn't - and most of the time there isn't - return the concrete struct. Let the caller see the full type.
When you find yourself reaching for a mock library, look at the interface you're mocking and ask: "How many of these methods do I actually use?" If it's a small subset, the interface is too big. Split it.
When you find yourself defining an interfaces package, stop. Move each interface to where it's consumed.
That's the rule, end to end. The slogan compresses it. The reasons are bigger than the slogan, and once they click, the way you draw the boundaries in your Go code changes - usually for the rest of your career.






