Интерфейсы в Go: как проектировать, а не копировать
Интерфейсы в Go — единственный инструмент полиморфизма, и именно поэтому с ними чаще всего ошибаются. Разбираем типичные ошибки проектирования, сравниваем подходы и показываем, когда маленький интерфейс выигрывает у большого.

Проблема: интерфейс на стороне реализации
Самая частая ошибка при работе с интерфейсами в Go — объявлять их рядом с типом, который их реализует. Разработчик пишет пакет storage, создаёт структуру PostgresStore с десятью методами, а потом добавляет в тот же пакет интерфейс Store с теми же десятью методами. Выглядит аккуратно. На практике это бесполезная обёртка: ни один потребитель не зависит от интерфейса, потому что импортирует конкретную реализацию. Тесты мокают все десять методов, хотя реальному вызывающему коду нужны два.
В Java и C# интерфейсы живут рядом с реализацией — это идиома языка. В Go идиома обратная: интерфейс объявляет потребитель, а не поставщик. Разница не стилистическая — от неё зависит связанность пакетов, размер интерфейсов и стоимость тестирования.
Принцип: объявляй там, где используешь
Стандартная библиотека Go задаёт образец. io.Reader объявлен в пакете io, который потребляет потоки, а не в пакетах os, net или bytes, которые их реализуют. Ни один из реализующих типов не знает об io.Reader на уровне импорта. Именно поэтому io.Reader работает с чем угодно — от файла до HTTP-тела — без централизованной иерархии.
Правило: если вы пишете пакет billing, которому нужно читать данные пользователя, объявите интерфейс UserSource прямо в billing — с двумя методами, которые billing реально вызывает, а не с двадцатью, которые умеет пакет user.
«The bigger the interface, the weaker the abstraction» — Rob Pike, Go Proverbs. Чем больше методов в интерфейсе, тем меньше типов ему соответствуют и тем сложнее подменить реализацию.
Четыре подхода к дизайну интерфейсов
На практике интерфейсы в Go проектируют по-разному. Ниже — четыре подхода, которые встречаются в реальных кодовых базах, с ценой каждого.
1. Большой интерфейс на стороне реализации
Пакет storage экспортирует интерфейс Store с полным набором методов. Все потребители импортируют его напрямую.
Плюс: одно место правды. Минус: любой потребитель зависит от всех методов, даже если вызывает один. Моки раздуваются, рефакторинг ломает всё сразу.
2. Маленький интерфейс на стороне потребителя
Каждый пакет-потребитель объявляет свой интерфейс с одним-тремя методами. Реализация удовлетворяет им неявно.
Плюс: минимальная связанность, тесты мокают только нужное. Минус: интерфейсы дублируются, если несколько потребителей хотят одно и то же.
3. Функциональные зависимости
Вместо интерфейса потребитель принимает функцию: func(ctx context.Context, id string) (User, error). Подходит, когда зависимость — ровно одно действие.
Плюс: ноль типов, тест — анонимная функция. Минус: сигнатура не именована, при трёх функциях-аргументах конструктор становится нечитаемым.
4. Составной интерфейс через встраивание
Несколько мелких интерфейсов встраиваются в один большой там, где нужен полный набор — например, при инициализации приложения. Потребители продолжают зависеть от мелких.
Плюс: компромисс — потребители остаются узкими, а точка сборки видит все возможности. Минус: требует дисциплины, чтобы не начали импортировать составной интерфейс вместо мелких.
Сравнение подходов
| Критерий | Большой интерфейс | Мелкий у потребителя | Функция | Встраивание |
|---|---|---|---|---|
| Связанность пакетов | высокая | низкая | низкая | низкая |
| Стоимость мока в тестах | дорого — все методы | дёшево — 1-3 метода | бесплатно | дёшево |
| Читаемость конструктора | один аргумент | один аргумент | N аргументов | один аргумент |
| Риск раздувания | высокий | низкий | нет | средний |
| Обнаружение реализации | grep по типу | grep по методу | grep по сигнатуре | grep по типу |
Код: мелкий интерфейс на стороне потребителя
Пример на Go 1.21+. Пакет billing принимает зависимость через узкий интерфейс, а не через полный контракт хранилища.
// пакет billing
package billing
import "context"
// UserSource — ровно то, что нужно billing для расчёта.
// Объявлен здесь, а не в пакете user.
type UserSource interface {
UserByID(ctx context.Context, id string) (User, error)
}
// User — минимальная проекция, не полная модель из пакета user.
type User struct {
ID string
PlanSlug string
}
type Service struct {
users UserSource
}
func NewService(u UserSource) *Service {
return &Service{users: u}
}
func (s *Service) Invoice(ctx context.Context, userID string) (Invoice, error) {
u, err := s.users.UserByID(ctx, userID)
if err != nil {
return Invoice{}, err
}
// расчёт по u.PlanSlug
return calculateInvoice(u), nil
}
Тест для billing.Service мокает один метод, а не весь репозиторий:
package billing_test
import (
"context"
"testing"
"yourmod/billing"
)
// fakeUsers реализует billing.UserSource — один метод.
type fakeUsers struct {
user billing.User
err error
}
func (f *fakeUsers) UserByID(_ context.Context, _ string) (billing.User, error) {
return f.user, f.err
}
func TestInvoice_BasicPlan(t *testing.T) {
svc := billing.NewService(&fakeUsers{
user: billing.User{ID: "u1", PlanSlug: "basic"},
})
inv, err := svc.Invoice(context.Background(), "u1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inv.Amount <= 0 {
t.Errorf("expected positive amount, got %d", inv.Amount)
}
}
Когда что выбирать
- Одно действие, один вызов — передавайте функцию. Интерфейс из одного метода, который используется в одном месте, — лишняя сущность.
- Два-три связанных метода — мелкий интерфейс у потребителя. Это основной путь для бизнес-логики.
- Инициализация приложения, DI-контейнер — встраивание мелких интерфейсов в составной. Помогает собирать зависимости в
main, не протаскивая десять аргументов. - Публичная библиотека, которую вы не контролируете — допустим большой интерфейс на стороне реализации, потому что у потребителей разные наборы нужных методов и вы не знаете их заранее. Но даже здесь предпочтительны несколько мелких.
Что ломается: типичные ошибки
Интерфейс «на вырост»
Разработчик объявляет интерфейс с шестью методами, хотя сейчас вызывает два. «Потом пригодится». В Go интерфейсы удовлетворяются неявно — расширить интерфейс потребителя можно в любой момент, не трогая реализацию. Объявлять заранее нет причин, а цена — раздутые моки и ложное чувство стабильности контракта.
Интерфейс ради тестируемости — но в пакете реализации
Создают интерфейс storage.Repo и мокают его в тестах handler. Пакет handler теперь импортирует storage ради типа, а не ради логики. Если Repo объявлен в handler, импорта нет — зависимость разорвана.
Утечка реализации через возвращаемый тип
Интерфейс чист, но метод возвращает *pgx.Rows — конкретный тип из драйвера Postgres. Потребитель снова привязан к реализации. Возвращайте доменные структуры или примитивы.
Пустой интерфейс вместо проектирования
interface{} (он же any с Go 1.18) — капитуляция перед типизацией. Допустим в обобщённом коде логирования или сериализации, но в бизнес-логике превращает ошибки компиляции в панику времени выполнения.
Сноска. Неявное удовлетворение интерфейса (implicit satisfaction) — механизм, при котором тип реализует интерфейс автоматически, если у него есть все нужные методы с правильными сигнатурами. Ключевого слова implements в Go нет. Это фундаментальное отличие от Java и C#, которое делает возможным объявление интерфейсов на стороне потребителя без изменения реализации.
Что делать дальше
- Откройте любой пакет вашего проекта с интерфейсом. Проверьте: он объявлен в пакете, который вызывает методы, или в пакете, который их реализует? Если второе — попробуйте перенести.
- Посчитайте методы в самом большом интерфейсе проекта. Если больше пяти — разбейте на несколько и встраивайте там, где нужен полный набор.
- Измерьте стоимость моков: сколько методов мока в среднем тесте реально вызывается, а сколько висит заглушкой с
panic("not implemented"). Каждая такая заглушка — сигнал, что интерфейс шире, чем нужно. - Прочитайте раздел «Interfaces» в Effective Go — актуальная версия живёт в официальной документации Go на
go.dev/doc/effective_go.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Effective Go — Interfaces (официальная документация), Go Code Review Comments — Interfaces (GitHub wiki)
Частые вопросы
Почему в Go интерфейс должен быть у потребителя, а не у реализации?
Сколько методов должно быть в интерфейсе Go?
А если у меня другая версия Go — подход работает?
Не приведёт ли множество мелких интерфейсов к дублированию?
Когда всё-таки уместен большой интерфейс на стороне реализации?
Какие подводные камни есть в продакшене с мелкими интерфейсами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.