Sunday, August 16, 2026
Why Should You Accept Interfaces, Return Structs in Go?
Posted by

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.