Интерфейсы в Go: маленькие vs большие, accept interfaces, return structs
Когда я пришёл в Go из Java, первой реакцией на интерфейсы было «где аннотации implements». Через пару проектов привык к утиной типизации, но потом долго не мог определиться: делать интерфейсы маленькими или собирать всё в большой UserRepository. Сейчас, после девяти лет, ответ для меня устоялся, но видно, что в команде у каждого свой подход.
Рассмотрю, чем отличаются разные стили описания интерфейсов в Go, почему «accept interfaces, return structs» — это не догма, и как обычно получается компромисс на практике.
Маленькие интерфейсы: один-два метода
Самые известные примеры — io.Reader, io.Writer, fmt.Stringer. По одному методу, и весь stdlib строится вокруг них.
type Reader interface {
Read(p []byte) (n int, err error)
}
Удобство: любой тип, у которого есть Read([]byte) (int, error), автоматически удовлетворяет интерфейсу. Можно писать функции вроде io.Copy(dst io.Writer, src io.Reader), и они работают с файлами, сетевыми соединениями, gzip-стримами, вообще со всем.
Плюсы:
- Легко тестировать — мокается одной функцией.
- Композируется.
io.ReadCloser=Reader + Closer. - Не зависит от деталей реализации. Реализатор не знает про интерфейс.
Большие интерфейсы: как у бизнес-репозиториев
В реальных сервисах часто видишь такое:
type UserRepository interface {
Create(ctx context.Context, u User) error
Get(ctx context.Context, id string) (User, error)
Update(ctx context.Context, u User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter UserFilter) ([]User, error)
CountByStatus(ctx context.Context, s Status) (int, error)
}
Это нормально, и не надо за это ругать архитектора. Большой интерфейс — это явный контракт сервисного слоя с хранилищем. Тут другая логика: интерфейс описывает не «способность Read», а «всю поверхность работы с пользователями».
Минусы:
- Мокать дорого — нужно подделывать все методы, даже если тест использует один.
- При добавлении метода обновляешь все моки.
- Часть методов добавляется «на будущее» и никогда не используется.
Решение мокинга — использовать moq, mockery, или генерить моки автоматически. Это не идеально, но снимает большую часть боли.
«Accept interfaces, return structs»
Эта фраза часто приводится без объяснений. Что она реально значит:
// плохо
func NewService(db *postgres.Client) *Service
// хорошо
func NewService(db UserStore) *Service
func (s *Service) GetUser(ctx context.Context, id string) (*User, error)
Принимаем UserStore — узкий интерфейс с теми методами, что реально нужны сервису. Возвращаем конкретный *Service, без интерфейса.
Зачем так:
- Принимая интерфейс, мы делаем сервис тестируемым: подсунем мок.
- Возвращая структуру, не плодим лишние интерфейсы. Если кто-то захочет интерфейс — сделает его на своей стороне с теми методами, которые ему нужны.
Это работает потому, что в Go интерфейсы implicit: тебе не надо менять код реализации, чтобы он соответствовал новому интерфейсу.
Где правило не работает
Есть случаи, когда возврат интерфейса оправдан.
Фабрики разных реализаций
type Cache interface {
Get(key string) ([]byte, bool)
Set(key string, val []byte, ttl time.Duration)
}
func NewCache(cfg CacheConfig) Cache {
switch cfg.Type {
case "redis":
return newRedisCache(cfg)
case "memory":
return newMemoryCache(cfg)
}
return nil
}
Тут нет одной конкретной структуры — есть несколько реализаций под общий контракт. Возвращать что-то одно бессмысленно.
Возврат через pkg-private реализацию
// внутри пакета
type tokenStore struct { /* ... */ }
// наружу даём только интерфейс
type TokenStore interface {
Issue(...) (Token, error)
Revoke(...) error
}
func NewTokenStore() TokenStore {
return &tokenStore{}
}
Иногда хочется, чтобы наружу пакета не утекали внутренние поля. Тогда возвращаем интерфейс, а реализация остаётся private. На большом коде это даёт чистый API пакета.
Куда положить определение интерфейса
Самый частый спор в команде. Два варианта:
В пакете-провайдере
// repository/users.go
type Store interface { /* ... */ }
type postgresStore struct { /* ... */ }
func New(db *pgx.Pool) Store { /* ... */ }
Интерфейс рядом с реализацией. Просто, но не очень идиоматично для Go.
В пакете-потребителе
// service/users.go
type userStore interface {
Get(ctx context.Context, id string) (User, error)
Save(ctx context.Context, u User) error
}
type Service struct {
store userStore
}
Это «accept interfaces» в чистом виде. Сервис описывает, что ему нужно — узкий интерфейс. Реализация в другом пакете не знает про этот интерфейс. Когда метод в реализации меняется, проверка на соответствие случается при инъекции в NewService.
Я почти всегда выбираю второй вариант. Минус — если интерфейс используется в трёх сервисах, его описание дублируется. Но дубликаты эти короткие, и каждый сервис описывает только нужные ему методы.
Эмбеддинг интерфейсов
type ReadWriter interface {
io.Reader
io.Writer
}
Это удобно для составления контрактов. Используется в stdlib (io.ReadCloser, io.ReadWriteCloser). В пользовательском коде применяю осторожно: иногда хочется записать «X = A + B», но потом X начинает жить своей жизнью, а композиция остаётся неявной.
Дженерики и интерфейсы
С Go 1.18 у нас есть type parameters. Часть случаев, где раньше бы написали интерфейс с одним методом, можно теперь решить дженериками:
// Раньше
type Less interface {
Less(other any) bool
}
func Min(a, b Less) Less { /* ... */ }
// Сейчас
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
Где интерфейс работает со значениями разных типов через type assertion и reflection — там дженерики чище. Но интерфейсы по-прежнему нужны для описания поведения, а не структуры данных.
Антипаттерны, которые до сих пор встречаются
Интерфейс ради интерфейса
type UserService interface {
CreateUser(ctx context.Context, u User) error
}
type userService struct { /* ... */ }
func (s *userService) CreateUser(ctx context.Context, u User) error { /* ... */ }
Интерфейс с одной реализацией, который никем не подменяется. Если завтра появится вторая реализация — добавишь интерфейс тогда. Сейчас он просто шум.
Интерфейсы из чужого пакета
// my_service/db.go
type DBClient = postgres.Client // type alias
Иногда вижу: вместо своего интерфейса делают alias на конкретный тип из библиотеки. Тестируемость пропадает, абстракция нулевая. Если уж делаешь свой alias — пусть это будет интерфейс.
Слишком абстрактные имена
Manager, Service, Handler, Provider — самые бесполезные имена интерфейсов. По ним нельзя понять, что внутри. Лучше UserStore, PaymentRouter, TokenIssuer. Имя интерфейса должно намекать на конкретный контракт.
Что в итоге
Несколько правил, которыми руководствуюсь сам:
- Маленькие интерфейсы — лучше. Если не получается, делай большой, но осознанно.
- Описывай интерфейс там, где он используется, а не там, где реализуется.
- Возвращай конкретный тип, если нет нескольких реализаций.
- Не делай интерфейс, пока не видишь второй реализации или не нужно для тестов.
- Не плоди абстрактных названий:
Manager— плохо,UserCounter— хорошо.
Со временем интерфейсы перестают быть инструментом «про ООП» и становятся способом описать минимальный контракт между двумя кусками кода. Чем меньше этот контракт — тем легче с ним жить, проще тестировать и проще менять обе стороны независимо.