Small Interfaces
Why Go interfaces are at their best when they are tiny - often a single method - and how to use this idiom to build flexible, testable code.
When you open the Go standard library, the same pattern appears everywhere: interfaces with one method. io.Reader has Read. io.Writer has Write. fmt.Stringer has String. This is not a coincidence or a stylistic quirk — it is a deliberate design idiom that shapes how Go programs stay modular, testable, and easy to change. Small interfaces are the engine behind Go’s implicit satisfaction and composition-based polymorphism.
What a Small Interface Looks Like
A small interface in Go is exactly what the name suggests: an interface that declares only the methods a particular consumer genuinely needs. More often than not, that means exactly one method.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
Each of these describes a single, well‑defined capability. A type that can read bytes satisfies Reader. A type that can write bytes satisfies Writer. A type that can close a resource satisfies Closer. There is no obligation to cram unrelated abilities into the same contract.
The convention is to name a one‑method interface with an -er suffix built from the method name: Read becomes Reader, Write becomes Writer, Close becomes Closer, String becomes Stringer. This naming signals to every Go developer that the interface is tiny and focused.
Why the -er suffix matters:
The agent‑noun pattern (Reader, Writer, Stringer) is not enforced by the compiler, but it is so deeply embedded in the Go ecosystem that straying from it creates friction. A name like DataHandler tells you nothing about the number of methods or the scope of the contract; ReadCloser tells you exactly two things and nothing more.
Why the Standard Library Is Built on Single-Method Interfaces
The real proof is in io and fmt. The entire I/O system rests on Reader and Writer. Every file, network connection, byte buffer, and HTTP response body plugs into the same two abstractions.
func copyData(dst Writer, src Reader) (int64, error) {
buf := make([]byte, 32*1024)
var written int64
for {
n, err := src.Read(buf)
if n > 0 {
w, wErr := dst.Write(buf[:n])
written += int64(w)
if wErr != nil {
return written, wErr
}
}
if err == io.EOF {
break
}
if err != nil {
return written, err
}
}
return written, nil
}
A function like this works with any source and any destination that understand Read and Write. It does not care whether the source is a file, a TCP connection, or an in‑memory buffer. That is the power of narrow interfaces: they make the contract so small that an enormous variety of concrete types can satisfy it without any extra work.
This is where beginners should think of small interfaces as capability markers. Instead of asking “what type is this thing?” the code asks “can this thing do X?” The smaller the X, the more things can do it.
The Damage Fat Interfaces Cause
A fat interface bundles many methods that may or may not be needed by every consumer. It often looks like this in real code:
// Anti-pattern: an interface that tries to describe everything a repository does.
type Repository interface {
Save(user User) error
FindByID(id string) (User, error)
FindByEmail(email string) (User, error)
Update(user User) error
Delete(id string) error
List(filter Filter) ([]User, error)
Count(filter Filter) (int, error)
}
A function that only needs to look up a user by ID is now forced to depend on Repository — and therefore on Save, Update, Delete, and every other method it will never call. This creates three concrete problems.
Testing becomes heavyweight. To write a test, you must implement every method on the mock, even though the function under test only uses one. That leads to stubs full of panic("not implemented") or boilerplate that distracts from the test’s purpose.
Refactoring becomes dangerous. Adding a new method to the interface breaks every implementation, including mocks and stubs you wrote months ago for a completely unrelated piece of code. You cannot evolve the contract without touching every consumer, even the ones that do not care about the new method.
Reuse disappears. A different service that also needs to find users cannot reuse anything because it is chained to the entire Repository monolith. You end up writing the same interface again, slightly different, or coupling parts of the codebase that should remain independent.
Fat interfaces silently kill reuse:
When a developer sees a fat interface, the instinct is to make a new, slightly smaller one — and now the codebase has two overlapping interfaces that do not compose, with duplicate mock implementations and no shared understanding. This fragmentation grows silently until no one knows which interface to use for what.
How to Keep Interfaces Small in Your Own Code
The core rule: build the interface from the consumer’s perspective, not the provider’s. Don’t start with a struct and ask “what methods does it have?” Start with the function that will accept the interface and ask “what is the absolute minimum it needs to do its job?”
A concrete workflow:
- Write the function that will use the dependency, calling the methods it needs on a concrete type.
- Observe which methods the function actually calls.
- Extract exactly those methods into an interface.
- Name the interface after the capability it represents (ideally an
-ername).
// Step 1 & 2: This function only needs to fetch one user by ID.
func sendWelcomeEmail(fetcher UserFetcher, userID string) error {
user, err := fetcher.FetchByID(userID)
if err != nil {
return fmt.Errorf("fetching user: %w", err)
}
return emailer.Send(user.Email, welcomeTemplate)
}
// Step 3: Extract the minimal interface.
type UserFetcher interface {
FetchByID(id string) (User, error)
}
Now sendWelcomeEmail accepts anything that can fetch a user by ID. A real database, a cache layer, or a test stub — all equally possible, no extraneous methods required.
If your interface has one method, you’re doing it right:
A single-method interface is not a sign you haven't thought hard enough; it is the sign that you’ve isolated the one thing the consumer genuinely depends on. Most well‑designed Go interfaces stay in the 1–2 method range.
Composing Small Interfaces into Larger Ones
Small interfaces do not mean you cannot express richer contracts. They mean you build richer contracts by composing small ones. Go’s interface embedding makes this natural.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
// Compose them when a consumer really needs multiple capabilities.
type ReadWriter interface {
Reader
Writer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}
A function that needs to read and write should accept ReadWriter, not some massive File interface. Each consumer takes exactly the set of capabilities it requires, and implementations that satisfy the larger interface automatically work wherever the smaller pieces are expected.
This composability is what makes small interfaces so robust. You can grow contracts organically, adding a Reader here, a Closer there, without ever breaking existing code that only depends on one of the pieces.
Common Mistakes When Designing Small Interfaces
Small interfaces are simple in theory, but a few patterns show up repeatedly in real projects.
Mistake 1: Defining the interface next to the implementation.
When the interface lives in the same package as the struct, you lose the consumer‑driven design. The interface tends to grow every method the struct happens to have. Instead, define the interface in the package that uses it — the consumer.
// BAD: interface in the same package as the implementation, mirroring every method.
package storage
type Store interface {
Save(data []byte) error
Load(id string) ([]byte, error)
Delete(id string) error
List() ([]string, error)
}
type fileStore struct { ... }
func (s *fileStore) Save(...) error { ... }
func (s *fileStore) Load(...) ([]byte, error) { ... }
// ...
// GOOD: interface defined where it is consumed, with only what is needed.
package report
type loader interface {
Load(id string) ([]byte, error)
}
func Generate(l loader, reportID string) error {
data, err := l.Load(reportID)
// ...
}
Mistake 2: Adding methods to an interface “just in case.”
You suspect a future consumer might need Count() or Validate(), so you add them now. This violates the consumer‑driven rule and bloats every implementation. Add methods only when a real consumer demands them.
Future‑proofing with interface methods backfires:
Every method you add to an interface “for the future” must be implemented by every present‑day mock, stub, and adapter. That is maintenance overhead you pay immediately for a future that may never arrive.
Mistake 3: Returning an interface when a concrete type would do.
But as a small‑interface principle, returning a narrow interface from a constructor robs the caller of the ability to use the concrete type’s other methods without a type assertion. Let the caller decide what interface they want.
Testing Becomes Trivial with Small Interfaces
The immediate payoff of small interfaces appears in test files. A function that needs a UserFetcher (one method) can be tested with a tiny stub:
type stubFetcher struct {
user User
err error
}
func (s stubFetcher) FetchByID(id string) (User, error) {
return s.user, s.err
}
func TestSendWelcomeEmail(t *testing.T) {
stub := stubFetcher{user: User{Email: "test@example.com"}}
err := sendWelcomeEmail(stub, "usr_1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
Compare that to the mock you would need for the fat Repository interface — seven methods, most of them unreachable panics. The stub above is trivial to write, easy to read, and safe to change. This alone is enough reason to keep interfaces small.
The Interface Segregation Principle in Go Terms
The idea of small interfaces is not unique to Go, but Go’s implicit satisfaction and lack of type hierarchy make it especially important. In other languages, a “fat interface” can be partially implemented with default methods or abstract base classes. Go has none of that. Every type must implement every method of the interface it satisfies.
This means a fat interface in Go is not just a design smell — it is an active barrier. A type cannot “mostly” satisfy a large interface; it is either fully compliant or useless. Small interfaces lower that barrier so that a single concrete type can satisfy many different contracts without carrying methods it does not need.
Think of it this way: the smaller the interface, the greater the chance an existing type already implements it without any modification. This is how *os.File automatically satisfies io.Reader, io.Writer, io.Closer, io.Seeker, and several others — each a tiny, focused contract.
Summary
Small interfaces are not a minor stylistic preference. They are the mechanism that makes Go’s implicit polymorphism work at scale. When you keep interfaces to one or two methods, you get:
- Consumer‑driven design — the caller defines the contract, not the implementer.
- Trivial testability — stubs and mocks need only the methods actually used.
- Painless evolution — adding a new method means creating a new, optional interface, not breaking every implementation.
- Composition over hierarchy — complex contracts are built by embedding simple ones, not by inheritance.
A quick self-check:
Look at your most recent Go project. Count the methods on each interface you defined. If most have more than two methods, you are fighting the language’s design. Try moving each interface into the package that calls it and stripping it down to only the methods that specific package actually calls.