Interface Best Practices — Find the Bug¶
Each exercise follows this format: 1. Buggy code 2. Hint 3. Identifying the bug and its cause 4. Fixed code
Every bug below pivots on a single broken interface-design rule. Read the hint, try to spot the smell, then compare your reasoning against the cause and fix sections.
Bug 1 — Returning an interface when a struct fits¶
package store
type Reader interface {
Get(key string) ([]byte, error)
}
type fileReader struct{ path string }
func (f *fileReader) Get(key string) ([]byte, error) { /* ... */ return nil, nil }
func (f *fileReader) Stats() Stats { /* ... */ return Stats{} }
// NewReader is the only way callers obtain a fileReader.
func NewReader(path string) Reader {
return &fileReader{path: path}
}
package main
import "example.com/store"
func main() {
r := store.NewReader("data.bin")
s := r.Stats() // ?
_ = s
}
Hint: What does the caller actually see?
Bug: The constructor returns the interface store.Reader, not the concrete *fileReader. The Stats method exists on the concrete type but is not part of Reader, so the caller cannot reach it. Compile error: r.Stats undefined (type store.Reader has no field or method Stats). The guideline "accept interfaces, return structs" was inverted.
Fix:
// Export the concrete type and return it.
type FileReader struct{ path string }
func (f *FileReader) Get(key string) ([]byte, error) { /* ... */ return nil, nil }
func (f *FileReader) Stats() Stats { /* ... */ return Stats{} }
func NewReader(path string) *FileReader {
return &FileReader{path: path}
}
Callers who only need Get can still pass *FileReader to any function that accepts a Reader interface — the interface stays on the consumer side, where it belongs.
Bug 2 — Defining the interface at the producer side¶
package mailer
type Sender interface {
Send(to, subject, body string) error
}
type SMTP struct{ /* ... */ }
func (s *SMTP) Send(to, subject, body string) error { /* ... */ return nil }
package signup
import "example.com/mailer"
type Service struct {
mail mailer.Sender // depends on the producer's interface
}
func (s *Service) Welcome(user string) error {
return s.mail.Send(user, "Welcome", "Hi!")
}
Hint: Who decides the shape of Sender?
Bug: The interface Sender is declared in mailer (the producer) and re-used by signup (the consumer). Every consumer that wants to swap the dependency for a fake — for tests, for an alternative provider, for a queue-backed sender — must drag in the mailer package and match its full method set. The consumer cannot tailor a smaller interface to its own needs.
Fix: Define the interface in the consumer package, narrow to what that package actually calls.
package signup
type mailSender interface {
Send(to, subject, body string) error
}
type Service struct {
mail mailSender // small, package-local, easy to fake
}
mailer.SMTP still satisfies it implicitly. Tests in signup can now provide a one-method fake without importing mailer.
Bug 3 — Header interface impossible to mock¶
package storage
type Storage interface {
Get(key string) ([]byte, error)
Put(key string, val []byte) error
Delete(key string) error
List(prefix string) ([]string, error)
Stat(key string) (Info, error)
Copy(src, dst string) error
Move(src, dst string) error
Lock(key string) (Unlock, error)
Snapshot() (Snapshot, error)
Compact() error
Close() error
}
package report
type Generator struct {
store storage.Storage // depends on the whole header
}
func (g *Generator) Run() error {
data, err := g.store.Get("report.tmpl")
if err != nil { return err }
_ = data
return nil
}
Hint: How big is the test double for Generator?
Bug: Generator only calls Get, but to test it you must implement all eleven methods of storage.Storage — a "header interface" that captures everything the implementation can do. Mocks become walls of empty stubs and any new method on Storage breaks every test.
Fix: Depend on the smallest interface the consumer actually uses.
package report
type templateFetcher interface {
Get(key string) ([]byte, error)
}
type Generator struct {
store templateFetcher
}
A test fake now has a single method. storage.Storage still satisfies templateFetcher, but the contract at the call site is honest.
Bug 4 — Missing optional capability detection¶
package copyutil
import "io"
func CopyAll(dst io.Writer, src io.Reader) (int64, error) {
buf := make([]byte, 32*1024)
var total int64
for {
n, err := src.Read(buf)
if n > 0 {
m, werr := dst.Write(buf[:n])
total += int64(m)
if werr != nil { return total, werr }
}
if err == io.EOF { return total, nil }
if err != nil { return total, err }
}
}
Hint: What does the standard library's io.Copy do that this one does not?
Bug: Some readers know how to write themselves into a writer faster than the generic loop (e.g. *os.File on Linux uses sendfile). The standard pattern is to probe for an optional capability with a type assertion and fall back when it is missing. This implementation skips the probe and pays the buffer-copy cost every time.
Fix:
func CopyAll(dst io.Writer, src io.Reader) (int64, error) {
if wt, ok := src.(io.WriterTo); ok {
return wt.WriteTo(dst)
}
if rf, ok := dst.(io.ReaderFrom); ok {
return rf.ReadFrom(src)
}
// ... slow path identical to before
return 0, nil
}
The function still accepts the small io.Reader / io.Writer interfaces, but lets richer types opt in to a faster path.
Bug 5 — Missing compile-time satisfaction check¶
package handlers
import "net/http"
type AuthHandler struct{ /* ... */ }
// Typo: ServerHTTP instead of ServeHTTP.
func (h *AuthHandler) ServerHTTP(w http.ResponseWriter, r *http.Request) {
// ... auth logic ...
}
// Registry stored as interface{} — http.Handler check is deferred.
var registry = map[string]any{}
func Register(path string, h any) { registry[path] = h }
func init() {
Register("/login", &AuthHandler{}) // compiles fine
}
Hint: Where does the http.Handler contract get checked?
Bug: Because Register accepts any, the compiler never verifies that *AuthHandler satisfies http.Handler. The misspelled ServerHTTP slips past the build, and at request time the dispatcher returns 404 (or panics on a type assertion). Without an explicit assertion in the file that owns the type, a renamed or typo'd method silently breaks the contract.
Fix: Place a compile-time check next to the type definition.
type AuthHandler struct{ /* ... */ }
// Compile-time assertion: *AuthHandler must satisfy http.Handler.
var _ http.Handler = (*AuthHandler)(nil)
func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ...
}
Now any rename or signature drift fails the build immediately, in the file that owns the type — long before the type is laundered through any.
Bug 6 — Interface for a single implementation in the same package¶
package billing
type Calculator interface {
Total(items []Item) Money
}
type calculator struct{ taxRate float64 }
func (c *calculator) Total(items []Item) Money { /* ... */ return Money{} }
func New(taxRate float64) Calculator {
return &calculator{taxRate: taxRate}
}
The package has exactly one implementation, no tests that swap it, and no external consumer that needs to substitute it.
Hint: What does the interface buy you here?
Bug: Defining an interface "just in case" introduces indirection with no benefit: every method call goes through a vtable, the concrete type's extra methods are hidden, godoc shows two types instead of one, and the abstraction lies about extensibility. Idiomatic Go interfaces appear when more than one implementation exists — typically introduced from the consumer side at that moment, not preemptively.
Fix:
package billing
type Calculator struct{ taxRate float64 }
func (c *Calculator) Total(items []Item) Money { /* ... */ return Money{} }
func New(taxRate float64) *Calculator {
return &Calculator{taxRate: taxRate}
}
If a second implementation appears later, declare a small interface in the package that needs the swap.
Bug 7 — any parameter where a generic would be honest¶
package collections
// Find returns the first element for which match returns true.
func Find(items []any, match func(any) bool) any {
for _, it := range items {
if match(it) {
return it
}
}
return nil
}
ages := []int{12, 17, 21, 30}
boxed := make([]any, len(ages))
for i, a := range ages { boxed[i] = a }
v := collections.Find(boxed, func(x any) bool {
return x.(int) >= 18 // type assertion at every call
}).(int)
_ = v
Hint: What does the caller pay for any?
Bug: any here is a stand-in for "I gave up on types". Callers must box every element into []any, write type assertions inside the predicate, and unbox the result. There is no real polymorphism — only one abstract slot — which is exactly what type parameters are for. The interface (any is interface{}) is the wrong tool: a generic function expresses the contract precisely and removes the boxing.
Fix (Go 1.18+):
func Find[T any](items []T, match func(T) bool) (T, bool) {
for _, it := range items {
if match(it) {
return it, true
}
}
var zero T
return zero, false
}
v, ok := collections.Find(ages, func(x int) bool { return x >= 18 })
_, _ = v, ok
No assertions, no boxing, the compiler catches mismatches.
Bug 8 — Premature abstraction blocking refactoring¶
package pipeline
type Stage interface {
Name() string
Configure(map[string]string) error
Validate() error
Open() error
Process(in <-chan Event, out chan<- Event) error
Close() error
Metrics() Metrics
}
// Only one stage exists today: *FilterStage. The interface was added
// "to keep the pipeline pluggable" before a second stage was needed.
type FilterStage struct{ /* ... */ }
When the team finally adds a BatchStage, they discover that Configure should really return computed defaults, Open should accept a context, and Metrics should be pulled instead of pushed. Every change ripples through the interface, all callers, and all eight stub methods of the test fake — even though only one real implementation has ever shipped.
Hint: Which is harder to change: a struct, or an interface its consumers already depend on?
Bug: The interface was speculative. Its shape locked in assumptions made before any second implementation existed, and now refactoring it costs more than refactoring a struct would. The Go proverb applies: "the bigger the interface, the weaker the abstraction" — and an interface adopted before the second user is, in practice, a copy of the first implementation's surface area.
Fix: Wait. Use the concrete *FilterStage until a real second user appears, then extract the smallest interface both implementations genuinely share. Refactoring a single struct touches one file; refactoring an interface touches every fake and every consumer.
Bug 9 — Poorly named interface (no -er suffix, vague intent)¶
package report
type ReportThing interface {
DoIt(ctx context.Context, id string) ([]byte, error)
}
func Render(rt ReportThing, id string) ([]byte, error) {
return rt.DoIt(context.Background(), id)
}
Hint: Read the call site aloud.
Bug: Two related smells. (1) The interface name ReportThing describes a thing rather than a behavior — Go's convention for single-method interfaces is the action plus -er (Reader, Stringer, Closer). (2) The method DoIt has no semantic content. Together they defeat the main purpose of an interface: documenting intent at the call site. Render(rt, id) reads as nonsense; future readers cannot guess what rt is supposed to do without jumping to the definition.
Fix:
type Renderer interface {
Render(ctx context.Context, id string) ([]byte, error)
}
func Render(r Renderer, id string) ([]byte, error) {
return r.Render(context.Background(), id)
}
The interface name is a noun derived from the verb, the method name matches the action, and the call site self-documents.
Bug 10 — Embedding too aggressively into a god interface¶
package fs
type Reader interface{ Read(p []byte) (int, error) }
type Writer interface{ Write(p []byte) (int, error) }
type Closer interface{ Close() error }
type Seeker interface{ Seek(int64, int) (int64, error) }
type Stater interface{ Stat() (Info, error) }
type Locker interface{ Lock() error; Unlock() error }
type Syncer interface{ Sync() error }
// "Convenience" interface — embeds everything, used everywhere.
type File interface {
Reader
Writer
Closer
Seeker
Stater
Locker
Syncer
}
func Process(f File) error { /* ... only calls Read and Close */ return nil }
Hint: What does Process actually need from f?
Bug: Embedding small interfaces into a single mega-type creates a "god interface" — every consumer pays for the union of all capabilities even when it uses one or two. Implementations must support every embedded contract; mocks must implement seven methods to test a function that touches two; an in-memory fake that has no Sync semantics is forced to add a no-op. The composition is going the wrong direction: small interfaces should be embedded by callers as needed, not pre-aggregated by the producer.
Fix: Let each consumer compose what it needs.
package fs
type Reader interface{ Read(p []byte) (int, error) }
type Closer interface{ Close() error }
// ... other one-method interfaces stay tiny
// Consumer composes only what it uses.
func Process(f interface {
Reader
Closer
}) error {
// ...
return nil
}
Or, in the consumer's own package:
Each call site documents its real dependency, and tests need only the methods that call site exercises.
Cheat Sheet¶
INTERFACE DESIGN PITFALLS
─────────────────────────────────────────
1. Return interface, hide concrete → caller loses extra methods
2. Producer-side interface → consumer can't shape its own
3. Header interface (10+ methods) → mocks become walls of stubs
4. No optional-capability probe → miss WriterTo / ReaderFrom fast paths
5. No compile-time `var _ I = ...` → silent breakage on rename
6. Interface for one impl, same package → indirection with no payoff
7. `any` instead of generic → boxing + assertions everywhere
8. Premature abstraction → refactor-locked, costs more later
9. Vague name / no -er suffix → call site reads as nonsense
10. God interface via embedding → every consumer pays for everything
GUIDING PROVERBS
─────────────────────────────────────────
• "Accept interfaces, return structs."
• "The bigger the interface, the weaker the abstraction."
• "Don't design with interfaces — discover them."
• Interfaces belong on the consumer side, kept as small as possible.
TOOLS
─────────────────────────────────────────
go vet ./... # interface satisfaction sanity
staticcheck ./... # flags ineffective receivers, unused interfaces
golangci-lint run # bundles the above + ifacecheck-style linters