Back to blog

Sunday, August 16, 2026

Why Should You Accept Interfaces, Return Structs in Go?

Title card for 'Why Should You Accept Interfaces, Return Structs in Go?' with a short code example

Because the caller owns what they need, and the producer owns what they built. A parameter typed as a small interface accepts a real DB, a tx, or a fake. A return typed as the struct you constructed keeps fields and extra methods without a type assertion. Returning an interface when there is only one implementation is a lock you put on your own callers.

The failure

The lookup cannot be tested without a live database. The constructor hides a type nobody else implements.

func LookupUser(db *sql.DB, id int) (*User, error) {
    row := db.QueryRow(`SELECT name FROM users WHERE id = $1`, id)
    // ...
}

type Cache interface {
    Get(k string) (string, bool)
}

func NewCache() Cache {
    return &memoryCache{data: make(map[string]string)}
}

LookupUser only needed QueryRow. It demanded *sql.DB. NewCache only ever returns *memoryCache. Callers cannot call extra methods or embed the struct without asserting.

Why this happens

Go interfaces are satisfied implicitly. The consumer can declare a one-method interface next to the function. Any struct in any package that has that method works. If the producer owns a fat Store interface and returns it, every consumer depends on methods they do not call, and mocks grow with the interface.

Returning a concrete value is cheap and honest: one type, one layout. Returning an interface is the right move only when the caller must treat several concrete types the same way without knowing which one they got.

That is why error is an interface: many types, one method. That is why io.Reader is an interface on the parameter side: the function reads, it does not construct the source.

The fix

Define the dependency next to the function. Return the struct.

type rowQuerier interface {
    QueryRow(query string, args ...any) *sql.Row
}

func LookupUser(db rowQuerier, id int) (*User, error) {
    row := db.QueryRow(`SELECT name FROM users WHERE id = $1`, id)
    var u User
    err := row.Scan(&u.Name)
    return &u, err
}

type memoryCache struct {
    data map[string]string
}

func NewCache() *memoryCache {
    return &memoryCache{data: make(map[string]string)}
}

Tests pass a stub with QueryRow. Production passes *sql.DB or *sql.Tx. Callers of NewCache see the real type. If they later need a Getter interface, they declare it.

Return an interface when:

  • the concrete type is unexported and the constructor is the only legal factory
  • you return error
  • a factory truly picks among several types the caller must treat uniformly

Do not return any. That is not this idiom. Do not put a 20-method repository interface in the producer package "for mocking." Keep the interface as small as the call site.