Why large interfaces make testing painful—and how to shrink them.
Chapter 19: The Overloaded Interface
The archive was quiet, save for the rhythmic tapping of Ethan’s computer keyboard. He was staring at his dual monitors, scrolling through a massive file.
"You look deep in thought," Eleanor said softly, pausing at his desk.
"I'm refactoring the User Service," Ethan said, looking up. "You told me interfaces were the key to flexibility. So I made a UserService interface that handles everything a user might need."
He pointed to his code.
type UserService interface {
GetUser(id string) (*User, error)
ListUsers() ([]User, error)
CreateUser(u *User) error
UpdateUser(u *User) error
DeleteUser(id string) error
ResetPassword(email string) error
VerifyEmail(token string) error
AddRole(userID, role string) error
RemoveRole(userID, role string) error
AuditLog(action string) error
}
"It covers everything," Ethan said. "Now, whenever I need to do anything with users, I just pass this interface around. It fully decouples the code."
"That is a very complete list," Eleanor agreed. "How are the unit tests coming along?"
The Mocking Pain
Ethan hesitated. "That is where I am running into some friction. I am trying to test the LoginHandler. It only needs to check the user's password, but the setup feels... heavier than I expected."
He opened his test file.
type MockUserService struct {}
// I only need this one method for the test...
func (m *MockUserService) GetUser(id string) (*User, error) {
return &User{ID: "123"}, nil
}
// ...but the compiler forces me to implement ALL of these:
func (m *MockUserService) ListUsers() ([]User, error) { return nil, nil }
func (m *MockUserService) CreateUser(u *User) error { return nil }
func (m *MockUserService) UpdateUser(u *User) error { return nil }
func (m *MockUserService) DeleteUser(id string) error { return nil }
func (m *MockUserService) ResetPassword(e string) error { return nil }
// ... (10 more empty methods)
"I spend more time writing empty mock methods than actual test code," Ethan admitted.
"It looks like that interface is carrying a lot of weight," Eleanor noted gently. "You are forcing the test to carry the whole library just to read one book."
Consumer-Defined Interfaces
She pulled up a chair. "Ethan, in languages like Java or C#, you often define the interface with the implementation. You define the UserService upfront."
"Right. That's what I did."
"In Go, we can do it differently. We can define interfaces where they are used, not where they are implemented. This way, each package defines only the behavior it actually needs, not the entire capabilities of the dependency."
She pointed to the LoginHandler code.
"Let's look at what this handler actually needs," she suggested. "It certainly uses GetUser. But does it ever need to delete users or reset passwords?"
"No," Ethan said. "It just reads the user ID."
"Then let's just ask for that," she said, typing. "We can make your life much easier."
// Inside the 'handler' package (where the interface is USED)
type UserGetter interface {
GetUser(id string) (*User, error)
}
func Login(ug UserGetter, id string) {
user, _ := ug.GetUser(id)
// ... logic ...
}
"Now look at your test," she said.
type MockUserGetter struct {}
func (m *MockUserGetter) GetUser(id string) (*User, error) {
return &User{ID: "TestID"}, nil
}
Ethan stared at the screen. "That's it? I don't need to implement the other nine methods?"
"No," she smiled. "Because Login doesn't ask for a UserService anymore. It asks for a UserGetter. Anything that can get a user satisfies the requirement."
"But what about my real code?" Ethan asked. "Do I need to go back to my RealUserService and tell it that it implements UserGetter?"
"Not at all. Your RealUserService already has a GetUser method. In Go, interfaces are satisfied implicitly. Therefore, it is a UserGetter."
// This works automatically
svc := &RealUserService{}
Login(svc, "123")
The Interface Segregation Principle
"This is the Interface Segregation Principle," Eleanor explained. "Clients should not be forced to depend on methods they do not use."
She pointed to his original code.
"If you ask for a UserService, you are technically depending on creating, updating, deleting, and auditing users. If you ask for a UserGetter, you depend only on the read operation."
Ethan started deleting lines of code.
"Return concrete structs from your service package," Eleanor advised. "Let the consumer define the small, precise interface they need. One method is best. Two is okay. Three is usually fine for cohesive operations, but if you see methods from different domains mixed together, consider splitting it."
Key Concepts from Chapter 19
Interface Pollution:
The tendency to create large, all-encompassing interfaces (like UserService) that describe an entire subsystem. This makes testing difficult because mocks must implement every method, even the ones irrelevant to the test.
Consumer-Defined Interfaces:
In Go, interfaces should be defined by the consumer (the function calling the code), not the producer (the struct implementing the code).
-
Don't: Export a massive
Readerinterface in yourdatabasepackage. -
Do: Define a small
Readerinterface in yourworkerpackage that only includes the method you call.
"Accept Interfaces, Return Structs":
A standard Go design pattern.
- Functions should accept interfaces: This allows you to pass in any implementation (real or mock).
- Functions should return concrete structs: This gives the consumer the freedom to define their own small interfaces to describe that struct.
Implicit Satisfaction:
A type satisfies an interface if it implements the required methods. No explicit declaration (like implements UserService) is required. This allows you to create new, small interfaces that work with existing code without modifying the original structs.
Next chapter: The Defer Statement. Ethan learns that defining what you need (interfaces) is half the battle; defining when to clean it up is the other half.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (1)
This really clicked for me.
I’ve felt that exact pain of writing huge mocks just to test one small behavior.
Defining interfaces where they’re used instead of where they’re implemented makes Go feel so much cleaner and more test-friendly.
Small interfaces, less friction, happier tests — great explanation 👍