IndexAdvanced Go PatternsPart 02

Interface Design

Accept interfaces, return structs, and keep them small.

Feb 3, 20266 min readAPI DesignPart 02 of 11

Interfaces are where Go developers most often bring habits from other languages that actively hurt them. If you're declaring an interface for every struct, or defining interfaces next to their implementations, you're writing Java in Go.

Go's interface philosophy is different and, once it clicks, genuinely freeing. Two rules carry most of the weight: keep interfaces small, and accept interfaces, return structs. Let's earn both.

Interfaces are satisfied implicitly

The thing that makes Go interfaces special: a type satisfies an interface just by having the right methods. No implements keyword, no declaration of intent.

Go
type Stringer interface {
    String() string
}

// Temperature satisfies Stringer just by having a String() method.
// It never mentions Stringer.
type Temperature float64

func (t Temperature) String() string {
    return fmt.Sprintf("%.1f°C", float64(t))
}

This decoupling is the whole point. The consumer of a type gets to decide what interface it needs — the producer doesn't have to predict it. That single fact drives everything else in this post.

The consumer owns the interface — producers satisfy it implicitly io.ReaderRead(p []byte) (int, error) *os.File *bytes.Buffer net.Conn strings.Reader
None of these types mention io.Reader — a Read method is enough

Keep interfaces small

The most-quoted line in Go is "the bigger the interface, the weaker the abstraction." The standard library's most useful interfaces are tiny:

Go
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

One method each. And because they're small, enormous numbers of types satisfy them — files, network connections, buffers, HTTP bodies, gzip streams. Any function that takes an io.Reader works with all of them, including ones that didn't exist when the function was written.

A single-method interface is so common it has a naming convention: the interface is the method name plus -er. Read → Reader. Close → Closer. And you compose small interfaces into bigger ones when you need to:

Code
type ReadCloser interface {
    Reader
    Closer
}

Compare that to a sprawling interface:

Go
// Don't do this. It's a class disguised as an interface.
type Storage interface {
    Get(key string) ([]byte, error)
    Set(key string, value []byte) error
    Delete(key string) error
    List(prefix string) ([]string, error)
    Stat(key string) (FileInfo, error)
    // ...12 more methods
}

Every consumer is now coupled to all 17 methods even if it calls one. Every test mock has to stub all 17. And nothing else can realistically satisfy it. Big interfaces are weak abstractions.

Fat interface — weak Small interfaces — strong StorageGet · Set · Delete · ListStat · …12 moreevery consumer coupledto all 17 methods Getter — Get Setter — Set Deleter — Delete
Depend on the one method you call, not the fifteen you don't

Accept interfaces, return structs

This is the rule that reorganizes how you think about API boundaries.

Accept interfaces in your function parameters. Ask for the minimum behavior you need, not a concrete type. This makes your function maximally reusable and trivially testable.

Go
// Bad: demands a concrete type, so callers must have a real *os.File,
// and tests need a real file on disk.
func Count(f *os.File) (int, error) { ... }

// Good: accepts anything readable. Works with files, network streams,
// strings.NewReader in tests, etc.
func Count(r io.Reader) (int, error) {
    data, err := io.ReadAll(r)
    if err != nil {
        return 0, err
    }
    return bytes.Count(data, []byte("\n")), nil
}

That second version is testable with zero filesystem setup:

Code
n, err := Count(strings.NewReader("a\nb\nc\n"))

Return structs (concrete types), not interfaces. The caller gets the full, documented type with all its methods and fields, and they decide what interface to view it through.

Go
// Good: returns the concrete type. The caller sees everything it can do.
func NewClient(addr string) *Client { ... }

// Usually unnecessary: returning an interface hides capabilities and
// forces the caller to type-assert to get them back.
func NewClient(addr string) ClientInterface { ... }

There are exceptions — returning an interface makes sense when you genuinely have multiple implementations chosen at runtime (io.Pipe returns interfaces, error is an interface). But the default should be concrete-out. Returning an interface "to be flexible" usually just hides functionality and is the #1 source of unnecessary interfaces in Go codebases.

Define interfaces at the point of use

This follows directly from "consumers decide." Don't define an interface next to the type that implements it. Define it in the package that consumes it.

Go
// package payment — the CONSUMER defines exactly what it needs.
package payment

type Charger interface {
    Charge(ctx context.Context, cents int) error
}

func Process(c Charger, cents int) error {
    return c.Charge(context.Background(), cents)
}
Go
// package stripe — the PROVIDER doesn't know or care about payment.Charger.
// Its *Client just happens to have a Charge method, so it satisfies it.
package stripe

type Client struct{ ... }

func (c *Client) Charge(ctx context.Context, cents int) error { ... }

The stripe package has no dependency on payment. The payment package defines the narrow contract it actually uses. You can swap in a different provider, or a fake in tests, and stripe never needs to know. This inversion — interfaces owned by consumers, not producers — is the single most important interface idiom in Go.

Practical guidance

  • Don't create an interface until you have a reason. "I might need to mock this someday" is not a reason to add an interface today — you can add it the moment you write the test, in the consumer's package. Premature interfaces are pure overhead.
  • One implementation? Probably no interface. If there's exactly one real implementation and you don't need a test double, just use the concrete type.
  • Keep the interface as small as the consumer needs. If Process only calls Charge, don't make it depend on a 5-method interface — define a 1-method one.
  • any is a smell in signatures. interface{} / any discards all type information. Reach for generics before falling back to any.
  • Watch the nil-interface trap. A non-nil interface holding a nil pointer is not == nil. Returning a typed nil pointer as an error is a classic bug:
Go
func doThing() error {
    var e *MyError // nil pointer
    // ... if we never assign e ...
    return e       // BUG: returns a non-nil error interface wrapping a nil *MyError!
}
// Caller's `if err != nil` is TRUE even though "nothing went wrong".

Return a literal nil for the success case, never a typed nil pointer.

The takeaway

Go interfaces are implicit, which means consumers — not producers — should own them. Keep them small (often one method), accept them as parameters but return concrete structs, and define them in the package that uses them, right when you need them. Resist the urge to add interfaces speculatively. Do this and your code gets more testable, more reusable, and dramatically less coupled — with less ceremony, not more.

The takeaway

Accept interfaces, return structs. Keep them tiny, and let the package that uses an interface be the one that defines it — the moment it actually needs it, not a moment sooner.