Skip to main content

Go Interfaces in Practice: Polymorphism, Composition, and Testing

·8 mins
Table of Contents
Go interfaces are implicit. You do not declare that a type implements an interface. If a type has the right methods, it satisfies the interface. This single design decision makes Go interfaces more flexible, more composable, and more powerful for testing than explicit interface implementations in Java or C#.

Interfaces are the mechanism Go uses to express polymorphism, to decouple consumers from producers, and to make code testable without heavyweight frameworks. This post works through the patterns that matter in real codebases: polymorphism with slices of interfaces, the io.Reader/io.Writer design, interface composition, interfaces for test mocks, and the line between any and generics.

Basic Interface and Polymorphism
#

Define an interface that describes behavior, not identity:

shapes/shapes.go
package shapes

import "math"

// Shape describes any two-dimensional figure that has an area.
type Shape interface {
    Area() float64
    Perimeter() float64
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64      { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64      { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }

type Triangle struct {
    Base, Height, A, B, C float64 // A, B, C are side lengths
}

func (t Triangle) Area() float64      { return 0.5 * t.Base * t.Height }
func (t Triangle) Perimeter() float64 { return t.A + t.B + t.C }

Now write a function that works on any Shape without knowing the concrete type:

shapes/shapes.go (continued)
// TotalArea sums the area of all shapes. It works for any mix of types.
func TotalArea(shapes []Shape) float64 {
    var total float64
    for _, s := range shapes {
        total += s.Area()
    }
    return total
}

// LargestShape returns the shape with the greatest area.
func LargestShape(shapes []Shape) Shape {
    if len(shapes) == 0 {
        return nil
    }
    largest := shapes[0]
    for _, s := range shapes[1:] {
        if s.Area() > largest.Area() {
            largest = s
        }
    }
    return largest
}

Usage:

main.go
shapes := []shapes.Shape{
    shapes.Circle{Radius: 5},
    shapes.Rectangle{Width: 10, Height: 3},
    shapes.Triangle{Base: 6, Height: 4, A: 5, B: 5, C: 6},
}

fmt.Printf("Total area: %.2f\n", shapes.TotalArea(shapes))
fmt.Printf("Largest: %+v\n", shapes.LargestShape(shapes))

Circle, Rectangle, and Triangle never declare that they implement Shape. The compiler checks the method set at the call site. Adding a new shape type requires no changes to TotalArea or LargestShape.

The io.Reader and io.Writer Pattern
#

io.Reader and io.Writer are the most consequential interfaces in the Go standard library. They are also the best illustration of why small interfaces are powerful.

io interfaces
type Reader interface {
    Read(p []byte) bool n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

Any type that implements Read is an io.Reader. This includes files, network connections, HTTP response bodies, byte buffers, gzip streams, and strings. Because of this, you can write one function that works on all of them:

io_example.go
package main

import (
    "compress/gzip"
    "io"
    "net/http"
    "os"
    "strings"
)

// countBytes counts bytes from any Reader. It does not care whether
// the data comes from a file, an HTTP body, a string, or a network socket.
func countBytes(r io.Reader) (int64, error) {
    return io.Copy(io.Discard, r)
}

func main() {
    // From a file
    f, _ := os.Open("data.txt")
    defer f.Close()
    n, _ := countBytes(f)
    fmt.Printf("file: %d bytes\n", n)

    // From an HTTP response body
    resp, _ := http.Get("https://example.com")
    defer resp.Body.Close()
    n, _ = countBytes(resp.Body)
    fmt.Printf("http: %d bytes\n", n)

    // From a string
    n, _ = countBytes(strings.NewReader("hello world"))
    fmt.Printf("string: %d bytes\n", n)

    // From a gzip-compressed source (transparently decompresses)
    gz, _ := gzip.NewReader(strings.NewReader(compressedData))
    n, _ = countBytes(gz)
    fmt.Printf("gzip: %d bytes\n", n)
}

Writing to any destination works the same way with io.Writer. os.Stdout, os.File, http.ResponseWriter, bytes.Buffer, and gzip.Writer all implement io.Writer. A function that accepts io.Writer works with all of them.

Tip

If you find yourself writing a function that takes *os.File or *bytes.Buffer as a parameter for I/O, reconsider. Accept io.Reader or io.Writer instead. The function immediately becomes more general and easier to test (pass a bytes.Buffer in tests instead of creating a real file).

Interface Composition
#

Go interfaces compose by embedding. Build complex interfaces from focused, single-purpose ones:

io composition
// These are defined in the standard library.

type ReadWriter interface {
    Reader
    Writer
}

type ReadCloser interface {
    Reader
    Closer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

In your own code:

composed interfaces
package storage

type Reader interface {
    Get(ctx context.Context, key string) ([]byte, error)
}

type Writer interface {
    Put(ctx context.Context, key string, value []byte) error
    Delete(ctx context.Context, key string) error
}

type ReadWriter interface {
    Reader
    Writer
}

// Functions that only need to read accept Reader.
// Functions that need to write accept Writer.
// Functions that need both accept ReadWriter.
// This makes dependencies explicit and reduces the surface area of mocks.
func exportData(ctx context.Context, r storage.Reader, keys []string) ([]byte, error) {
    // ...
}

Composed interfaces let you express the exact capabilities a function needs, nothing more. A function that accepts a large interface with twenty methods is harder to mock and harder to reason about than one that accepts a two-method interface.

Interfaces for Testability
#

The most practical reason to define interfaces in Go is to make code testable without external dependencies. Define a thin interface over any dependency with external effects:

http/client.go
package http

import "net/http"

// HTTPClient abstracts net/http.Client for testing.
type HTTPClient interface {
    Do(req *http.Request) (*http.Response, error)
}

// APIClient uses HTTPClient, not *http.Client directly.
type APIClient struct {
    base   string
    client HTTPClient
}

func NewAPIClient(base string, client HTTPClient) *APIClient {
    return &APIClient{base: base, client: client}
}

func (c *APIClient) GetUser(ctx context.Context, id string) (*User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        c.base+"/users/"+id, nil)
    if err != nil {
        return nil, err
    }
    resp, err := c.client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("http request: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
    }

    var user User
    return &user, json.NewDecoder(resp.Body).Decode(&user)
}

In the test, inject a fake:

http/client_test.go
type fakeHTTPClient struct {
    resp *http.Response
    err  error
}

func (f *fakeHTTPClient) Do(_ *http.Request) (*http.Response, error) {
    return f.resp, f.err
}

func TestGetUser_Success(t *testing.T) {
    body := `{"id":"42","name":"Alice"}`
    fake := &fakeHTTPClient{
        resp: &http.Response{
            StatusCode: http.StatusOK,
            Body:       io.NopCloser(strings.NewReader(body)),
        },
    }

    client := NewAPIClient("https://api.example.com", fake)
    user, err := client.GetUser(context.Background(), "42")
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "Alice" {
        t.Errorf("name = %q; want Alice", user.Name)
    }
}

No real HTTP calls. No test server. Fast, deterministic, and completely isolated.

Empty Interface (any) and Generics
#

any (the alias for interface{}) means “I accept any type”. It disables the type system for that value. Every operation on an any requires a type assertion at runtime.

any vs generics
// BAD: loses type information, requires assertion at call site
func printAll(items []any) {
    for _, item := range items {
        fmt.Println(item)
    }
}

// BETTER: if you know the element type at compile time, use generics
func printAll[T any](items []T) {
    for _, item := range items {
        fmt.Println(item)
    }
}
  • The type is genuinely unknown at compile time (JSON unmarshaling, reflection-based code)
  • You are writing a container that must hold heterogeneous types (map[string]any for JSON)
  • You are implementing a low-level infrastructure component that must work with arbitrary types and generics are not expressive enough
  • You are writing a reusable data structure (stack, queue, set, cache)
  • You are writing an algorithm that works identically for multiple types (sort, map, filter)
  • You want compile-time type safety without boxing into any
Warning

any in a function signature is almost always a code smell. It means the function cannot be type-checked at the call site. Before using any, ask whether generics, a concrete type, or a more specific interface would serve the purpose.

Common Mistakes
#

Defining interfaces at the producer instead of the consumer
The producer of data rarely knows all the ways it will be consumed. Define interfaces where you use them, not where you implement them. A database package should export concrete types. The service layer that uses the database should define the interface it needs. This keeps the interface small and matched to actual usage.
Huge interfaces
An interface with fifteen methods is nearly impossible to mock completely and signals that too much responsibility is concentrated in one place. Prefer small, focused interfaces. If a function only needs three of an interface’s fifteen methods, define a new three-method interface and accept that instead.
Not verifying interface satisfaction at compile time

If *MyStruct is supposed to implement MyInterface, add this line to the package:

var _ MyInterface = (*MyStruct)(nil)

This is a compile-time assertion. If MyStruct is missing a method, the build fails immediately instead of at runtime when the code is first called. Place this near the type declaration.

Defining interfaces too early
Do not define an interface until you have at least two concrete types that need to satisfy it, or until you need to mock the dependency in a test. Premature interfaces add indirection without benefit. Start with concrete types and extract an interface when the need becomes clear.
Important

Accept interfaces, return structs. This is the most important guideline for Go interfaces. When a function accepts an interface, callers can pass any implementation, including test fakes. When a function returns an interface, callers lose type information and cannot access methods specific to the concrete type. Return the concrete struct. Let callers decide what interface to assign it to.


If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related