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:
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:
// 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:
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.
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:
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.
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:
// 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:
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:
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:
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.
// 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]anyfor 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
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
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
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
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.