How to Design Good Interfaces in Go (Accept Interfaces, Return Structs)

Accept interfaces as function arguments to allow flexibility and return concrete structs as results to ensure clarity and testability in Go code.

The lock-in trap

You write a function that processes user uploads. It takes a *sql.DB pointer, runs a query, and saves the result. It works perfectly in production. Two weeks later, you need to write a test. You realize you cannot run the test without spinning up a real database. The marketing team also wants the same logic to work with an S3 bucket instead of a relational table. You are stuck rewriting the entire function because you tied your logic to a specific type.

Go solves this with a simple boundary rule: accept interfaces, return structs. The phrase sounds like a slogan, but it is a practical design constraint that keeps your code flexible without hiding implementation details. You define the behavior you need, you let callers provide anything that matches, and you give back exactly what you built.

What the proverb actually means

An interface in Go is a list of methods. A struct is a collection of fields. Interfaces describe behavior. Structs describe state. The proverb tells you where to draw the line between the two.

When you accept an interface as a parameter, you are saying you do not care how the work happens. You only care that the value can perform a specific set of actions. The caller can pass a real database, a mock for testing, a cached wrapper, or a completely different storage engine. Your function does not change.

When you return a struct, you are saying this is exactly what you built. Callers can see its fields, they can type assert it, and they can extend it if needed. Returning an interface hides the concrete type. It forces callers to guess what they got back, and it breaks tools that rely on reflection or type assertions.

Think of it like a restaurant kitchen. The menu is the interface. It lists what you can order. The kitchen returns a concrete dish. You do not hand the customer a vague food container and make them guess whether it contains pasta or soup. You give them the plate. The menu stays flexible. The plate stays explicit.

Go interfaces are not like Java or C++ interfaces. You do not declare that a struct implements an interface. The compiler figures it out by checking the method set. This implicit contract is what makes the proverb work. You describe what a type must do, not what it is. Define the contract first. Keep it small. Let the rest of the codebase fill in the blanks.

Keep interfaces at the edge of your package. Keep structs inside.

A minimal working example

Here is the simplest form of the pattern. We define a reader contract, a function that accepts it, and a constructor that returns a concrete type.

// Reader describes anything that can fill a byte slice.
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Process accepts the interface so any compatible type works.
func Process(r Reader) error {
    // Allocate a fixed buffer on the stack to avoid heap allocation.
    var buf [1024]byte
    // Call the method defined by the interface.
    _, err := r.Read(buf[:])
    // Return early if the underlying call fails.
    return err
}

// FileReader holds the actual state needed to read from disk.
type FileReader struct {
    name string
}

// NewFileReader returns a concrete struct, not an interface.
func NewFileReader(name string) *FileReader {
    return &FileReader{name: name}
}

// Read implements the Reader interface implicitly.
func (f *FileReader) Read(p []byte) (int, error) {
    // Real implementation would open f.name and read into p.
    return 0, nil
}

The compiler checks FileReader against Reader automatically. You never write implements. You just match the method signatures. The Process function does not know about FileReader. It only knows about Reader. The constructor NewFileReader hands back a pointer to the struct so callers can inspect or extend it if needed.

Implicit implementation keeps your packages decoupled. You add methods to structs without touching interface definitions.

What happens under the hood

At compile time, the Go compiler builds a method set for every named type. When you pass a value to a function expecting an interface, the compiler checks whether the value's method set contains every method in the interface. If even one method is missing, compilation stops. The compiler rejects this with cannot use type as type in argument: missing method X.

At runtime, an interface value is a small data structure containing two pointers. One points to the type information. The other points to the actual data. When you assign a struct to an interface, the compiler boxes the value. It copies the data into a new allocation and stores the type metadata alongside it. This boxing happens automatically. You do not write it, but it costs memory and CPU cycles.

Because interfaces carry type information, you can use type assertions to recover the concrete type. That is why returning structs is safer. If you return an interface, the caller must assert it back to a struct to access fields or call non-interface methods. Returning a struct skips the assertion step entirely.

The receiver name in method declarations is usually one or two letters matching the type. You will see (f *FileReader) instead of (this *FileReader). It is a community convention that keeps method signatures readable. Stick to it.

Interfaces are contracts, not containers. Let the compiler enforce the boundary.

Realistic service layer

Production code rarely deals with raw readers. It deals with services, repositories, and handlers. Here is how the pattern looks in a storage abstraction.

// Storage defines the operations a service needs from a backend.
type Storage interface {
    Get(ctx context.Context, key string) ([]byte, error)
    Set(ctx context.Context, key string, data []byte) error
}

// Service holds business logic and depends on the Storage interface.
type Service struct {
    backend Storage
}

// NewService accepts an interface and returns a concrete struct.
func NewService(backend Storage) *Service {
    return &Service{backend: backend}
}

// Fetch retrieves data through the abstracted backend.
func (s *Service) Fetch(ctx context.Context, key string) ([]byte, error) {
    // Delegate to the interface method.
    return s.backend.Get(ctx, key)
}

The Service struct does not care whether backend is a local filesystem, a Redis client, or a mock. It only calls Get and Set. The context.Context parameter follows the standard convention: it always goes first, conventionally named ctx, and functions should respect cancellation and deadlines.

You can now write a test that passes a fake Storage implementation. You can swap the backend in production without touching Service. The boundary is clean. The dependency flows inward.

The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible instead of hiding it behind panic or silent failures.

Keep the interface at the package boundary. Keep the implementation inside.

Where interfaces go wrong

Interfaces are powerful, but they become liabilities when misused. The most common mistake is interface bloat. You start with one method. Then you add another. Then you add a third. Suddenly your interface requires a full database driver to implement. Callers cannot mock it easily. The abstraction leaks.

Keep interfaces small. One or two methods is usually enough. If you need more, split the interface. A Reader should not also be a Writer. A Storage should not also be a Logger. Composition beats inheritance, and small interfaces compose better than large ones.

Another trap is returning interfaces. You might think it makes your API more flexible. It actually makes it harder to use. Callers cannot access fields. They cannot type assert without knowing the concrete type. They cannot extend the behavior. The compiler complains with cannot use interface type as struct type in assignment when callers try to treat the return value as a concrete type.

Returning an interface also breaks reflection-based tools. Frameworks that inspect struct tags, serialize data, or generate documentation rely on concrete types. Hiding the type behind an interface forces you to write manual adapters.

A third pitfall is defining interfaces in the same package that implements them. This creates a circular dependency in your design. The interface should live where it is consumed, not where it is built. If package A needs a Storage, package A defines the interface. Package B implements it. This keeps the dependency graph pointing inward.

The compiler will not stop you from making these mistakes. You have to enforce the discipline yourself.

Small interfaces at the edge. Concrete structs in the center.

When to reach for what

You will face choices every time you design a function signature. Use the following rules to decide quickly.

Use an interface when you need to decouple a caller from a specific implementation. Use an interface when you want to swap backends, inject mocks, or test logic in isolation. Use an interface when the behavior matters more than the internal state.

Use a struct when you are returning a value from a constructor or factory function. Use a struct when callers need to inspect fields, access non-interface methods, or extend the type. Use a struct when the implementation is stable and you want to expose the exact shape of the data.

Use generics when you need type safety across multiple concrete types without defining a common interface. Use generics when the operation is purely structural, like sorting a slice or hashing a value, and does not depend on custom methods. Use generics when you want to avoid interface boxing overhead in tight loops.

Use plain sequential code when you do not need abstraction. The simplest thing that works is usually the right thing. Do not wrap a single implementation in an interface just to follow a pattern.

Trust the compiler. Argue design, not syntax.

Where to go next