Интерфейсы и архитектурные паттерны в Go
Интерфейсы в Go помогают разделять части программы по поведению. Они особенно полезны, когда бизнес-логика не должна знать, где именно лежат данные: в PostgreSQL, Redis, файле, памяти или внешнем API.
Но интерфейсы легко превратить в лишнюю сложность. Если в проекте на три файла появляется десять интерфейсов, код становится не гибким, а мутным. В Go хорошая абстракция обычно маленькая, конкретная и появляется там, где уже есть реальная причина.
Интерфейс описывает поведение
Интерфейс говорит не "что это за объект", а "что он умеет делать".
type Notifier interface { Send(ctx context.Context, message Message) error }
Этому интерфейсу не важно, как именно отправляется сообщение: через Telegram, email, mock в тесте или запись в файл. Важен контракт: можно отправить сообщение и получить ошибку.
Это похоже на розетку. Устройство не знает, какая электростанция за стеной. Оно знает форму разъема и напряжение.
Не нужно писать implements
В Go тип реализует интерфейс неявно. Если у типа есть нужные методы, он подходит.
type EmailNotifier struct{} func (n EmailNotifier) Send(ctx context.Context, message Message) error { return nil }
EmailNotifier реализует Notifier, хотя мы нигде не написали implements Notifier.
Это удобно, но требует дисциплины: интерфейс должен быть маленьким и понятным.
Определяйте интерфейс там, где используете
В Go часто лучше держать интерфейс на стороне потребителя:
type UserService struct { users UserRepository } type UserRepository interface { FindByID(ctx context.Context, id string) (*User, error) }
Так интерфейс содержит только методы, которые реально нужны сервису.
Плохой вариант - создать огромный интерфейс рядом с реализацией:
type PostgresUserRepository interface { FindByID(...) FindByEmail(...) Create(...) Update(...) Delete(...) List(...) Count(...) }
Если потребителю нужен только FindByID, он не должен зависеть от всего набора методов.
Dependency inversion
Высокоуровневая логика не должна зависеть от деталей:
service -> interface <- postgres repository
Сервис знает контракт, но не знает SQL. PostgreSQL-реализация знает SQL, но не содержит бизнес-правила.
Это делает код тестируемым:
type fakeUsers struct { user *User } func (f fakeUsers) FindByID(ctx context.Context, id string) (*User, error) { return f.user, nil }
Теперь тест сервиса не требует реальной базы данных.
Service layer
Service layer хранит бизнес-логику. Handler работает с HTTP, repository работает с БД, а service принимает решение.
type ReviewService struct { reviews ReviewRepository books BookRepository } func (s *ReviewService) Create(ctx context.Context, input CreateReviewInput) error { book, err := s.books.FindByID(ctx, input.BookID) if err != nil { return err } if book == nil { return ErrBookNotFound } if input.Rating < 1 || input.Rating > 5 { return ErrInvalidRating } return s.reviews.Create(ctx, input.ToReview()) }
Handler не должен решать, можно ли поставить рейтинг 6. Он должен прочитать JSON, вызвать service и вернуть HTTP-ответ.
Repository
Repository изолирует доступ к данным:
type BookRepository interface { FindByID(ctx context.Context, id string) (*Book, error) List(ctx context.Context, limit int) ([]Book, error) Create(ctx context.Context, book Book) error }
PostgreSQL-реализация может использовать SQL, а in-memory реализация - map для тестов.
Не добавляйте generic методы вроде Save(any) и FindAll. Такие методы быстро теряют смысл и усложняют тесты.
Package boundaries
Границы пакетов должны отражать смысл, а не только технический слой.
Пример для backend-сервиса:
internal/ ├── domain/ ├── service/ ├── repository/ ├── handler/ └── config/
Это нормальная структура, если проект уже имеет HTTP, базу, бизнес-правила и конфигурацию.
Для маленького CLI такая архитектура может быть избыточной. Начните проще и выделяйте пакеты, когда появляется реальная граница ответственности.
Clean Architecture без фанатизма
Идея clean architecture проста: бизнес-правила не зависят от HTTP, SQL, очередей и внешних API.
Это не значит, что каждый проект должен иметь десять слоев. Практический смысл такой:
- domain не импортирует
net/http; - service не знает деталей SQL;
- handler не содержит бизнес-правила;
- repository не принимает HTTP request;
- внешний API спрятан за небольшим клиентом или интерфейсом.
Когда интерфейс не нужен
Интерфейс не нужен, если:
- есть только одна реализация;
- вы не тестируете потребителя отдельно;
- тип маленький и не создает зависимости на инфраструктуру;
- интерфейс просто копирует все методы struct;
- вы добавляете его "на будущее".
Go-подход часто звучит так: принимаем интерфейсы, возвращаем конкретные типы. Функция должна просить только то поведение, которое ей нужно.
Типичные ошибки
- Интерфейс создается рядом с реализацией и копирует все ее методы.
- В проекте появляется пакет
interfacesбез ясной причины. - Service layer превращается в прокладку без логики.
- Repository содержит бизнес-правила.
- Handler напрямую пишет SQL.
- Clean architecture применяется к проекту из трех файлов.
- Абстракции появляются раньше, чем проблема.
Чеклист
- Интерфейс маленький.
- Интерфейс расположен рядом с потребителем.
- Handler не содержит бизнес-правила.
- Repository не принимает HTTP-типы.
- Service можно протестировать без реальной БД.
- Внешние API спрятаны за клиентом или интерфейсом.
- Абстракция нужна сейчас, а не гипотетически.