Files
Rodin c8ed244a07 feat: add source hyperlinks (commit SHA permalinks) to all pattern files
Every source reference now links to the exact line in the golang/go
repo at commit 17bd5ab. Added PATTERN_COMPLETE sentinels.

Total: 154 hyperlinks across 10 topic files.
2026-04-30 14:42:20 -07:00

22 KiB

Go Interface Design Patterns

Patterns extracted from the Go standard library source code.


1. Small Interfaces (1-2 Methods)

Source: src/io/io.go#L80 (Reader), 93-103 (Writer), 105-109 (Closer)

Go's most powerful interfaces have exactly one method:

// [src/io/io.go#L80](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L80)
type Reader interface {
    Read(p []byte) (n int, err error)
}

// [src/io/io.go#L93](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L93)
type Writer interface {
    Write(p []byte) (n int, err error)
}

// [src/io/io.go#L105](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L105)
type Closer interface {
    Close() error
}

Why

Small interfaces are easy to implement and easy to compose. Any type can satisfy io.Reader by implementing a single method. This maximizes the number of types that can participate in the ecosystem — files, network connections, buffers, compressors, encryptors all satisfy Reader.

When to Use

Triggers:

  • You're defining a function that only needs ONE capability from its argument (reading, writing, closing)
  • You want maximum reusability — many different types should be able to satisfy your requirement
  • You're tempted to create a big interface but realize most consumers only use 1-2 methods

Example — before:

// Accepts only *os.File — can't use with buffers, HTTP bodies, test mocks
func countLines(f *os.File) (int, error) {
    scanner := bufio.NewScanner(f)
    count := 0
    for scanner.Scan() { count++ }
    return count, scanner.Err()
}

Example — after:

// Accepts io.Reader — works with files, HTTP bodies, strings.NewReader, gzip.Reader, etc.
func countLines(r io.Reader) (int, error) {
    scanner := bufio.NewScanner(r)
    count := 0
    for scanner.Scan() { count++ }
    return count, scanner.Err()
}

When NOT to Use

Don't use this when:

  • You only have one implementation and no tests — you're adding indirection for no reason
  • The function genuinely needs multiple capabilities together (reading + seeking + closing)
  • You're creating an interface to match a single concrete type that you control

Over-application example:

// Interface with one implementation, no tests, no external consumers
type Configurer interface {
    LoadConfig(path string) (*Config, error)
}

type fileConfigurer struct{}

func (f *fileConfigurer) LoadConfig(path string) (*Config, error) {
    return parseFile(path)
}

func NewApp(c Configurer) *App {
    // c is always *fileConfigurer — the interface adds nothing
    return &App{cfg: c}
}

Better alternative:

// Just use the concrete type until you actually need the abstraction
func NewApp(cfgPath string) *App {
    cfg := parseFile(cfgPath)
    return &App{cfg: cfg}
}

Why: Interfaces in Go should be discovered through usage, not predicted. "Accept interfaces" means accept them at the boundaries where multiple types actually flow through. If you have one implementation and no tests that need a mock, you have a premature abstraction.

Anti-pattern

// DON'T: Giant interface that tries to cover everything
type FileSystem interface {
    Open(name string) (File, error)
    Create(name string) (File, error)
    Remove(name string) error
    Stat(name string) (FileInfo, error)
    ReadDir(name string) ([]DirEntry, error)
    MkdirAll(path string, perm FileMode) error
    // ... 10 more methods
}

Large interfaces are hard to implement, hard to mock, and couple consumers to capabilities they don't need.


2. Interface Composition

Source: src/io/io.go#L131

Compose small interfaces into larger ones only when needed:

// [src/io/io.go#L131](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L131)
type ReadWriter interface {
    Reader
    Writer
}

// [src/io/io.go#L136](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L136)
type ReadCloser interface {
    Reader
    Closer
}

// [src/io/io.go#L141](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L141)
type WriteCloser interface {
    Writer
    Closer
}

// [src/io/io.go#L146](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L146)
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

Why

Composition lets you express precisely what you need. A function that only reads should accept Reader, not ReadWriteCloser. Callers provide the minimum; composers build up.

Anti-pattern

// DON'T: Define a big interface, then use only part of it
func processData(rw ReadWriteCloser) {
    // only calls Read... why require Write and Close?
}

3. Accept Interfaces, Return Structs

Source: src/io/io.go#L461 (LimitReader), src/io/io.go#L618 (TeeReader)

// src/io/io.go:461
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

// [src/io/io.go#L467](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L467)
type LimitedReader struct {
    R Reader // underlying reader
    N int64  // max bytes remaining
}
// [src/io/io.go#L618](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L618)
func TeeReader(r Reader, w Writer) Reader {
    return &teeReader{r, w}
}

Why

  • Accept interfaces: Maximizes what callers can pass in — any Reader works.
  • Return structs (or concrete types): Gives callers access to the full type, including fields and methods not in any interface. LimitedReader exposes R and N publicly so callers can inspect remaining bytes.

The return type of LimitReader is Reader (interface), but the underlying value is *LimitedReader (struct). Functions like io.Copy can type-assert to *LimitedReader to optimize buffer sizes (line 425).

When to Use

Triggers:

  • You're writing a function/constructor that operates on a capability (reading, hashing, connecting)
  • Your return type has useful fields or methods beyond the interface contract
  • You want callers to pass anything that satisfies the contract, but return something concrete they can inspect

Example — before:

// Too restrictive input, too vague output
func NewLogger(f *os.File) io.Writer {
    return &logger{out: f, level: "info"} // hides SetLevel, Flush methods
}

Example — after:

type Logger struct {
    out   io.Writer
    level string
}

func (l *Logger) SetLevel(lvl string) { l.level = lvl }
func (l *Logger) Flush() error { /* ... */ }

// Accept interface (any io.Writer), return struct (full access)
func NewLogger(w io.Writer) *Logger {
    return &Logger{out: w, level: "info"}
}

When NOT to Use

Don't use this when:

  • Your function is internal and only ever called with one concrete type
  • Returning an interface is genuinely better because the concrete type is an implementation detail that may change
  • The struct's exported fields would expose dangerous internals

Over-application example:

// Accepting an interface when only one concrete type makes sense
func NewDatabaseMigrator(db interface {
    Exec(query string, args ...any) (sql.Result, error)
    Query(query string, args ...any) (*sql.Rows, error)
    Begin() (*sql.Tx, error)
}) *Migrator {
    // This custom interface exactly matches *sql.DB — just accept *sql.DB
    return &Migrator{db: db}
}

Better alternative:

// Accept the concrete type when the abstraction doesn't buy anything
func NewDatabaseMigrator(db *sql.DB) *Migrator {
    return &Migrator{db: db}
}

Why: "Accept interfaces" doesn't mean "always accept interfaces." If you define a bespoke interface that matches exactly one concrete type and no one else will implement it, you've just added indirection. The guideline targets standard interfaces (io.Reader, io.Writer) that many types satisfy.

Anti-pattern

// DON'T: Accept concrete types
func LimitReader(r *os.File, n int64) Reader  // too restrictive

// DON'T: Return interfaces when you have useful struct fields
func NewServer() ServerInterface  // hides useful config fields

4. Interface Satisfaction as a Compile-Time Check

Source: src/io/io.go#L645, src/net/http/server.go#L4071

// src/io/io.go:645
var _ ReaderFrom = discard{}

// src/net/http/server.go:4071
var _ Pusher = (*timeoutWriter)(nil)

Why

These blank-identifier declarations cause a compile error if the type stops implementing the interface. They're documentation and safety net combined — no runtime cost.

Anti-pattern

// DON'T: Discover interface failures at runtime
func doSomething(w ResponseWriter) {
    pusher := w.(Pusher) // panics if w doesn't implement Pusher
}

5. Interface-Based Polymorphism (sort.Interface)

Source: src/sort/sort.go#L16

// [src/sort/sort.go#L16](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/sort/sort.go#L16)
type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}

Why

The sort package defines behavior it needs, not data it operates on. Any collection — slices, linked lists, database result sets — can be sorted by implementing three methods. The algorithm is decoupled from the data structure.

Anti-pattern

// DON'T: Hardcode to a specific data type
func Sort(data []int)          // only works for []int
func Sort(data []interface{})  // loses type safety

Note: Since Go 1.21, slices.SortFunc is preferred for slices (generic + faster), but sort.Interface remains the pattern for non-slice collections.


6. The Adapter Pattern (HandlerFunc)

Source: src/net/http/server.go#L2334

// [src/net/http/server.go#L2334](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/net/http/server.go#L2334)
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// [src/net/http/server.go#L2341](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/net/http/server.go#L2341)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Why

This bridges functions and interfaces. Any function with the right signature becomes a Handler via HandlerFunc(myFunc). You get the simplicity of functions with the composability of interfaces.

When to Use

Triggers:

  • You have an interface with a single method and users frequently implement it with a bare function
  • You want to accept both struct-based and function-based implementations of the same behavior
  • Requiring a struct definition for simple cases feels like boilerplate

Example — before:

type Processor interface {
    Process(data []byte) error
}

// User must create a whole struct just to use a function
type upperProcessor struct{}
func (u upperProcessor) Process(data []byte) error {
    fmt.Println(strings.ToUpper(string(data)))
    return nil
}

Example — after:

type Processor interface {
    Process(data []byte) error
}

// Adapter: any function with the right signature becomes a Processor
type ProcessorFunc func([]byte) error

func (f ProcessorFunc) Process(data []byte) error { return f(data) }

// Now users can write:
pipeline.Use(ProcessorFunc(func(data []byte) error {
    fmt.Println(strings.ToUpper(string(data)))
    return nil
}))

When NOT to Use

Don't use this when:

  • The interface has more than one method — adapters only work for single-method interfaces
  • Implementations typically need state (struct fields) that closures would awkwardly close over
  • The function signature is complex enough that a named type with methods is clearer

Over-application example:

// Adapter for a multi-method interface — doesn't work
type StorageFunc func(key string, data []byte) error

func (f StorageFunc) Store(key string, data []byte) error { return f(key, data) }
func (f StorageFunc) Load(key string) ([]byte, error)     { /* can't implement! */ }
func (f StorageFunc) Delete(key string) error             { /* can't implement! */ }

Better alternative:

// For multi-method interfaces, use a struct (or split the interface)
type MemoryStorage struct {
    data map[string][]byte
}

func (m *MemoryStorage) Store(key string, data []byte) error { ... }
func (m *MemoryStorage) Load(key string) ([]byte, error)     { ... }
func (m *MemoryStorage) Delete(key string) error             { ... }

Why: The adapter pattern bridges functions to interfaces. Functions have one signature, so adapters only work for single-method interfaces. If your interface has multiple methods, callers need a struct anyway — the adapter just adds confusion.

Anti-pattern

// DON'T: Force users to create a struct just to implement an interface
type myHandler struct{}
func (h myHandler) ServeHTTP(w ResponseWriter, r *Request) {
    // just calls a function anyway
    handleHome(w, r)
}

7. Optional Interfaces (Runtime Feature Detection)

Source: src/net/http/server.go#L165 (Flusher), src/net/http/server.go#L183 (Hijacker)

// [src/net/http/server.go#L165](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/net/http/server.go#L165)
type Flusher interface {
    Flush()
}

// [src/net/http/server.go#L183](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/net/http/server.go#L183)
type Hijacker interface {
    Hijack() (net.Conn, *bufio.ReadWriter, error)
}

Usage pattern (from doc comments):

// Handlers should always test for this ability at runtime.
if flusher, ok := w.(Flusher); ok {
    flusher.Flush()
}

Why

Not every ResponseWriter supports flushing or hijacking (HTTP/2 doesn't support Hijacker). Instead of bloating the main interface, optional capabilities are separate interfaces checked at runtime. This keeps the core interface small while allowing progressive enhancement.

When to Use

Triggers:

  • Some implementations support a capability but others don't (flushing, hijacking, seeking)
  • You want to keep the core interface small but allow optimizations when available
  • You're writing middleware that should enhance behavior when possible, not require it

Example — before:

// Forces ALL stores to implement caching, even simple ones
type Store interface {
    Get(key string) ([]byte, error)
    Set(key string, val []byte) error
    InvalidateCache() error  // not all stores have a cache!
}

Example — after:

type Store interface {
    Get(key string) ([]byte, error)
    Set(key string, val []byte) error
}

// Optional capability — check at runtime
type Cacheable interface {
    InvalidateCache() error
}

func refreshAll(s Store) {
    if c, ok := s.(Cacheable); ok {
        c.InvalidateCache() // only if supported
    }
}

When NOT to Use

Don't use this when:

  • All implementations will always support the capability — just put it in the main interface
  • The capability is required for correctness, not just optimization
  • You have only 2-3 implementations and a simple interface split handles it better

Over-application example:

// Every HTTP handler MUST write a response — this isn't optional
type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

// Don't make response-writing "optional"
type ResponseWriter interface {
    Header() Header
}

type BodyWriter interface {
    Write([]byte) (int, error) // NOT optional — every response needs a body mechanism
}

func handle(w ResponseWriter) {
    if bw, ok := w.(BodyWriter); ok { // wrong: Write is fundamental, not optional
        bw.Write([]byte("hello"))
    }
}

Better alternative:

// Write is fundamental — keep it in the core interface
type ResponseWriter interface {
    Header() Header
    Write([]byte) (int, error)
    WriteHeader(statusCode int)
}

// Only truly optional capabilities get separate interfaces
type Flusher interface {
    Flush()
}

Why: Optional interfaces are for progressive enhancement — capabilities that some implementations support but others legitimately don't. If every implementation must support it for the system to work, it belongs in the core interface. Overusing type assertions makes code fragile and harder to reason about.

Anti-pattern

// DON'T: Put optional capabilities in the main interface
type ResponseWriter interface {
    Write([]byte) (int, error)
    WriteHeader(int)
    Header() Header
    Flush()                                    // not always supported!
    Hijack() (net.Conn, *bufio.ReadWriter, error) // not always supported!
}

8. The Stringer Interface (Convention-Based Behavior)

Source: src/fmt/print.go#L63

// [src/fmt/print.go#L63](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/fmt/print.go#L63)
type Stringer interface {
    String() string
}

Why

fmt checks if a value implements Stringer and calls String() for its text representation. This is a convention: any type in any package can participate without importing fmt. The interface acts as a protocol.

Similarly, GoStringer (line 71) controls %#v output, and Formatter (line 58-61) gives total control over formatting.

Anti-pattern

// DON'T: Use type switches over known types
func printThing(v any) string {
    switch v := v.(type) {
    case MyType: return v.name
    case OtherType: return v.label
    // ... must know about every type
    }
}

9. Interface Upgrade Pattern (WriterTo/ReaderFrom in io.Copy)

Source: src/io/io.go#L410

// [src/io/io.go#L410](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/io/io.go#L410)
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // If the reader has a WriteTo method, use it to do the copy.
    // Avoids an allocation and a copy.
    if wt, ok := src.(WriterTo); ok {
        return wt.WriteTo(dst)
    }
    // Similarly, if the writer has a ReadFrom method, use it to do the copy.
    if rf, ok := dst.(ReaderFrom); ok {
        return rf.ReadFrom(src)
    }
    // ... fallback to buffer-based copy
}

Why

The core function works with the minimum interface (Reader/Writer) but upgrades behavior when richer interfaces are available. *os.File implements ReadFrom using sendfile(2) — zero-copy kernel transfer. The generic path works, but specialized implementations optimize transparently.

Anti-pattern

// DON'T: Always use the slow generic path
func Copy(dst Writer, src Reader) {
    buf := make([]byte, 32*1024)
    // always allocates, never leverages kernel optimizations
}

10. The driver.Driver Pattern (Plugin Interfaces)

Source: src/database/sql/driver/driver.go#L85, 104-112

// [src/database/sql/driver/driver.go#L85](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/database/sql/driver/driver.go#L85)
type Driver interface {
    Open(name string) (Conn, error)
}

// [src/database/sql/driver/driver.go#L104](https://github.com/golang/go/blob/17bd5ab8c650155dd2bd09f7005726552639eea0/src/database/sql/driver/driver.go#L104)
type DriverContext interface {
    OpenConnector(name string) (Connector, error)
}

Why

The database/sql package defines interfaces that driver authors implement. The base interface (Driver) is minimal (one method). Extended capabilities (DriverContext, Pinger, Execer, Queryer) are opt-in — the sql package checks for them at runtime. This allows the ecosystem to grow without breaking existing drivers.

Anti-pattern

// DON'T: One massive interface all drivers must implement
type Driver interface {
    Open(name string) (Conn, error)
    OpenConnector(name string) (Connector, error)
    Ping(ctx context.Context) error
    // ... every driver must implement everything, even if not supported
}

Summary: Go Interface Design Principles

Principle Standard Library Example
Keep interfaces small (1-2 methods) io.Reader, io.Writer, fmt.Stringer
Compose small interfaces io.ReadWriteCloser
Accept interfaces, return structs io.LimitReader, io.TeeReader
Use adapter types for functions http.HandlerFunc
Optional capabilities via separate interfaces http.Flusher, http.Hijacker
Compile-time interface checks var _ Interface = (*Type)(nil)
Runtime interface upgrade for optimization io.CopyWriterTo/ReaderFrom
Plugin/driver interfaces start minimal database/sql/driver.Driver