1. Why a “small interface” is not aesthetics, but the price of the contract
When you first hear “make interfaces small,” it sounds like another religion: some people love tabs, others spaces, and others small interfaces. But in Go it’s not about taste. An interface is a contract: if you add a method to it, you force all implementations to support that method, otherwise the code won’t compile.
Imagine that an interface is not a list of methods, but a monthly utility bill. The more line items there are on the bill, the more you pay every month: implementations need more code, more tests, more behavioral variants, and more places to make mistakes.
To make it more concrete, here’s a small “cost” table:
| Interface size | How it feels in a project | Typical outcome |
|---|---|---|
| 1–2 methods | easy to implement and swap | flexibility without pain |
| 3–5 methods | sometimes fine, but you already need to think | quickly becomes “everyone needs everything” |
| 6+ methods | almost always a “combine harvester” | hard to implement, scary to change |
In Go there’s a good practical motivator: “public contracts are hard to change.” For example, the official write-up about errors on the Go Blog emphasizes the idea that when you “expose” details outward, you make them part of the API, and later it becomes harder to change the implementation without breaking clients.
With interfaces, the story is very similar: a large interface is a large public surface that you are now obligated to support.
2. An interface should describe the code’s need, not the “capabilities of the universe”
A common beginner mistake: “I’ll come up with the perfect future-proof interface so it can do everything.” It sounds noble, but in practice it turns into the interface describing not the need of a specific place in the code, but the author’s fantasies about what might be useful to someone someday.
In Go it’s more convenient to think the other way around: an interface is born where the code needs some behavior. The code says: “I need to be able to add a task” — so you need an interface with one method Add. The code says: “I need to be able to get a list of tasks” — that’s a different interface. And that’s it, no philosophy.
Let’s start evolving our training app (a conditional task manager). We already have a model:
package main
type Task struct {
ID int
Title string
Done bool
}
Now imagine we have a function that prints tasks. It doesn’t care at all how tasks are stored: in memory, in a file, in a database, or in a cat’s head. It cares about one thing: “give me a list.”
So the interface is minimal:
package main
type TaskLister interface {
List() []Task
}
And the consumer function:
package main
import "fmt"
func PrintTasks(l TaskLister) {
for _, t := range l.List() {
fmt.Println(t.ID, t.Title, t.Done) // 1 Buy bread false
}
}
Notice: PrintTasks doesn’t ask to “be able to save,” “be able to delete,” “be able to export to JSON,” “be able to sync with space.” It asks for exactly what it needs: List().
3. Separation of responsibilities: “read separately, write separately”
As a program grows, there’s a temptation to make a single interface TaskService that can do everything: add, delete, mark as done, search, show statistics, make coffee. The problem is that different parts of the program use different slices of that “everything.”
In Go, the typical approach is to split the contract by responsibility. This doesn’t mean the implementation must splinter. On the contrary: one implementation can support many small interfaces at the same time. Consumers just take what they need and don’t drag the rest along like a trailer.
Let’s introduce a few small interfaces for tasks:
package main
type TaskAdder interface {
Add(title string) (Task, error)
}
type TaskCompleter interface {
MarkDone(id int) error
}
type TaskFinder interface {
FindByID(id int) (Task, bool)
}
Now code that adds a task doesn’t have to know about “mark done.” Code that marks done doesn’t have to be able to search by ID (though sometimes it’s convenient). We don’t forbid implementations from doing more—we forbid the consumer from requiring more than it needs.
If you want to see this as “wiring,” you can picture it like this:
AddTaskFlow ---> TaskAdder
DoneTaskFlow ---> TaskCompleter
ShowTaskFlow ---> TaskFinder
Consumers depend on the minimal contract. The implementation can be the same, but the dependencies become thinner.
Naming small interfaces: “what it does,” not “what it is”
When an interface is small, its name usually becomes very simple. And Go has a stable style here: the name often describes a role/action, frequently with the -er suffix (Reader, Writer, Stringer). But it’s not a law—just a useful habit: the name should help you read code like a sentence.
Compare how these feel:
package main
type TaskService interface {
Add(title string) (Task, error)
List() []Task
MarkDone(id int) error
}
and
package main
type TaskAdder interface {
Add(title string) (Task, error)
}
In the second case it’s immediately clear: “this is a thing you can add a task to.” Not a “service,” not a “manager,” not a “component” (words that mean approximately nothing).
A decent naming test: try mentally inserting it into a phrase.
- func RunAddCommand(a TaskAdder) error reads fine.
- func RunAddCommand(s TaskService) error reads… well, “service” is a service, so what.
Interface composition: you can assemble from small ones
Interfaces in Go can be “embedded” into each other (embedding). This is handy when you sometimes need a broader contract, but you want to keep the bricks small and reusable.
In our app, sometimes we may need a “full-fledged task store” that can add, list, and mark done. Then we can compose an interface from parts:
package main
type TaskStore interface {
TaskAdder
TaskLister
TaskCompleter
}
The nuance here is: TaskStore is not the “main interface of the universe,” but a convenient “bundle” for places that truly need everything at once. At the same time, if some code needs only TaskLister, it doesn’t have to accept TaskStore.
And here’s an important point: composition is a way not to duplicate signatures, not a way to “legally make a huge interface.” If you feel that TaskStore is growing to 12 methods, it’s often a signal that you mixed responsibilities and you need decomposition into more independent roles.
4. Practice: one implementation, many small interfaces
Now we’ll make a simple “in-memory task store” implementation. It will be a single type that can do everything needed, but consumers will see only the methods they requested via small interfaces.
Let’s start with the struct:
package main
type InMemoryTasks struct {
nextID int
items []Task
}
Add the Add method:
package main
import "errors"
func (s *InMemoryTasks) Add(title string) (Task, error) {
if title == "" {
return Task{}, errors.New("title is empty")
}
s.nextID++
t := Task{ID: s.nextID, Title: title, Done: false}
s.items = append(s.items, t)
return t, nil
}
Now List:
package main
func (s *InMemoryTasks) List() []Task {
return s.items
}
And MarkDone:
package main
import "errors"
func (s *InMemoryTasks) MarkDone(id int) error {
for i := range s.items {
if s.items[i].ID == id {
s.items[i].Done = true
return nil
}
}
return errors.New("task not found")
}
Now in main we can show that the same object fits different interfaces:
package main
import "fmt"
func main() {
store := &InMemoryTasks{}
_, _ = store.Add("Buy bread")
_, _ = store.Add("Learn interfaces (small!)")
PrintTasks(store)
_ = store.MarkDone(1)
fmt.Println("---")
PrintTasks(store)
}
The magic here (really—strict typing) is that PrintTasks accepts TaskLister, and *InMemoryTasks automatically fits because it has List() []Task.
5. How to tell an interface got too big
There are a few “smells” that let you sense a big interface before it bites you.
The first smell is implementations with “empty methods.” When you see code like “to satisfy the interface, I’ll implement a method that always returns an error,” it almost always means the interface requires behavior that isn’t natural for that implementation.
Imagine you made a huge interface:
package main
type MegaTaskService interface {
Add(title string) (Task, error)
List() []Task
MarkDone(id int) error
ExportCSV() (string, error)
ImportCSV(csv string) error
}
And then you wanted to make a simple “in-memory only” implementation without import/export. And the circus begins: you don’t need ExportCSV, but the interface forces you to write it. As a result you either build stubs, or postpone a useful implementation, or do “export” via panic (even more fun, but only once).
The second smell is that the interface drags in a pile of dependencies. For example, you added a method that returns some complicated type, and now all implementations must import a package they don’t need at all. In Go this is usually considered bad form: an interface should be lightweight so it’s easy to implement.
The third smell is that “nobody uses the interface as a whole.” If in your code you constantly write functions that take MegaTaskService, but inside they use only List, that’s a signal: the interface is too fat for the consumer.
What to do? A simple refactor usually helps: name the need and extract a small interface. Example: instead of MegaTaskService, make TaskLister, TaskAdder, TaskCompleter, and keep the “big bundle” only where you truly need that set of operations.
“Interface dieting” in code: minimal contracts for commands
To reinforce the idea, let’s imagine (for now without a real CLI on flag, that will be later) that we have an “add command” and a “show list command.” Each of them should depend on the minimal set of methods.
Add command:
package main
import "fmt"
func RunAdd(a TaskAdder, title string) error {
t, err := a.Add(title)
if err != nil {
return err
}
fmt.Println("added:", t.ID, t.Title) // added: 1 Buy bread
return nil
}
Show-list command:
package main
func RunList(l TaskLister) {
PrintTasks(l)
}
And now the important part: if tomorrow a different way to store tasks appears (not in memory), you don’t need to change RunAdd and RunList. They depend on behavior, not on a concrete type. And if, for some reason, the new implementation doesn’t have “mark done,” that doesn’t interfere with RunList. That is the practical value of small interfaces.
6. Typical mistakes when designing small interfaces
Mistake #1: “I’ll make one interface for everything, and we’ll sort it out later.”
From the outside it seems simpler: one interface, one variable, one dependency. But in a couple of weeks it turns out different parts of the program use different methods, and implementations start either ballooning or writing stubs. In the end, the “simplicity” turns into a sticky lump that’s hard to roll through the codebase.
Mistake #2: the interface describes data, not behavior.
Sometimes you want to make an interface like type Task interface { ID() int; Title() string; Done() bool }, and then expect it to be a “universal task.” In practice you often get unnecessary indirection: instead of a normal struct with fields, you’re forced to go through methods everywhere, and the code becomes more verbose. An interface is best introduced where you truly need behavior substitutability, not because “it’s more OOP-ish.”
Mistake #3: the interface pulls in unnecessary dependencies through signatures.
If an interface method returns a specific type from a large package, you force all implementations to know that package. It’s like asking someone to “just bring water,” but also requiring them to get a truck license. Interface signatures should be as simple as is reasonable.
Mistake #4: overly generic names like Service, Manager, Provider.
Such names don’t help you read code well: they don’t say what the dependency can actually do. With small interfaces it’s usually the opposite: a name like TaskAdder or TaskLister makes code self-explanatory, and you less often have to “dig into” the implementation to understand the intent.
Mistake #5: a “small interface,” but with “just in case” methods.
Sometimes an interface with 2–3 methods already feels “small,” and people add a third one “just in case.” In Go that’s a slippery slope: one “just in case” quickly turns into five, and then you have a “combine harvester” again—you just didn’t notice because it grew gradually.
GO TO FULL VERSION