Содержание

Интерфейсы и архитектурные паттерны в 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-подход часто звучит так: принимаем интерфейсы, возвращаем конкретные типы. Функция должна просить только то поведение, которое ей нужно.

Типичные ошибки

  1. Интерфейс создается рядом с реализацией и копирует все ее методы.
  2. В проекте появляется пакет interfaces без ясной причины.
  3. Service layer превращается в прокладку без логики.
  4. Repository содержит бизнес-правила.
  5. Handler напрямую пишет SQL.
  6. Clean architecture применяется к проекту из трех файлов.
  7. Абстракции появляются раньше, чем проблема.

Чеклист

  1. Интерфейс маленький.
  2. Интерфейс расположен рядом с потребителем.
  3. Handler не содержит бизнес-правила.
  4. Repository не принимает HTTP-типы.
  5. Service можно протестировать без реальной БД.
  6. Внешние API спрятаны за клиентом или интерфейсом.
  7. Абстракция нужна сейчас, а не гипотетически.

Следующий шаг после статьи

Закрепите тему в реальном проекте и продолжайте обучение на курсах Praxis.

Продолжить изучение

Выбери следующую статью по маршруту или углубись в смежную тему.

Похожие статьи