Pragmatic Go: The Power of Small Interfaces and Composition
"Master the art of small, single-method interfaces in Go. Learn why consumer-defined abstractions and composition beat traditional OOP inheritance."
Pragmatic Go: The Power of Small Interfaces and Composition
One of the defining design philosophies of the Go programming language is:
"The bigger the interface, the weaker the abstraction." — Rob Pike
Coming from object-oriented backgrounds (Java, C#, C++), developers often bring inheritance hierarchies and massive interface definitions containing dozens of methods. In Go, this produces brittle, hard-to-test codebases.
Idiomatic Go leverages small, single-method interfaces, duck typing (structural typing), and consumer-defined abstractions.
The Philosophy: Standard Library Blueprint
Consider the two most influential interfaces in the Go standard library:
// package io
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Because io.Reader and io.Writer only require a single method, hundreds of decoupled types in the standard library and third-party ecosystem seamlessly interlock:
os.File,net.Conn,bytes.Buffer,crypto/cipher,gzip.Writer,http.Response.Body.
You can pipe an HTTP response directly into an S3 multipart uploader through a gzip compression stream without any concrete type knowing about the others:
io.Copy(gzipWriter, httpResponse.Body)
Anti-Pattern: Provider-Side Monster Interfaces
In typical OOP patterns, developers define interfaces alongside their implementations in the provider package:
// BAD: Anti-pattern in package "repository"
package repository
type UserRepository interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, u *User) error
UpdateUser(ctx context.Context, u *User) error
DeleteUser(ctx context.Context, id string) error
ListUsers(ctx context.Context, filter Filter) ([]*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
UpdatePassword(ctx context.Context, id string, hash string) error
// 20 more methods...
}
Why this hurts:
- Mock Bloat: Unit testing any service that needs
GetUserrequires mocking 25 unused methods. - Coupling: Every consumer is tightly coupled to the entire schema surface of the provider.
- Violates ISP (Interface Segregation Principle): Clients are forced to depend on methods they do not use.
Idiomatic Solution: Consumer-Defined Interfaces
In Go, the consumer defines the interface, not the producer.
A business service only declares what it needs to execute its specific responsibility:
// GOOD: In package "auth"
package auth
import "context"
// AuthUserProvider requires ONLY what this package touches
type UserFetcher interface {
GetByEmail(ctx context.Context, email string) (*User, error)
}
type Service struct {
users UserFetcher
}
func NewService(users UserFetcher) *Service {
return &Service{users: users}
}
func (s *Service) Authenticate(ctx context.Context, email, password string) (*Session, error) {
user, err := s.users.GetByEmail(ctx, email)
if err != nil {
return nil, err
}
// Verify password hash and generate session...
return &Session{UserID: user.ID}, nil
}
Because Go interfaces are satisfied implicitly (structural typing), your concrete PostgresUserRepository struct automatically satisfies UserFetcher without importing the auth package or declaring an explicit implements keyword.
Interface Composition
Small interfaces combine gracefully into composite interfaces when richer behavior is required:
package storage
import "io"
type ReadCloser interface {
io.Reader
io.Closer
}
type ReadWriteSeeker interface {
io.Reader
io.Writer
io.Seeker
}
Pragmatic Rules for Go Interfaces
1. Do Not Export Interfaces Before You Have Multiple Implementations
Unless building an exported public library with known extension points, accept concrete structs and return concrete structs. Abstract into interfaces only when:
- You need to inject mocks for unit testing.
- You have multiple production implementations (e.g.,
S3BlobStoreandDiskBlobStore).
2. Keep Method Counts Low
- Target 1 to 3 methods per interface.
- Interfaces with methods often signal a structural code smell or poor cohesion.
3. Accept Interfaces, Return Structs
// GOOD
func Process(r io.Reader) (*Result, error) { ... }
// AVOID (unless required for dynamic polymorphism)
func NewWorker() WorkerInterface { ... }
Returning concrete structs enables callers to access newly added methods without breaking interfaces, while accepting interfaces gives your functions maximum flexibility.
Summary
| OOP Tradition | Idiomatic Go |
|---|---|
| Large upfront interfaces | Minimal single-method interfaces |
Explicit implements keyword |
Implicit structural satisfaction |
| Producer owns the interface | Consumer owns the interface |
| Deep inheritance trees | Struct embedding and interface composition |
By keeping interfaces tiny and consumer-centric, Go codebases remain modular, effortlessly testable, and resilient to refactoring.
About the Author
huud
@huud
Systems architect and software engineer building high-performance distributed platforms.