Accept Interfaces, Return Structs

Why Go APIs should accept interfaces as parameters and return concrete types to stay flexible, testable, and easy to understand

When writing functions and constructors in Go, a widely repeated design guideline is: accept interfaces, return structs. It’s not a compiler-enforced rule, and it has deliberate exceptions, but internalising the reasoning behind it changes how you design APIs that survive refactoring, testing, and new requirements. The guideline addresses two sides of the same problem: how much the caller must commit to, and how much the function forces the caller to know.

What the Idiom Says

A function or constructor should:

  • Accept interfaces for the dependencies it needs, not concrete types.
  • Return concrete structs (or pointers to structs) so callers get a well-defined, predictable value.

It’s a direct consequence of Go’s implicit interface satisfaction. The caller owns the definition of what it needs; the producer owns the shape of what it hands back.

Not an official proverb:

Although “accept interfaces, return structs” is often quoted as a Go proverb, it does not appear in the canonical Go Proverbs list. It is, however, a community idiom that encapsulates several deeper principles about dependency inversion, testability, and avoiding premature abstraction.

Why the Idiom Exists

Two design pressures collide every time you write a function signature:

  1. The caller wants to pass whatever implementation suits their context — a real database, a mock, a buffer — without changing the function.
  2. The caller wants to know exactly what they get back, without guessing a type or performing type assertions.

Accepting a concrete type breaks the first pressure. Returning an interface breaks the second. The idiom resolves both.

Concrete parameters lock out alternatives

If a function takes *sql.DB, it can never be used with a transaction object, a connection pool wrapper, or a test double. The function has declared a dependency on the entire concrete type, even if it only calls one method.

// Tightly coupled — only works with *sql.DB
func LookupUser(db *sql.DB, id int) (*User, error) {
    row := db.QueryRow("SELECT name FROM users WHERE id = ?", id)
    // ...
}

This forces every caller — production code, integration tests, benchmarks — to supply a real *sql.DB. The function is no longer reusable across contexts.

Library code should never demand a concrete type:

When you ship a library that accepts *sql.DB, you are making an irreversible decision for every downstream consumer. They cannot wrap, decorate, or mock the database handle without forking or rewriting your function.

Returning interfaces hides information the caller needs

Returning an interface from a function where only one concrete implementation exists robs the caller of concrete-type methods, fields, and the ability to assign the value to a variable of a concrete type without a type assertion. It also forces the caller to read the implementation to discover what they actually get.

// Returns an interface even though there is only one implementation
func NewCache() Cache {
    return &memoryCache{data: make(map[string]string)}
}

Callers of NewCache see only the methods on Cache. If memoryCache has additional exported methods — or if they later want to embed the struct — they are stuck unless they assert the type. The interface return offers no extra flexibility because only one implementation exists; it only obscures detail.

Accept Interfaces — What It Really Means

The “accept” side is about dependency inversion. The function declares the smallest set of behaviour it requires, and the caller supplies any type that satisfies it. This inverts the usual dependency flow: the function no longer reaches down to a specific implementation; it trusts the caller to provide something that fulfils the contract.

In practice, you define a small, locally scoped interface and use it as the parameter type.

// Defined at the call site — only the methods this function actually calls
type userSaver interface {
    SaveUser(name string) error
}
func Register(saver userSaver, name string) error {
    return saver.SaveUser(name)
}

Any struct with a SaveUser(string) error method can now be injected. Production code might pass a PostgresStore; tests might pass a fakeSaver. The function never knows the difference.

Your function is now testable without a real database:

Because Register accepts the userSaver interface, a unit test can supply a trivial implementation that records calls in memory. You no longer need a running database to verify the registration logic.

Keep the interface small and consumer-defined

The interface should live as close as possible to the function that uses it, not in a shared “models” package. This is the Interface Segregation Principle applied inside Go: a function should not depend on methods it doesn’t call. If Register never lists users, its interface shouldn’t require a ListUsers method.

// ❌ Forcing callers to implement unnecessary methods
type UserRepository interface {
    SaveUser(string) error
    ListUsers() ([]string, error)
    DeleteUser(string) error
}
// ✅ Only what this function genuinely needs
type userSaver interface {
    SaveUser(string) error
}

When interfaces are defined by the consumer, different consumers can define different slices of the same struct’s behaviour without coordination. That is the power of Go’s implicit satisfaction.

Return Structs — What It Really Means

Returning a concrete struct gives callers a stable, predictable type. They see all exported fields and methods. They can assign the value to a variable of that exact type. They can later decide to define their own interface around the returned struct if they need abstraction on their side.

type RegistrationResult struct {
    ID   string
    Name string
}
func Register(saver userSaver, name string) (RegistrationResult, error) {
    err := saver.SaveUser(name)
    if err != nil {
        return RegistrationResult{}, err
    }
    return RegistrationResult{ID: "usr_123", Name: name}, nil
}

The caller receives a RegistrationResult and can use result.ID directly. No type assertion, no guesswork. If the caller later wants to treat RegistrationResult polymorphically with other result types, they define their own interface:

type RegistrationReporter interface {
    Report() string
}
// The caller decides that RegistrationResult satisfies this interface.

The producer stays focused on the data it returns; the consumer owns the abstractions.

Premature interface returns add indirection without benefit:

Returning an interface from a function that only ever creates one underlying struct is a form of preemptive abstraction. It solves a problem that does not yet exist and makes the API harder to read. Wait until you genuinely have multiple return types that must be treated uniformly before introducing an interface return.

Exceptions — When Returning an Interface Makes Sense

The idiom is a guideline, not a law. Several situations justify returning an interface.

Hiding an unexported implementation

If the concrete type is unexported, the only way callers can interact with it is through the returned interface. This is common in constructors that enforce invariants.

// Storage is the exported interface.
type Storage interface {
    Save(data string) error
}
// storageImpl is unexported — callers cannot create it directly.
type storageImpl struct {
    conn string
}
func NewStorage(conn string) Storage {
    return &storageImpl{conn: conn}
}

The caller never sees storageImpl. They cannot construct a zero-value struct and bypass the constructor. The interface acts as the public API while the struct stays private.

Returning the error interface

Functions return error, an interface, because different error conditions produce different concrete types (*os.PathError, *net.OpError, custom sentinel errors). The caller interacts with the value through the single Error() method and uses type assertions or errors.As when they need specifics. This is a genuine case of multiple return types behind a single interface.

Factory functions with multiple implementations

When a factory genuinely returns one of several concrete types depending on input, returning an interface is appropriate. The caller must treat all return values uniformly, so the interface captures that uniformity.

Weigh the need before returning an interface:

Even in factory scenarios, ask whether the different concrete types really need to share an interface from the producer’s side. Often the caller can define a local interface post‑factum, preserving the “return struct” side of the guideline.

Common Mistakes When Applying the Idiom

The principle is simple, but misapplication shows up in several repeatable forms.

  • Accepting a concrete type “because it’s easier”: immediate convenience becomes a long‑term testing and refactoring tax. Once the codebase grows, functions that accept *sql.DB or *http.Client directly become islands that cannot be reused outside their original context.
  • Returning interface{} instead of a proper struct: this is the worst of both worlds. Callers lose all type safety and are forced into type assertions. The guideline says return structs, not return the empty interface.
  • Making the parameter interface too large: a function that only writes data should not require a ReadWriteCloser. Keep the interface to exactly the methods the function invokes.
  • Defining the interface in the producer package: when the producer owns the interface, every consumer is forced to depend on that one definition. Changes to the interface ripple everywhere. Instead, let consumers define the interface they need.
  • Returning an interface for a single, exported struct : this hides the struct’s fields and extra methods from callers who might need them. It also breaks the ability to embed the struct in another type without a type assertion.

Watch out for struct fields needed by the function:

If your function genuinely needs to read or write struct fields (not just call methods), you cannot accept an interface because interfaces only capture behaviour. In that case you must accept the concrete struct — and that’s a deliberate, not accidental, exception to the idiom.

A Complete Example — Service That Stores Data

Imagine a service that processes documents and records the outcome. It needs something that can store a record, but it doesn’t care whether that storage is a database, a file, or an in‑memory buffer for testing. It also needs to hand back a result that the caller can inspect.

package service
import "fmt"
// saver is a small, locally defined interface.
type saver interface {
    Save(record string) error
}
// Result is the concrete struct returned to callers.
type Result struct {
    Success bool
    Message string
}
// Process accepts any value that satisfies saver and returns a concrete Result.
func Process(s saver, record string) Result {
    err := s.Save(record)
    if err != nil {
        return Result{Success: false, Message: err.Error()}
    }
    return Result{Success: true, Message: "saved"}
}
// Example concrete implementation for production.
type DBStorage struct {
    DSN string
}
func (d DBStorage) Save(record string) error {
    // Real database interaction would happen here.
    fmt.Printf("Saving %q to database at %s\n", record, d.DSN)
    return nil
}

In production, a DBStorage is passed to Process. In tests, a tiny stub is enough:

type stubSaver struct {
    saved []string
}
func (s *stubSaver) Save(record string) error {
    s.saved = append(s.saved, record)
    return nil
}
// ...
s := &stubSaver{}
result := Process(s, "test-record")
// result.Success == true, s.saved contains "test-record"

The Process function never knows about databases. The test never touches one. And the caller of Process sees a Result and can immediately check result.Success without a type assertion.

If your code reads like this, you’ve applied the idiom correctly:

The combination of an interface parameter for the dependency and a concrete return type produces functions that are easy to test, easy to read, and easy to change. When every function in a package follows this pattern, the package becomes a collection of small, composable building blocks.

Summary

“Accept interfaces, return structs” is a design habit that aligns your Go code with the language’s strengths: implicit interfaces, consumer‑owned abstractions, and straightforward static types. It pushes you to:

  • Depend on behaviour, not on origin — accept interfaces so callers can supply any implementation.
  • Deliver clarity, not abstraction — return concrete types so callers know exactly what they receive.

The exceptions — unexported implementations, the error interface, genuine multi‑type factories — are few and well‑defined. When you encounter one, you will know it because the alternative of returning a concrete type would either leak internals or force callers into awkward type gymnastics.

What you do next with this idiom depends on the kind of code you’re writing. If you’re building a library, it’s the difference between a package that gets adopted and one that gets wrapped. If you’re writing application code, it’s the difference between tests that run in milliseconds and tests that require a fully provisioned environment. Either way, the habit pays off immediately.