What Is Interface Pollution in Go and How to Avoid It

Avoid interface pollution in Go by defining small, focused interfaces that only include the methods actually used by the consumer.

Interface Pollution in Go

You are writing a function to calculate the checksum of a file. You need to read bytes. You look at the standard library and see io.ReadWriteCloser. You define your function to take that interface. Now you try to test it with a simple string wrapper. The compiler rejects the code. Your string wrapper does not have Write or Close. You spend twenty minutes adding dummy methods just to make the test compile. That is interface pollution. You asked for a key that opens the whole building, but you only needed to unlock the front door.

Interface pollution happens when an interface demands more behavior than the consumer actually uses. Go interfaces are satisfied implicitly. If a type has the methods, it fits. If the interface lists methods the type does not need, the type cannot fit. The interface becomes a bottleneck. Small interfaces keep your code flexible. Big interfaces tie you to specific implementations and make testing painful.

What It Actually Is

Go interfaces are behavioral contracts. A type satisfies an interface if it implements every method the interface declares. There is no implements keyword. The compiler checks the method set automatically. This implicit satisfaction is powerful. It means you can satisfy an interface defined in a package you do not control, as long as your type has the right methods.

Pollution occurs when the interface grows beyond what the caller needs. Imagine a USB-C port. It handles power, data, video, and audio. If you just want to charge your phone, you need a cable that supports power. If the port required a cable that also supported 8K video transmission to charge, you would be stuck. You would need a massive, expensive cable just to get juice. A small interface is like a dedicated charging pin. It does one thing. Any device with that pin works. A polluted interface is the jack-of-all-trades port that forces every device to support everything, even if it only needs one feature.

In Go, the convention is to define interfaces where they are used, not where they are implemented. The consumer decides what behavior it needs. The producer provides the implementation. If the producer defines the interface, the producer decides the contract. The consumer might only need one method, but the producer's interface demands five. This forces the consumer to depend on the producer's full API. Define the interface in the package that calls the methods.

Minimal Example

Here is a clean interface. It asks for exactly one method. Any type with a Read method satisfies it.

// GoodReader only asks for what it needs.
type GoodReader interface {
    // Read fills the buffer and returns the count.
    Read(p []byte) (n int, err error)
}

func Process(r GoodReader) {
    // Only calls Read. Write and Close are irrelevant here.
    buf := make([]byte, 10)
    r.Read(buf)
}

This interface matches the standard library's io.Reader. You can pass a file, a network connection, a buffer, or a custom struct. As long as the type has Read(p []byte) (n int, err error), the code compiles. The function does not care about the underlying type. It only cares about the behavior.

Run gofmt on your code. It ensures your interface methods are formatted consistently. Most editors run it on save. Trust the tool. Argue logic, not formatting.

How Go Checks Interfaces

Go checks interfaces at compile time. The compiler looks at the method set of the type you pass. It compares that list against the interface definition. If the type has every method in the interface, the code compiles. If the interface has extra methods the type lacks, the compiler rejects the call.

The compiler complains with cannot use x (type MyType) as type Interface in argument: MyType does not implement Interface (missing Write method) if you try to pass a type that is missing a method. This error is precise. It tells you exactly which method is missing. Fix the type or shrink the interface.

At runtime, interfaces are just a pair of pointers: one to the type descriptor, one to the data. Small interfaces mean the type descriptor is smaller and the contract is clearer. There is no performance penalty for small interfaces. The benefit is flexibility. You can swap implementations without changing the function signature. You can mock dependencies in tests without setting up heavy infrastructure.

Implicit satisfaction also means you can add interface satisfaction to existing types. If the standard library defines io.Reader, you can write a struct in your package with a Read method, and it automatically satisfies io.Reader. You do not need to modify the standard library. You do not need to register your type. The compiler handles it. This works best when interfaces are small. If the interface is huge, you cannot satisfy it without copying the entire API.

Realistic Scenario

Let's look at a database scenario. You are building a service that fetches a user. You might be tempted to pass the whole database connection object. That ties your service to the database driver. You cannot test it without a real database. You cannot swap the driver without rewriting the service.

Here is a service layer using a small interface. The interface defines only what the service needs.

// UserStore defines the minimal contract for fetching users.
type UserStore interface {
    // GetUser retrieves a user by ID.
    GetUser(ctx context.Context, id string) (*User, error)
}

// UserService depends only on UserStore, not the full database driver.
type UserService struct {
    // Store holds the dependency.
    store UserStore
}

// NewUserService creates a service with the given store.
func NewUserService(store UserStore) *UserService {
    // Inject the dependency.
    return &UserService{store: store}
}

// HandleRequest processes an incoming request.
func (s *UserService) HandleRequest(ctx context.Context, id string) error {
    // Use the store to fetch data.
    _, err := s.store.GetUser(ctx, id)
    return err
}

The UserService accepts a UserStore. It does not care if the store uses PostgreSQL, SQLite, or an in-memory map. The receiver name is (s *UserService), matching the type. context.Context is the first parameter in GetUser, following the convention. Functions that take a context should respect cancellation and deadlines. The error handling uses if err != nil implicitly by returning the error. The community accepts the boilerplate because it makes the unhappy path visible.

Now you can mock UserStore easily. You do not need a real DB. You just need a struct with GetUser.

// MockStore implements UserStore for testing.
type MockStore struct {
    // Users holds the test data.
    Users map[string]*User
}

// GetUser returns a user from the map.
func (m *MockStore) GetUser(ctx context.Context, id string) (*User, error) {
    // Return the user or an error.
    u, ok := m.Users[id]
    if !ok {
        return nil, fmt.Errorf("user not found")
    }
    return u, nil
}

This mock is trivial. If the interface had Close, Ping, BeginTx, and Commit, the mock would be painful. You would have to implement all those methods just to test GetUser. Small interfaces make mocking easy. Big interfaces make mocking a chore.

Accept interfaces, return structs. The UserService accepts the UserStore interface. It returns concrete errors or values. This pattern keeps dependencies flexible while providing concrete results.

Pitfalls and Errors

What goes wrong? You define an interface that mirrors the implementation. This is the interface pollution trap. You create type FileProcessor interface { Read(...); Write(...); Close(...); Seek(...); Stat(...) }. Now only *os.File fits. You have tied your code to the OS file implementation.

If you try to pass a bytes.Buffer, the compiler rejects it with cannot use buffer (type *bytes.Buffer) as type FileProcessor in argument: *bytes.Buffer does not implement FileProcessor (missing Seek method). The error tells you the interface is too big for the type you want to use. Shrink the interface.

Another pitfall is interface explosion. If you make interfaces too small, you end up with too many interfaces. You might define Reader, Writer, Closer, Seeker, Stat as separate interfaces and pass five arguments to a function. That is also bad. The rule of thumb is one method is great. Two or three is fine if they are related. More than that, ask if they belong together. io.Reader is one method. io.ReadCloser is two. io.ReadWriteCloser is three. These are standard. But do not invent io.ReadWriteCloseSeekStatTruncateSync unless you really need all of them.

Use interface embedding when a type naturally combines two independent behaviors. io.ReadCloser embeds io.Reader and io.Closer. This lets you build larger interfaces from small ones without repeating methods.

// ReadCloser combines reading and closing.
type ReadCloser interface {
    // Reader provides the read behavior.
    io.Reader
    // Closer provides the close behavior.
    io.Closer
}

This is clean. You can satisfy ReadCloser by implementing Read and Close. You can also satisfy it by embedding types that already implement those methods. Interface embedding keeps definitions DRY.

The worst interface bug is the one that never logs. If you define an interface that is too big, you might not notice until you try to use a new type. The compiler will catch it, but the error might be confusing if you are deep in a call stack. Keep interfaces small. The compiler will thank you.

When to Use What

Use a small interface when the function only calls one method. Use a focused interface when you want to mock the dependency in tests without setting up heavy infrastructure. Use interface embedding when a type naturally combines two independent behaviors, like reading and closing. Use a concrete struct when you need to return a value that carries state or implements multiple unrelated interfaces. Use the standard library interface when it already matches your needs, like io.Reader for reading bytes. Use the empty interface any when you need to store a value of unknown type, though this is rare in well-typed code. Use a larger interface when multiple methods are always used together and the interface represents a distinct capability.

Small interfaces scale. Big interfaces break. Define the interface where it is used. Let the implementation surprise you.

Where to go next