Interface Anti-Patterns — Middle Level¶
Table of Contents¶
- Introduction
- Mock-Driven Design
- Header Interfaces
- Pointer-to-Interface
- Interface Bloat
- io.Reader-Shaped Types That Aren't Readers
- Empty Interface as a Parameter Type
- Wrapper Interfaces That Hide Errors
- Interface in the Same Package as Only Implementation
- Interface That Imitates a Class
- Returning Interface from Constructors
- Catching All Anti-Patterns with go vet, staticcheck, revive
- Refactoring Patterns
- Test
- Cheat Sheet
- Summary
Introduction¶
At junior level we learned about typed-nil and premature abstraction. At middle level the anti-patterns become more subtle: they survive code reviews because they look "professional" but actually choke the codebase. The most damaging ones in this category are mock-driven design and header interfaces — both turn a single struct into three or four files for no benefit.
Each section follows the same shape: BAD → WHY → GOOD.
Mock-Driven Design¶
Symptom¶
You see this in the directory tree:
billing/
├── service.go
├── service_test.go
├── repository.go ← interface
├── repository_postgres.go ← only real implementation
├── repository_mock.go ← generated, ONLY consumer is the test
BAD¶
// repository.go
type Repository interface {
FindCharge(ctx context.Context, id string) (*Charge, error)
SaveCharge(ctx context.Context, c *Charge) error
DeleteCharge(ctx context.Context, id string) error
ListCharges(ctx context.Context, filter Filter) ([]*Charge, error)
CountCharges(ctx context.Context) (int, error)
}
// service.go
type Service struct{ repo Repository }
func (s *Service) ChargeUser(ctx context.Context, userID string, amount int) error {
// ... uses only FindCharge and SaveCharge
}
// repository_mock.go (generated by mockgen)
type MockRepository struct{ /* 200 lines */ }
WHY¶
- The interface exists only because
mockgenwas easier than designing the test. - Every time
Serviceneeds a new repository method, three files change: the interface, the postgres impl, the mock. - Tests cannot tell you whether the postgres implementation actually behaves correctly — they pass against a mock that returns whatever you told it to.
- The interface lists every method a struct has, so the abstraction value is zero (this is the "header interface" anti-pattern combined with mock generation).
GOOD¶
Three options, choose by context:
Option A — declare the minimal interface at the consumer side.
// service.go
type chargeStore interface {
FindCharge(ctx context.Context, id string) (*Charge, error)
SaveCharge(ctx context.Context, c *Charge) error
}
type Service struct{ store chargeStore }
Option B — use a fake implementation written by hand.
type fakeStore struct{ charges map[string]*Charge }
func (f *fakeStore) FindCharge(_ context.Context, id string) (*Charge, error) {
if c, ok := f.charges[id]; ok { return c, nil }
return nil, ErrNotFound
}
func (f *fakeStore) SaveCharge(_ context.Context, c *Charge) error {
f.charges[c.ID] = c
return nil
}
Option C — integration tests against a real database. With testcontainers-go or an in-process SQLite, you bypass mocks entirely. For storage code, this is often the most reliable approach.
The smell¶
If you find yourself writing mockRepo.On("FindCharge", "x").Return(...) to set up a test, ask: would a hand-written fake or a small consumer-side interface be clearer?
Header Interfaces¶
BAD¶
// An interface that lists EVERY method of one struct
type Server interface {
Start() error
Stop() error
Restart() error
Status() Status
SetTimeout(time.Duration)
SetMaxConn(int)
SetTLS(cert, key string)
Metrics() Metrics
Reload() error
HealthCheck() error
}
type httpServer struct { /* ... */ }
// implements every method above
WHY¶
- The interface is just a table of contents for the struct, not an abstraction.
- It violates the Interface Segregation Principle: callers depend on methods they don't use.
- Adding a new method to the struct breaks every alternative implementation (real or mocked).
- The "abstraction" reveals nothing — readers must look at the implementation anyway.
GOOD¶
Define role-based interfaces near consumers:
// In a health-checker package
type healthChecker interface {
HealthCheck() error
}
// In a metrics exporter
type metricsSource interface {
Metrics() Metrics
}
Each is one method. The struct satisfies both implicitly. No header file required.
Pointer-to-Interface¶
This is one of the most common bugs in Go code written by people coming from C++ or C#.
BAD¶
type Reader interface { Read(p []byte) (int, error) }
func ReadAll(r *Reader) ([]byte, error) { // *Reader — pointer to interface
var buf bytes.Buffer
_, err := io.Copy(&buf, *r)
return buf.Bytes(), err
}
WHY¶
- An interface value is already a pointer-sized two-word header (type pointer + data pointer). It is cheap to copy.
- A pointer to an interface adds a level of indirection that buys you nothing — you still have a pointer to a pointer to data.
- It breaks idiomatic call sites:
ReadAll(&someReader)is awkward and error-prone. - 99% of the time the author wrote
*io.Readerbecause they were thinking "I need to mutate the interface variable" — but you can do that with the interface value itself.
GOOD¶
func ReadAll(r io.Reader) ([]byte, error) {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
return buf.Bytes(), err
}
When (extremely rarely) is *Interface legitimate?¶
Only when you genuinely need to reassign the interface value through a parameter:
This is so rare that most Go codebases never need it. If you find yourself reaching for *Interface, reconsider the design.
Interface Bloat¶
BAD¶
type Datastore interface {
Open() error
Close() error
Ping() error
Begin() (Tx, error)
Commit(Tx) error
Rollback(Tx) error
Query(string, ...any) (*Rows, error)
Exec(string, ...any) (Result, error)
Prepare(string) (*Stmt, error)
Stats() Stats
SetMaxOpenConns(int)
SetMaxIdleConns(int)
SetConnMaxLifetime(time.Duration)
Driver() Driver
QueryContext(ctx context.Context, q string, args ...any) (*Rows, error)
// ... and 8 more
}
WHY¶
- The bigger the interface, the weaker the abstraction (Rob Pike).
- No one mock or alternate implementation can fully satisfy a 20-method interface without compromise.
- Method sets on this scale almost always reveal multiple unrelated responsibilities — there is no single role this interface fills.
GOOD¶
Split by capability, and let consumers pick:
type Pinger interface { Ping(context.Context) error }
type Querier interface { Query(context.Context, string, ...any) (*Rows, error) }
type ExecOnly interface { Exec(context.Context, string, ...any) (Result, error) }
type TxBeginer interface { Begin(context.Context) (*Tx, error) }
Note: this is exactly what the standard library does. *sql.DB implements many small roles; consumers depend only on what they need.
io.Reader-Shaped Types That Aren't Readers¶
BAD¶
// A class that "looks like" io.Reader but does something different
type ConfigReader struct{ /* ... */ }
// SAME signature as io.Reader.Read — but the method opens a config file
// and writes its parsed JSON into p. Misleading.
func (c *ConfigReader) Read(p []byte) (int, error) {
raw, err := os.ReadFile(c.path)
if err != nil { return 0, err }
return copy(p, raw), io.EOF
}
WHY¶
- A reader shaped like
io.Readeris expected to behave like a stream — repeated calls, partial reads,io.EOFsemantics. io.Copy(w, c)will work syntactically but produce surprising results.bufio.NewReader(c)will pre-buffer in ways the author never intended.- This is "method squatting" on a well-known interface. The fact that
io.Readeraccepts your type is not a reason to imitate its shape.
GOOD¶
Use a name and signature that match the actual semantics:
type ConfigLoader struct{ path string }
func (c *ConfigLoader) Load() (*Config, error) {
raw, err := os.ReadFile(c.path)
if err != nil { return nil, err }
var cfg Config
return &cfg, json.Unmarshal(raw, &cfg)
}
If you genuinely need to expose the config as a stream of bytes, wrap an actual bytes.Reader:
func (c *ConfigLoader) AsReader() io.Reader {
raw, _ := os.ReadFile(c.path)
return bytes.NewReader(raw)
}
The same warning applies to misusing io.Writer, io.Closer, error, fmt.Stringer, and sort.Interface.
Empty Interface as a Parameter Type¶
BAD (pre-generics, and post-generics when generics fit)¶
func Sum(values []interface{}) float64 { // any in Go 1.18+
var total float64
for _, v := range values {
switch n := v.(type) {
case int: total += float64(n)
case float64: total += n
case int64: total += float64(n)
}
}
return total
}
WHY¶
- The signature accepts anything — including types that produce a runtime panic.
- The implementation pays for runtime type assertions on every iteration.
- Generic constraints would express the intent at compile time.
GOOD (Go 1.18+)¶
type Number interface {
~int | ~int64 | ~float32 | ~float64
}
func Sum[T Number](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
The compiler enforces T is a number; no runtime assertions; the loop is monomorphized for each concrete T.
When is any still right?¶
- True heterogeneity:
fmt.Println(args ...any),json.Marshal(v any). - Reflection-driven libraries (encoding, ORMs).
If your function ends with a switch v.(type) listing 4-5 concrete types, switch to generics.
Wrapper Interfaces That Hide Errors¶
BAD¶
type SafeStorage interface {
Get(key string) string // error swallowed
Put(key, value string) // error swallowed
}
type s3Storage struct{ /* ... */ }
func (s *s3Storage) Get(key string) string {
v, err := s.realGet(key)
if err != nil {
return "" // WHY?
}
return v
}
func (s *s3Storage) Put(key, value string) {
_ = s.realPut(key, value) // discarded
}
WHY¶
- Callers cannot tell the difference between "key not found" and "S3 is down."
- Retries, circuit breakers, and metrics become impossible.
- Logs fill with
Get returned ""events that nobody can debug.
GOOD¶
Errors are first-class in Go. The interface must surface them:
If a caller really wants the "no error" experience, they can wrap:
func mustGet(s Storage, key string) string {
v, err := s.Get(key)
if err != nil { log.Fatalf("Get(%q): %v", key, err) }
return v
}
Interface in the Same Package as Only Implementation¶
BAD¶
package mailer
type Mailer interface {
Send(to, subject, body string) error
}
type smtpMailer struct{ host string }
func (m *smtpMailer) Send(to, subject, body string) error { /* ... */ }
func New(host string) Mailer { // returns interface
return &smtpMailer{host: host}
}
WHY¶
This is the producer-side interface trap. The package providing the implementation also dictates the interface — leaving consumers with whatever set of methods the producer chose. If *smtpMailer later gains a SendBatch method, every consumer that called through the Mailer interface cannot use it.
GOOD¶
package mailer
type SMTPMailer struct{ host string }
func New(host string) *SMTPMailer { return &SMTPMailer{host: host} }
func (m *SMTPMailer) Send(to, subject, body string) error { /* ... */ }
func (m *SMTPMailer) SendBatch(items []Message) error { /* ... */ } // free to add
Consumers that want polymorphism declare their own:
Interface That Imitates a Class¶
BAD¶
type Shape interface {
Area() float64
Perimeter() float64
Scale(factor float64) // mutates
Move(dx, dy float64) // mutates
Color() Color
SetColor(Color) // mutates
Draw(canvas Canvas)
Serialize() ([]byte, error)
}
WHY¶
This isn't an interface — it's an attempt at a class Shape with virtual methods. It mixes: - pure queries (Area, Perimeter, Color) - mutators (Scale, Move, SetColor) - I/O (Draw, Serialize)
Every implementation must provide all eight methods even when many are nonsense (a mathematical Point has zero area but can it be Drawn?).
GOOD¶
Decompose into roles:
type AreaCalculator interface { Area() float64 }
type Drawable interface { Draw(canvas Canvas) }
type Serializer interface { Serialize() ([]byte, error) }
Implement only what makes sense. Most production Go interfaces have one or two methods.
Returning Interface from Constructors¶
BAD¶
The caller cannot use any method outside io.ReadWriter, even though *myBuffer may have Reset(), Cap(), Bytes().
WHY¶
- The constructor is deciding for the caller what interface they want.
- If
io.ReadWriterdoesn't fit the caller's needs, they have to type-assert back — defeating the point. - It is a backwards-compatibility trap: changing the return type to a richer interface is a breaking API change.
GOOD¶
The caller decides how to view the value:
buf := NewBuffer(1024)
var rw io.ReadWriter = buf // they pick the interface
buf.Reset() // they keep the rich struct API
Catching All Anti-Patterns with go vet, staticcheck, revive¶
These tools flag many of the patterns above:
| Tool | Check | Catches |
|---|---|---|
go vet | assign | typed-nil in some forms |
staticcheck | SA4023 | "comparison of typed-nil and untyped-nil never equal" — surfaces the typed-nil bug |
staticcheck | SA1029 | Misuse of context.Context as a map key — sometimes related to interface misuse |
revive | unused-parameter | Receiver/param never used — hints at over-abstraction |
revive | if-return | Often surfaces redundant interface wrapping |
gocritic | ifElseChain | Type-switch chains that should be generics |
golangci-lint interfacebloat | Interfaces with > 10 methods | Interface bloat |
gocritic paramTypeCombine | Parameter shape issues | Incorrect *Interface usage |
Run golangci-lint run ./... --enable interfacebloat,unused-parameter,gocritic,staticcheck regularly.
Refactoring Patterns¶
Strategy 1 — Eliminate a header interface¶
// Before
type Service interface { /* 12 methods */ }
type service struct{ /* impl */ }
// After
type Service struct{ /* impl */ } // no interface; struct exported
Then for each consumer file:
// Before
func (h *Handler) handle(s Service) { ... }
// After — minimal local interface OR pass concrete
func (h *Handler) handle(s *Service) { ... } // if no second impl
Strategy 2 — Eliminate mock-driven design¶
- Keep the interface only if a real second implementation exists.
- Replace mocks with hand-written fakes.
- Or use
testcontainersfor integration tests. - Delete generated mocks and the now-orphan interface.
Strategy 3 — Replace *Interface with Interface¶
A simple grep:
Each match is almost certainly a bug.
Test¶
1. What is the central problem of mock-driven design?¶
- a) Mocks are slow
- b) Tests pass against fictional behavior, and the interface explodes
- c) Mock libraries are expensive
- d)
mockgenis unmaintained
Answer: b
2. A "header interface" is one that:¶
- a) Has only
headerin its name - b) Lists every method of exactly one struct
- c) Is in a header file
- d) Has no methods
Answer: b
3. Why is *io.Reader almost always wrong?¶
- a)
io.Readeris private - b) An interface value is already a small header — extra indirection adds nothing
- c) Pointers to interfaces are illegal in Go
- d) It panics at runtime
Answer: b
4. When does interface bloat appear?¶
- a) When generics are used
- b) When an interface has more than 8-10 methods, often listing unrelated capabilities
- c) When the interface is empty
- d) When you embed interfaces
Answer: b
5. The right fix for "io.Reader-shaped type that isn't a reader":¶
- a) Add another method
- b) Change the method signature to one that matches the semantics
- c) Embed
io.Reader - d) Cast to
interface{}
Answer: b
Cheat Sheet¶
MOCK-DRIVEN DESIGN
─────────────────────────────
Symptom: every interface has a generated mock and only one real impl
Fix: hand-written fake | minimal consumer-side interface | integration test
HEADER INTERFACE
─────────────────────────────
Symptom: interface lists every method of one struct
Fix: drop it OR split into role-based interfaces near consumers
POINTER-TO-INTERFACE
─────────────────────────────
NEVER use *io.Reader, *Mailer, *Repository
Interface is already a 2-word header
INTERFACE BLOAT
─────────────────────────────
> 10 methods → split
golangci-lint: interfacebloat
Single role per interface
WELL-KNOWN INTERFACES
─────────────────────────────
Don't fake io.Reader / io.Writer / Stringer signatures
Match SHAPE → match SEMANTICS
ANY vs GENERICS
─────────────────────────────
type-switch chain over 4+ types → generics
true heterogeneity → any (rare)
WRAPPER HIDING ERRORS
─────────────────────────────
Never drop error in interface methods
Always (T, error) on operations that can fail
Summary¶
The middle-level anti-patterns:
- Mock-driven design — interfaces invented to satisfy the mock generator. Use hand-written fakes or shrink the interface.
- Header interfaces — one interface enumerating one struct. Delete or split.
- Pointer-to-interface — almost always wrong; an interface is already a header.
- Interface bloat — > 10 methods is a smell; split by role.
- io.Reader-shaped non-readers — don't squat on standard interface shapes.
anywhere generics fit — convert type-switches to constraints.- Wrapper interfaces hiding errors — never drop the error.
- Same-package single-implementation interfaces — return the struct.
- Constructor returning interface — let callers pick.
The senior level moves from local smells to architectural damage.