JavaRush /Courses /Go SELF /Antipatterns — interface‑pollution

Antipatterns — interface‑pollution

Go SELF
Level 37, Lesson 3
Available

1. Why abstractions in Go aren’t “free flexibility”

Almost every beginner has a moment when it feels like: “If I create an interface, the code will become flexible. If I build a universal layer, the project will become ‘like the grown-ups do’.” That feeling is understandable: in many languages, abstractions are the norm and even a mandatory style. But in Go, abstractions really do have a cost: you pay with extra types, extra indirection, harder navigation, and a blurred contract.

To make it easier to “feel the cost”, it helps to remember: code readability is the speed at which you can answer questions like “what does this function do?” and “what happens if there’s an error here?”. Every additional interface, factory, “manager”, or “provider” forces your brain to take an extra lap: find the type, find the implementation, understand who calls whom. The result is weird math: you added 3 files and 4 types to save 2 lines of code and gain the “ability to swap the implementation in the future”. The future, by the way, never signed a contract that it would even arrive.

In Go, it is especially important that interfaces are part of the dynamic dispatch mechanism, which makes analyzing “what can really be called” harder: dynamic calls through interfaces affect method reachability and overall program analysis. Even code analysis tools discuss separately that interface calls expand the set of potentially invoked functions. This isn’t “forbidden”, but it’s a good marker: interfaces are a powerful tool, and power is best enabled not out of habit, but out of necessity.

2. Interface‑pollution: when interfaces start multiplying

Interface‑pollution (pollution by interfaces) is a situation where a project has too many interfaces that provide no real benefit: they don’t reduce coupling, don’t simplify testing, don’t reflect a consumer need, and simply create a “just in case” layer. Often it looks like this: for every struct we create an interface, for every service we create an interface, and then another interface that “combines everything”. And suddenly the code looks serious, but it’s impossible to read.

A typical symptom on a todo app example

Imagine we have a simple domain model Task and we store tasks in memory (at this stage of the course this is perfectly fine: the app runs, works, and is testable). Here is a natural concrete implementation:

package todo

type Task struct {
	ID    int
	Title string
	Done  bool
}

type MemoryStore struct {
	tasks []Task
}

And now a typical “harmful upgrade” that people often do “because that’s the right way”:

package todo

type TaskStore interface {
	Add(Task) error
	List() []Task
	MarkDone(id int) error
	Delete(id int) error
}

type MemoryStore struct {
	tasks []Task
}

At first glance it looks nice: “we have a contract”. But if you have exactly one implementation (MemoryStore) and you are not planning another one right now, then this interface didn’t add a contract—it added an obligation: now any code must accept TaskStore, any tests must think about a “mock”, and any new methods must be coordinated with the interface.

The most unpleasant effect is that the interface starts to bloat. You wanted to add UpdateTitle—you add it to the interface. Then FindByID—add it. Then filters—add them. And suddenly the interface is not “the minimum to work”, but “everything the concrete implementation can do”. That’s no longer a consumer contract; it’s a catalog of your struct’s capabilities—exactly what Go usually tries to avoid.

Why “an interface for every type” is a bad deal

In plain terms, an interface in Go is useful when it narrows a dependency. That is, when the consumer truly doesn’t need to know “what’s inside”; it only needs two or three methods. If the interface repeats the entire public API of a struct, it doesn’t narrow the dependency—it masks it, while still forcing you to jump around the code to find implementations.

There is also an engineering side: interfaces influence how the compiler/linker can discard unused methods. In Go’s history there were specific optimizations related to “method pruning” and the fact that methods sometimes must be kept due to the possibility of being called via an interface. This doesn’t mean “interfaces are bad”, but it does mean “don’t multiply them without a reason”.

3. An interface “for tests”: a popular myth and a normal alternative

Very often interface‑pollution is justified like this: “But interfaces are needed for testing!”. Let’s unpack this carefully. It’s not entirely false, but it is often applied automatically, even when tests can be written more simply and honestly.

What the myth looks like

You write a service that creates a task, and immediately introduce an interface:

package todo

type Store interface {
	Add(Task) error
}

type Service struct {
	store Store
}

Then in tests you think: “If there is an interface, that means I need a mock.” And you write a fakeStore, call counters, and so on. It’s not a disaster, but the problem is that you added the interface not because there were multiple implementations, but because you got scared of tests in advance.

A more Go-ish approach: test the concrete implementation

While MemoryStore is the only implementation, you can test it directly. The test is short and clear, without mock “magic”:

package todo

import "testing"

func TestMemoryStore_AddAndList(t *testing.T) {
	s := &MemoryStore{}                // concrete type, no interface
	_ = s.Add(Task{ID: 1, Title: "A"})  // simplified: we’ll handle the error later in the course

	got := s.List()
	if len(got) != 1 {
		t.Fatalf("expected 1 task, got %d", len(got))
	}
}

This isn’t “dirty”. It’s honest: we have one MemoryStore, and that is exactly the behavior we want to verify. If a real second implementation appears later, that’s when an interface can become beneficial—but based on an actual need, not out of habit.

4. Premature abstraction

If interface‑pollution is when you have lots of useless interfaces, then premature abstraction is a broader story. It’s about starting to build a “universal framework inside your project” even though you currently have only one task: for example, add a task and print a list.

This habit is especially sticky because it looks like professionalism: “I’m thinking about the future.” But in practice, the future almost always arrives differently than you expected. And instead of “universal architecture” you get universal pain: the code is hard to change because it was built around assumptions.

“Repository/Manager/Provider” for the sound of it

Here’s a very realistic picture in a learning project:

package todo

type TaskManager struct {
	repo TaskRepository
}

type TaskRepository interface {
	Save(Task) error
	LoadAll() ([]Task, error)
}

Now be honest: did the code become easier to read? While you have no DB, no files, and no network, TaskRepository is a “promise of the future”, but today it makes things worse. You are already forced to think in “repo/manager” terms even though you have trivial task-list logic.

Often it’s better to start with a concrete “storage” (for example, in-memory) and name things as they are: Store, Service, Task, AddTask. When a real extension point appears, you’ll see it.

“A universal utils package”

Another popular symptom of premature abstraction is a utils or common package where everything is dumped: parsing, formatting, “helpers”, errors. From the reader’s brain perspective it looks like: “everything important is in a box labeled ‘Misc’.” That’s not help; it’s a loss of meaning.

In Go, the package name is part of the API and part of reading. When you see todo.NormalizeTitle, you roughly understand the responsibility area. When you see utils.Normalize, you understand nothing except that the author got tired of thinking about names (we’ve all been there).

5. When an interface is truly needed

When people hear “don’t multiply interfaces”, a reasonable fear comes up: “So how do I write things ‘the right way’ then?”. The answer: interfaces in Go are not a “default template”, but a tool for specific tasks. To avoid guessing, keep a simple criterion in mind: an interface is needed when it reduces the consumer’s dependency.

It’s helpful to think like this: every interface has a “customer”. The customer is the code that will use the interface, not implement it. And in Go it is very natural to declare an interface “where it is used” (we already talked about this in the day about interfaces). This immediately protects you from bloat: the consumer will declare exactly what it needs, not “everything the struct can do”.

A small visual cheat sheet in the form of a flowchart helps you avoid arguing with yourself:

flowchart TD
    A[Want to create an interface] --> B{Do I have 2+ implementations right now?}
    B -->|Yes| C[The interface may be justified]
    B -->|No| D{Do I need to narrow the dependency? E.g., accept only 1-2 methods}
    D -->|Yes| C
    D -->|No| E[Keep the concrete type and come back later]

The key idea in this diagram: “come back later” is not laziness. It’s a normal engineering technique. Go generally encourages evolving the design through real experience, not through assumptions. (Ironically, the most “grown-up” code often starts as a “simple thing”, not as “enterprise architecture on a napkin”.)

By the way, when it comes to complex combinations of interfaces and generics, official materials also emphasize that constraints and interfaces are tools with trade-offs: you can do it neatly, but you can also make an API less readable if you start “constructing a type system” for the sake of the type system.

6. Todo example: without interface‑pollution

Now we’ll do something that may look boring, but in practice saves hours of life: we’ll show how to write code that is simple today and doesn’t close the door for tomorrow.

Start with concreteness: Service depends on *MemoryStore

First, straightforward and honest code:

package todo

import "errors"

type Service struct {
	store *MemoryStore
}

func (s *Service) Add(title string) error {
	if title == "" {
		return errors.New("title is empty")
	}
	return s.store.Add(Task{Title: title})
}

This is not “bad”. It’s the current reality of the project: one storage. The reader sees a concrete type and knows where to look for the implementation.

When a reason appears, introduce a narrow interface at the usage site

Suppose we now have a second storage method (what it is doesn’t matter; what matters is that the reason became real). Then we can define a small interface that the service actually needs—and only with the methods the service truly uses:

package todo

type TaskAdder interface {
	Add(Task) error
}

type Service struct {
	store TaskAdder
}

Note: the interface is named by role, and it’s small. Such a name is easier to read in code: store TaskAdder means “a store you can add to”. It reads like a role, not like a universal entity of the universe.

If later the service needs List, we’ll add a new interface or extend the contract—but based on facts. Not in advance.

Avoid fat interfaces: two roles are better than one combine-harvester

When an interface grows, it’s tempting to make “one big repository”:

// example of something that often makes design worse
type TaskStore interface {
	Add(Task) error
	List() []Task
	Delete(int) error
	MarkDone(int) error
}

Sometimes this is justified, but very often you catch yourself realizing that different consumers need different subsets. Then it is much more convenient to split it:

package todo

type TaskLister interface {
	List() []Task
}

type TaskDeleter interface {
	Delete(id int) error
}

This is not “playing with interfaces”. It’s a way to avoid turning one interface into a dumping ground.

And yes, interfaces in Go are valuable precisely because you can compose them as needed—but if you start doing that without an actual need, you’re back to premature abstraction again.

Smells: how to spot the problem before it becomes the norm

So you don’t have to catch yourself on every commit, it helps to have a few “smells”—signs that you are drifting into interface‑pollution and premature abstraction. The table below is not a law, but a self-check hint.

Code smell Why it’s suspicious What’s usually better
The interface and the only implementation sit next to each other and look the same The interface doesn’t narrow anything; it only adds another layer Start with the concrete type; move the interface later and to the usage site
An interface with 8–15 methods Almost certainly “everything the implementation can do”, not consumer needs Split into smaller roles or revisit the design
utils/common/helpers packages grow every week Meaning gets smeared out; navigation gets worse Make functions part of the domain package or a specific topic
Manager/Provider/Factory appear without real complexity These are “architecture words” that often explain nothing Name by action and domain: Service, Store, Parser
“We made an interface so we can test” with only one implementation Tests became more complex with no payoff Test the concrete type while it’s the only one

7. Common mistakes

Mistake #1: an interface for “prettiness” instead of a real need.
This often happens right after you learn interfaces and want to apply them everywhere. The project turns into a showcase of patterns: the interface exists, but there are no alternative implementations, and the reader is forced to search for “who actually does the work”. The cure is simple and sometimes a bit painful: start with the concrete type and add an interface only when a second implementation appears or there is a real need to narrow the dependency.

Mistake #2: a “fat interface” that repeats the entire struct API.
This almost always turns the interface into a trash can of requirements and breaks code evolution: any new feature requires changing the interface, which means touching all dependencies. You get reverse flexibility: adding becomes harder, tests and adapters start suffering. Usually, splitting into small roles and declaring the interface from the consumer side—not the implementation side—helps.

Mistake #3: a premature “architecture layer”: repository/manager/provider without a task.
In learning and small projects this is especially tricky: it feels like you’re “doing it like in big production”, but in reality you’re just adding places to make mistakes and increasing the distance between the call and the real action. It’s better to first build a direct, readable implementation, and when real complexity appears, extract a layer that truly reduces coupling.

Mistake #4: the appearance of utils/common, “about everything” packages.
These packages look like a convenient storage room, but a month later they turn into a quest: where is the needed function, and why is it even here? The code starts to lose its semantic structure, and new project members can’t navigate. Usually it’s better to keep helpers next to the domain area (for example, todo.NormalizeTitle), so the package name helps convey meaning.

Mistake #5: ignoring that interfaces increase the program’s dynamism.
Interfaces are a powerful mechanism, and they affect how tools analyze a program and which calls are considered possible: dynamic calls through interfaces expand the reachability space and make analysis harder. This is not a ban, but a reminder: if an interface doesn’t bring value, you pay for it in several places at once—not only “in your head”, but also in tool behavior.

1
Task
Go SELF, level 37, lesson 3
Locked
Shopping List
Shopping List
1
Task
Go SELF, level 37, lesson 3
Locked
Report to writer
Report to writer
1
Task
Go SELF, level 37, lesson 3
Locked
Storage roles
Storage roles
1
Task
Go SELF, level 37, lesson 3
Locked
Console todo
Console todo
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION