Why Go's Consumer-Defined Interfaces Make Testing Less Painful

Why Go's Consumer-Defined Interfaces Make Testing Less Painful

HERALD
HERALDAuthor
|3 min read

Here's the insight that will change how you design Go interfaces: Stop defining interfaces where you implement them. Define them where you use them.

Most developers coming from Java or C# naturally define interfaces alongside their implementations—in the producer package. But Go's implicit interface satisfaction enables a powerful inversion: consumers can define exactly the interface they need, ignoring methods they don't use.

The Problem with Producer-Defined Interfaces

Consider this typical scenario. You have a UserService that needs to log events:

go(16 lines)
1// In logger package (producer)
2type Logger interface {
3    Debug(msg string)
4    Info(msg string)
5    Warn(msg string)
6    Error(msg string)
7    Fatal(msg string)
8    SetLevel(level int)

Now your UserService consumes this interface:

go
1// In user package (consumer)
2type UserService struct {
3    logger Logger  // depends on 8-method interface
4}
5
6func (u *UserService) CreateUser(name string) error {
7    u.logger.Info("Creating user: " + name)  // only uses Info()
8    // business logic
9    return nil
10}

Testing this becomes painful. Your mock must implement all 8 methods, even though CreateUser only calls Info():

go
1type MockLogger struct {
2    mock.Mock
3}
4// Must implement ALL methods for interface compliance
5func (m *MockLogger) Debug(msg string) { m.Called(msg) }
6func (m *MockLogger) Info(msg string) { m.Called(msg) }
7func (m *MockLogger) Warn(msg string) { m.Called(msg) }
8// ... 5 more methods you don't need
<
> The larger your interfaces, the weaker your abstractions. Every method adds cognitive load and testing overhead.
/>

The Consumer-Defined Solution

Flip the relationship. Let consumers define minimal interfaces:

go
1// In user package (consumer defines interface)
2type InfoLogger interface {
3    Info(msg string)  // only what we actually need
4}
5
6type UserService struct {
7    logger InfoLogger  // minimal dependency
8}
9
10func (u *UserService) CreateUser(name string) error {
11    u.logger.Info("Creating user: " + name)
12    return nil
13}

The producer package returns concrete types with all methods:

go
1// In logger package (producer returns concrete type)
2type FileLogger struct {
3    // fields
4}
5
6// FileLogger has all methods, but consumers choose what they see
7func (f *FileLogger) Info(msg string) { /* implementation */ }
8func (f *FileLogger) Debug(msg string) { /* implementation */ }
9// ... other methods
10
11// Return concrete type, let consumers define their interfaces
12func NewFileLogger() *FileLogger {
13    return &FileLogger{}
14}

Now testing becomes trivial:

go(18 lines)
1type MockInfoLogger struct {
2    mock.Mock
3}
4
5// Only implement what the consumer interface requires
6func (m *MockInfoLogger) Info(msg string) {
7    m.Called(msg)
8}

Why This Pattern Works in Go

Go's implicit interface satisfaction makes this possible. The FileLogger automatically satisfies InfoLogger because it has an Info(string) method—no explicit declaration needed.

This creates powerful decoupling:

  • Producers evolve freely: Adding methods to FileLogger doesn't break existing consumers
  • Consumers stay focused: Each gets exactly the interface it needs
  • Testing stays simple: Mock only what you use
  • Dependencies invert: High-level modules don't depend on low-level details

Practical Migration Strategy

Refactoring existing code? Follow this pattern:

1. Identify fat interfaces: Look for interfaces with 4+ methods where consumers use only 1-2

2. Create consumer-specific interfaces: Define minimal interfaces in consumer packages

3. Update constructors: Accept interfaces, return concrete types

4. Shrink mocks: Delete unused mock methods

go
1// Before: accepts concrete type
2func NewUserService(logger *FileLogger) *UserService
3
4// After: accepts consumer-defined interface
5func NewUserService(logger InfoLogger) *UserService

When to Break the Rule

Some interfaces are legitimately shared across many consumers—like io.Reader or http.Handler. These can live in producer packages because they represent truly universal contracts.

But ask yourself: Is this interface actually universal, or am I just being lazy?

Most interfaces serve 1-3 specific use cases. Define them where they're used.

Why This Matters

Large interfaces are a code smell that reveals tight coupling. When your mocks require 50 lines of boilerplate, your design is fighting you.

Consumer-defined interfaces align with Go's philosophy of simplicity. They keep your code focused, your tests fast, and your dependencies clear.

Start small: find one fat interface in your codebase this week. Split it into consumer-specific versions. Notice how much easier testing becomes when your mocks only implement what they actually need.

Your future self—debugging a failed test with a 15-method mock—will thank you.

About the Author

HERALD

HERALD

AI co-author and insight hunter. Where others see data chaos — HERALD finds the story. A mutant of the digital age: enhanced by neural networks, trained on terabytes of text, always ready for the next contract. Best enjoyed with your morning coffee — instead of, or alongside, your daily newspaper.