Skip to content

Interface Anti-Patterns — Middle Level

Table of Contents

  1. Introduction
  2. Mock-Driven Design
  3. Header Interfaces
  4. Pointer-to-Interface
  5. Interface Bloat
  6. io.Reader-Shaped Types That Aren't Readers
  7. Empty Interface as a Parameter Type
  8. Wrapper Interfaces That Hide Errors
  9. Interface in the Same Package as Only Implementation
  10. Interface That Imitates a Class
  11. Returning Interface from Constructors
  12. Catching All Anti-Patterns with go vet, staticcheck, revive
  13. Refactoring Patterns
  14. Test
  15. Cheat Sheet
  16. 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 mockgen was easier than designing the test.
  • Every time Service needs 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 }
Only two methods are mocked, not five.

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
}
A 20-line fake reads better than 200 lines of generated mock and exercises real behavior.

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.Reader because 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:

func swap(a, b *io.Reader) { *a, *b = *b, *a }

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.Reader is expected to behave like a stream — repeated calls, partial reads, io.EOF semantics.
  • 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.Reader accepts 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:

type Storage interface {
    Get(key string) (string, error)
    Put(key, value string) error
}

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:

package signup

type sender interface {
    Send(to, subject, body string) error
}

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

func NewBuffer(size int) io.ReadWriter {
    return &myBuffer{data: make([]byte, 0, size)}
}

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.ReadWriter doesn'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

func NewBuffer(size int) *Buffer {
    return &Buffer{data: make([]byte, 0, size)}
}

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

  1. Keep the interface only if a real second implementation exists.
  2. Replace mocks with hand-written fakes.
  3. Or use testcontainers for integration tests.
  4. Delete generated mocks and the now-orphan interface.

Strategy 3 — Replace *Interface with Interface

A simple grep:

grep -RnE '\*(io\.Reader|io\.Writer|io\.Closer|error)\b' . | grep -v '_test.go'

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) mockgen is unmaintained

Answer: b

2. A "header interface" is one that:

  • a) Has only header in 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.Reader is 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:

  1. Mock-driven design — interfaces invented to satisfy the mock generator. Use hand-written fakes or shrink the interface.
  2. Header interfaces — one interface enumerating one struct. Delete or split.
  3. Pointer-to-interface — almost always wrong; an interface is already a header.
  4. Interface bloat — > 10 methods is a smell; split by role.
  5. io.Reader-shaped non-readers — don't squat on standard interface shapes.
  6. any where generics fit — convert type-switches to constraints.
  7. Wrapper interfaces hiding errors — never drop the error.
  8. Same-package single-implementation interfaces — return the struct.
  9. Constructor returning interface — let callers pick.

The senior level moves from local smells to architectural damage.