JavaRush /Courses /Go SELF /Where to declare interfaces

Where to declare interfaces

Go SELF
Level 24, Lesson 3
Available

1. Introduction

An interface in Go is not just “a type out of thin air”, but part of an agreement between pieces of a program. And this is where the philosophy begins (don’t worry—no beard and no monastery): whoever formulates the agreement sets the rules. If an interface is declared “in the wrong place”, your project gradually grows weird dependencies where one package drags in another just for the types, and replacing an implementation turns into a quest.

Imagine that an interface is a power outlet, and a concrete implementation is a specific device. If the outlet is screwed onto the kettle, then to plug in a laptop you’d have to carry the kettle around the apartment. It kind of works, but looks strange, and guests start asking questions.

In Go, the main practical rule sounds like this:

Most of the time, you should declare an interface where it’s needed—i.e., on the consumer side.

To make this more than a slogan and turn it into a clear technique, we’ll go through a small mini-app example.

2. The “interface on the consumer side” rule

The phrase “declare interfaces where they’re used” does not mean “always in main”, but “in the package/file that depends on the abstraction”. That is, where the code wants to call methods, but doesn’t want to know which concrete implementation is behind them (memory, file, database—anything).

Let’s continue our conditional learning app Tasker—the simplest task manager. We have a domain model Task and logic for adding/listing. We need a task storage. From the application logic point of view, the actions matter: “save a task”, “return the list of tasks”. How it’s done is a detail.

A mini “storage” contract (an interface) is logical to declare where it’s needed: in the code that manages tasks.

package tasker

type TaskStore interface {
	Add(text string) error
	List() []string
}

Notice: an interface doesn’t have to be “big and serious”. On the contrary, it’s usually small and quite down to earth: exactly the methods the consumer needs.

Now the task “service” can depend on TaskStore, not on a specific MemStore, FileStore, etc.

package tasker

type Service struct {
	store TaskStore
}

func NewService(store TaskStore) Service {
	return Service{store: store}
}

At this point you get a pleasant freedom: the service doesn’t care what’s inside store, as long as it can Add and List.

Tasker mini example: the interface next to the calling code

When a rule feels abstract, the easiest way is to ground it in a concrete file structure. Let’s imagine we have three packages: tasker (logic), memstore (in-memory implementation), and main (entry point). This is not “grown-up architecture”, just a tidy layout so the dependency direction is obvious.

It’s useful to read the diagram like this: main “wires up” dependencies, tasker formulates the contract (the interface), memstore provides the implementation. At the same time, tasker does not have to import memstore.

flowchart LR
  main --> tasker
  main --> memstore
  memstore -. "implements TaskStore" .-> tasker

The tasker package declares the interface and uses it. The memstore package just implements the required methods—and that’s it. It doesn’t have to “know” that an interface exists.

Here is what a simple “in-memory” implementation might look like:

package memstore

type Store struct {
	items []string
}

func (s *Store) Add(text string) error {
	s.items = append(s.items, text)
	return nil
}

And the second method:

package memstore

func (s *Store) List() []string {
	return s.items
}

And now main puts everything together. Notice: main depends on the concrete implementation (memstore.Store), but tasker.Service depends only on the interface (tasker.TaskStore).

package main

import (
	"fmt"

	"example/memstore"
	"example/tasker"
)

func main() {
	st := &memstore.Store{}
	svc := tasker.NewService(st)

	_ = svc // for now we just wired up dependencies
	fmt.Println("tasker started") // tasker started
}

The key point: the interface is declared not in memstore, but in tasker, because tasker is the one that formulates the need: “I need to be able to Add and List”.

If you declare the interface at the implementation: what breaks

Now let’s look at an alternative that beginners often do “by intuition”: since we have a memstore package, let’s declare the Store interface there (or Repository, or Storage, or “something very serious”).

The problem is that the interface then turns into a “gift from the implementation”. And that’s almost always inverted logic: the consumer starts depending on the provider just to describe what it needs.

Imagine that memstore declares:

package memstore

type Storage interface {
	Add(text string) error
	List() []string
}

And now tasker (logic) wants to accept memstore.Storage. What happens?

First, tasker is forced to import memstore, even though by meaning tasker should not know that the storage is specifically “mem”. Today it’s memstore, tomorrow filestore, the day after tomorrow sqlstore, and suddenly your business logic starts pulling in “technology” packages.

Second, the interface begins to live in a package you might want to delete or replace. But an interface is part of the API. If “your application’s API” is hidden inside an implementation package, it becomes hard to manage: you changed the implementation—and at the same time you changed the place where the contract lives.

Third, the interface almost inevitably grows “to fit the implementation”. An implementation always has the temptation to say: “Since we’re here, let’s add another method DebugDump()… might be useful.” And the interface stops being minimal.

A healthy intuition is: an interface is owned by the consumer, not the provider. The provider just satisfies the requirements.

3. Exported interfaces are an expensive promise

An interface, especially an exported one (capitalized), is a public contract. If you publish it, then adding a new method breaks everyone who implements that interface. It’s like saying: “Starting tomorrow, to enter the building you’ll need not only a key, but also a cat paw print.” Those without a cat start feeling sad.

In Go, API compatibility is a big value, and any public part of an API is considered an obligation. Therefore a pragmatic rule for interfaces inside packages is: if the interface is needed only inside the package, make it unexported.

For example, inside the tasker package you can do this:

package tasker

type store interface {
	Add(text string) error
	List() []string
}

And externally you can export concrete functions/types that accept the required dependencies, without exposing the interface as “part of the public constitution”.

If the interface really is part of the public API (for example, you’re writing a library), then it must be small, stable, and formulated from the consumer’s point of view.

4. An interface is an abstraction boundary

There’s one more subtle but very practical idea: an interface is not just a set of methods, it’s a boundary through which details can “leak”. If you include types from an implementation package in the interface, you effectively bind all consumers to those details.

Interfaces follow the same logic as errors: if internal implementation details start leaking outward, calling code can begin to rely on them, and changing internals becomes painful.

Bad design looks roughly like this (the example is intentionally “crooked” to make it obvious):

package tasker

import "example/memstore"

type TaskStore interface {
	SaveRow(row memstore.Row) error
}

Now tasker depends on memstore.Row. That is, the “task management” logic already knows the internal structure of the “in-memory store”. That’s an abstraction leak.

A good direction of thinking: the interface should speak the language of the domain (tasks, strings, IDs), not the language of the implementation (“Row”, “SQLTx”, “BoltBucket”, “MagicCursor3000”).

5. Why this makes swapping implementations easier

When the interface is declared on the consumer side and is minimal, you gain the ability to change implementations without rewriting business logic. Even if you’re not writing tests yet and not building complex architecture, this is already useful: first you can store tasks in memory, then in a file, and not touch the “heart of the application”.

To see the idea in practice, it’s enough to make a second implementation—for example, a “stub” that always returns an empty list. This isn’t a test, not a mock framework, and not magic—just another type with the same methods.

package tasker

type EmptyStore struct{}

func (EmptyStore) Add(text string) error { return nil }
func (EmptyStore) List() []string        { return nil }

Now any code that works with TaskStore can receive EmptyStore{} and not know that it’s a “special” implementation. That’s the power of the contract: a type doesn’t have to be called Store to be a store; it only has to implement the required methods.

At the same time, it’s important to remember: if you define methods on *T, then the interface is implemented by *T, not by T. That’s not “bad”; it’s just part of the contract, and when wiring dependencies you need to pass the right form of the value.

6. How to choose where an interface should live in a project

Intuitively you want to ask: “Okay, but how do I understand where ‘on the consumer side’ is if everything is in one package?” That’s a normal question, especially in learning and small projects.

A working practice is: declare the interface as close as possible to the code that uses it as a dependency. Most often that means “next to the function or struct that accepts the dependency”.

For example, if you have a function that prints tasks and it doesn’t matter where they come from, you can declare the interface right next to it:

package tasker

type Lister interface {
	List() []string
}

func PrintAll(x Lister) []string {
	return x.List()
}

This looks almost like a “local contract”: the function said what it needs—and that’s it.

If the interface is needed in several places inside the package, move it higher in the file or into a separate file like interfaces.go. If the interface is needed only inside the package, make it unexported. If the interface is needed by external users of the package, export it—but be ready to keep that promise (and don’t bloat the interface “just in case”).

It’s handy to keep a small cheat-sheet table in mind:

Situation What to usually do
The interface is needed by only one function/struct Declare it nearby (in the same file, close to where it’s used)
The interface is needed by multiple entities within the package Move it to the package level, but keep it unexported
The interface is part of the package’s public API Export it; keep it small and stable
The interface is “about an implementation” (SQL/Redis/Mem) Most likely a smell: the interface should be about behavior, not technology

7. Common mistakes when choosing where an interface should live

Mistake No. 1: the interface is declared in the implementation package, and business logic imports the implementation just to get the interface.
This often happens accidentally: “well, I’m already in memstore, I’ll just write type Store interface here.” As a result, the logic package starts depending on the “memory” or “SQL” package, and then you wonder why replacing the implementation is hard. If the interface describes what the logic needs, it belongs in the logic package.

Mistake No. 2: the exported interface has grown, and now it’s scary to touch.
First you added Save, then Load, then “well, Clear() might be useful”, then “let’s add Stats()”. And then it turns out that two out of three implementations can’t honestly implement all of this without weird hacks. An exported interface is a promise, and the price of a promise is high: changing such a contract hurts because you break those who adapted to it.

Mistake No. 3: the interface exposes types from a concrete implementation.
If an interface method accepts or returns a type from an implementation package, you’ve broken the idea of abstraction. It’s like internal implementation details leaking out, and calling code starting to rely on them. In interfaces, it’s better to keep domain types or simple types from the standard library.

Mistake No. 4: the interface was created “because interfaces are needed”, not because there is a real consumer.
Sometimes an interface is declared in advance, “for future growth”, before any code exists that benefits from it. The result is an abstraction without meaning: no second implementation, more complex signatures, more files, and zero value. An interface in Go is a tool, not a religion: it’s needed when it reduces code’s dependency on details.

Mistake No. 5: the interface name reflects the technology, not the behavior.
SQLStore, FileRepo, RedisManager as an interface is often a bad sign. It’s better to name the interface by behavior (for example, Store, Saver, Lister), and the concrete type by technology (SQLStore, FileStore). This way, when reading code, the brain doesn’t confuse the “contract” and the “implementation”, and the project becomes noticeably calmer.

1
Task
Go SELF, level 24, lesson 3
Locked
Greeting interface
Greeting interface
1
Task
Go SELF, level 24, lesson 3
Locked
Notes Service
Notes Service
1
Task
Go SELF, level 24, lesson 3
Locked
Wiring packages
Wiring packages
1
Task
Go SELF, level 24, lesson 3
Locked
Two scenarios
Two scenarios
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION