Interface Design Idioms
Learn the three key interface design idioms in Go - small interfaces, accepting interfaces and returning structs, and balancing generality.
Go interfaces are implicit — a type satisfies an interface simply by having the right methods. That freedom is powerful, but it also means the shape and placement of the interfaces you define determines how testable, reusable, and readable your code becomes. The three idioms in this chapter are not rigid rules, but they appear again and again in well‑designed Go codebases. Together they guide you toward interfaces that stay out of your way.
Small Interfaces
The interfaces you encounter most often in the standard library declare a single method.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Stringer interface {
String() string
}
A small interface is one that captures one, or at most a very few, well‑defined behaviors. The naming convention reinforces this: a single‑method interface often ends with -er — Reader, Writer, Formatter.
The reason this is so effective is that a tiny interface can be satisfied by almost anything. An io.Reader wraps a file, a network connection, an HTTP request body, a byte buffer in memory, or a compressed stream. Each of those types has its own rich set of methods, but they all share the ability to Read. By asking for io.Reader, you decouple your function from every irrelevant detail and accept any value that can supply bytes.
Avoid Interface Pollution:
When an interface grows beyond one or two methods it quickly becomes an obstacle. A BigInterface with five unrelated methods forces every implementation to provide all of them — even if the caller only ever uses one. It makes mocking in tests painful and hides the real dependency. Start with small, focused interfaces and compose them later if you genuinely need the combination.
How to Think About Small Interfaces as a Beginner
Imagine an interface as a job description with exactly one bullet point. If you hire someone who can "open a door," you don't care whether they're a security guard, a cleaner, or a CEO — you just need the door opened. A function that takes an io.Writer is saying: "I write bytes somewhere; I don't care where." The rest of the concrete type's resume (its other methods) is invisible inside that function.
Common Mistake: Starting with the Implementation
A typical mistake is to look at an existing struct, note all its methods, and immediately create an interface that mirrors the entire struct. That anchors the interface to one concrete type and defeats the purpose. Instead, define the interface where it is consumed — inside the package that uses it — and include only the methods that package actually calls.
// In a package that only needs to save a user, do this:
type userSaver interface {
Save(u User) error
}
func Register(saver userSaver, u User) error {
// ...
return saver.Save(u)
}
The userSaver interface has one method. Any database, mock, or file store that has a Save method will work, and the interface lives right next to the code that depends on it — not exported to the world.
How Small Interfaces Appear in Production
The io.Reader and io.Writer interfaces are the backbone of Go I/O. Functions like io.Copy accept a Writer and a Reader, which means they can copy between a file and a network socket, or from an HTTP response straight to disk, all without change. In test code, you replace a real io.Reader with strings.NewReader("test data") and instantly turn an integration test into a unit test.
Accept Interfaces, Return Structs
A function that declares a parameter as an interface opens itself to any concrete type that satisfies it. That's the "accept interfaces" half. The second half is that when a function creates a value and hands it back, it should return a concrete type — typically a struct pointer — not an interface.
// Right: parameter is an interface, return is concrete
func NewUserStore(cfg Config) *UserStore {
return &UserStore{cfg: cfg}
}
// Wrong: returning an interface from a constructor for no reason
func NewUserStore(cfg Config) UserRepository {
return &UserStore{cfg: cfg}
}
Why is returning a concrete type better? Because the caller might need more than what the interface exposes. If UserStore has a method BulkImport that is not part of UserRepository, returning *UserStore lets callers use it directly. If you instead return UserRepository, the caller is stuck with only the interface methods unless they perform a type assertion — a noisy, fragile workaround.
Don't Return Interfaces for the Sake of Abstraction:
Sometimes developers return an interface from a constructor because they want to "hide" the implementation or leave room to swap it later. In Go, the caller can define its own interface locally if it needs that flexibility. The producer should deliver the richest concrete value it has; the consumer can narrow that value down to the interface it needs.
There is a legitimate exception: a factory function that must return different concrete types based on input, and where the caller truly only needs the interface. The standard library's crypto/rand.Reader returns an io.Reader whose exact underlying type depends on the operating system. In such cases, the function's entire purpose is to provide a polymorphic value, so returning an interface is the whole point. That pattern is the Handle/Body idiom, but it should be a deliberate choice, not the default.
The Mental Model for Accept Interfaces, Return Structs
Think of it like a restaurant. The chef (the function) serves you a complete meal (the concrete struct) on a plate. You, as the customer, decide to eat only the parts you want — maybe you define a DessertEater interface that only requires EatDessert(). The chef doesn't hand you a "dessert‑only" plate and hide the rest; that would be wasteful. Functions that accept interfaces are like customers who specify what they need; functions that return structs are like chefs who provide everything they've got.
A Concrete Example
type MetricsCollector struct {
db *sql.DB
cache *redis.Client
}
func (mc *MetricsCollector) Increment(key string) { /* ... */ }
func (mc *MetricsCollector) Snapshot() Snapshot { /* ... */ }
// Exported function accepts a small interface for flexibility.
func TrackActivity(tracker interface{ Increment(string) }, action string) {
tracker.Increment(action)
}
func main() {
mc := NewMetricsCollector(dsn, redisAddr)
TrackActivity(mc, "user_signup") // mc satisfies the interface
// main still has access to mc.Snapshot() because mc is concrete
}
The TrackActivity function requires only the Increment behavior, so it accepts a minimal interface. The caller gets back the full *MetricsCollector and can use Snapshot afterward, without any type assertion.
Let the Caller Decide the Abstraction:
When you return a concrete struct, you give the caller the most information possible. When you accept an interface, you require the least information possible. Together, those two directions keep dependencies loose and types precise.
Interfaces and Generality
Deciding how general an interface should be is a design tension present in every Go project. Too specific, and the interface can't be reused. Too broad, and it becomes meaningless or burdensome. The goal is to capture the smallest behavioral contract that still makes sense across the implementations you actually need.
The io.Reader interface is an example of a perfectly general interface: it says nothing about where bytes come from or how they are stored, only that they can be read. That single method is general enough to cover files, sockets, and in‑memory buffers, yet specific enough to be useful in every piece of I/O code in the standard library.
Consumer‑Side Interface Definitions
Idiomatic Go often defines an interface in the package that uses it, not the package that implements it. This is the opposite of languages like Java, where the interface typically lives with its implementations. In Go, if a package only needs two methods from a user store, it declares a two‑method interface locally, even if the concrete user store has twenty methods. That local interface is by definition neither too broad nor too narrow — it captures exactly what this package needs.
// Inside package notifications
type contactFinder interface {
EmailFor(userID string) (string, error)
}
func SendWelcome(finder contactFinder, userID string) error {
email, err := finder.EmailFor(userID)
if err != nil {
return err
}
return emailClient.Send(email, "Welcome!")
}
The notifications package does not import a large UserService interface. It defines the single method it actually calls, making the dependency explicit and easy to mock. If another implementation — a caching layer, a gRPC client, a test stub — can provide EmailFor, it works.
When to Export an Interface
Most interfaces should remain unexported, used only within their own package. Export an interface when you intentionally want to establish a contract that other packages should implement. The io.Reader contract is so fundamental that it lives in the io package and is implemented by hundreds of types across many packages. The http.Handler interface is another example: the net/http package exports it because the whole ecosystem needs to agree on what a handler looks like.
Don't Export an Interface for a Single Implementation:
If only one concrete type satisfies an interface, and that type lives in the same package, exporting the interface is premature. It signals a commitment you haven't yet proven is necessary. Wait until you genuinely have multiple consumers or multiple implementations that must be interchangeable.
The Danger of Over‑Generality
A Storable interface with a single method Store() error sounds general, but if every implementation interprets "store" differently — one writes to disk, one sends a network request, one discards the data — the contract is too vague to be useful. An interface must have a clear, shared meaning across implementations. The io.Writer contract is clear because the semantics of writing bytes are well‑understood. If the semantics of your interface differ wildly between implementations, it's likely you need separate interfaces.
The Empty Interface Is the Ultimate Generality:
The empty interface interface{} (or any) accepts every value, but it provides zero information. Using it forces you to perform type assertions or reflection to extract any usable behavior, losing type safety. Reserve it for truly polymorphic containers like fmt.Println or JSON decoders, not for routine function parameters.
Balancing Generality and Precision
When designing an interface, ask: "What is the one thing the consumer truly cannot do without?" Make that the method. If you find yourself writing a method that is only called by half the implementations, split the interface. Small interfaces compose naturally: if a consumer needs both reading and writing, it can embed io.Reader and io.Writer into a ReadWriter interface. That composition is explicit and under the consumer's control, rather than forced by a prematurely large interface.
Summary
These three idioms reinforce each other. Small interfaces make it easy for functions to accept interfaces without forcing callers to implement massive contracts. Accepting interfaces and returning structs ensures that callers receive full, usable values while requiring only the behavior they actually need. Keeping interfaces general enough to be reused, but not so broad that their meaning dissolves, makes those interfaces worth depending on.
The common thread is control: define interfaces where they are consumed, with exactly the methods the consumer calls, and let the concrete implementations remain free to grow independently.