Чистая архитектура и внедрение зависимостей в Go#
Модуль: Backend · Уровень: Senior
TL;DR#
Суть чистой архитектуры сводится к одному правилу — зависимости направлены внутрь: бизнес-логика ничего не знает о базе, HTTP и брокере, а внешние слои зависят от неё. В Go это реализуется естественно, потому что интерфейс здесь объявляется на стороне потребителя: слой сценариев объявляет Repository, а пакет с PostgreSQL просто оказывается ему удовлетворяющим, ничего про него не зная. Внедрение зависимостей делается конструкторами, а не контейнером: граф собирается вручную в main (либо генерируется wire); рантайм-контейнеры вроде fx/dig переносят ошибки со стадии компиляции на стадию запуска. Главный риск — карго-культ: четыре слоя, три набора структур и мапперы между ними в CRUD-сервисе дают только накладные расходы.
Простыми словами#
Чистая архитектура — это одно правило и куча названий вокруг него. Правило: бизнес-логика не должна знать, откуда пришли данные и куда они сохраняются.
Практический смысл виден на примере. Если код расчёта скидки напрямую вызывает pgx, то протестировать его без базы нельзя, а переезд на другое хранилище означает правку логики. Если же он работает с интерфейсом OrderRepository, то в тесте подставляется мапа в памяти, а замена хранилища не трогает ни строчки правил расчёта.
В Go это получается изящнее, чем в большинстве языков, из-за одной особенности: интерфейс объявляет тот, кто им пользуется. Пакету с бизнес-логикой не нужно ничего знать про пакет с PostgreSQL — он объявляет у себя «мне нужен вот такой набор методов», а реализация из другого пакета случайно ему удовлетворяет. Никаких implements, никакой зависимости в обратную сторону.
Внедрение зависимостей звучит страшно, но означает буквально следующее: не создавай зависимости внутри, принимай их снаружи.
// было: невозможно протестировать
func NewService() *Service { return &Service{db: pgx.Connect(...)} }
// стало: подставим что угодно
func NewService(repo OrderRepository) *Service { return &Service{repo: repo} }Собирается всё это в main обычным кодом: создали пул, создали репозиторий, передали в сервис, передали в хендлер. Контейнер, который делает это «магически», в Go не является нормой.
Если ты с другого языка. Забудь про Spring и аннотации: в Go нет ни автосканирования, ни @Autowired, и граф зависимостей собирается видимым кодом. Поначалу кажется примитивным, потом выясняется, что читать такой код гораздо проще, а ошибки ловятся компилятором, а не при старте приложения.
Словарь этой статьи:
- правило зависимостей — внешние слои знают о внутренних, но не наоборот;
- порт и адаптер — интерфейс на границе логики и его конкретная реализация;
- DI (dependency injection) — передача зависимостей снаружи вместо создания внутри;
wire— генератор кода, собирающий граф зависимостей на этапе компиляции.
Теория#
Правило зависимостей#
Слои принято рисовать кольцами, но важна только стрелка зависимости — она всегда указывает внутрь:
HTTP / gRPC / CLI (доставка)
↓
Usecase / Service (сценарии)
↓
Domain / Entity (правила предметной области)
↑
Postgres / Kafka / S3 (инфраструктура)Инфраструктура находится снаружи и зависит от внутренних слоёв, а не наоборот. Достигается это инверсией: внутренний слой объявляет интерфейс, внешний его реализует.
Как это выглядит в Go#
// internal/order/service.go — слой сценариев
package order
// Интерфейс объявлен ЗДЕСЬ, у потребителя. Пакет order не импортирует postgres.
type Repository interface {
Get(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type Notifier interface {
OrderCreated(ctx context.Context, o *Order) error
}
type Service struct {
repo Repository
notifier Notifier
}
func NewService(repo Repository, n Notifier) *Service {
return &Service{repo: repo, notifier: n}
}
func (s *Service) Create(ctx context.Context, req CreateRequest) (*Order, error) {
o, err := NewOrder(req) // правила предметной области
if err != nil {
return nil, err
}
if err := s.repo.Save(ctx, o); err != nil {
return nil, fmt.Errorf("save order: %w", err)
}
if err := s.notifier.OrderCreated(ctx, o); err != nil {
// уведомление не критично: логируем, но заказ создан
slog.ErrorContext(ctx, "notify failed", "err", err)
}
return o, nil
}// internal/order/postgres.go — адаптер
package order
type PostgresRepository struct{ pool *pgxpool.Pool }
func NewPostgresRepository(p *pgxpool.Pool) *PostgresRepository { return &PostgresRepository{pool: p} }
func (r *PostgresRepository) Save(ctx context.Context, o *Order) error { /* ... */ }
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Order, error) { /* ... */ }Обрати внимание: PostgresRepository нигде не написано, что он реализует order.Repository. Он просто имеет нужные методы. Если хочется проверить соответствие на этапе компиляции, пишут строчку-утверждение:
var _ Repository = (*PostgresRepository)(nil)Интерфейсы объявляет потребитель#
Это главное отличие Go от привычного по Java подхода и одновременно самая частая ошибка новичков.
// АНТИПАТТЕРН: интерфейс рядом с реализацией
package postgres
type OrderRepository interface { ... } // зачем? реализация тут одна
type orderRepository struct { ... }Такой интерфейс никому не нужен: у него одна реализация, он повторяет её методы один в один и при добавлении метода правится в двух местах. Правильный вариант — объявить интерфейс там, где он потребляется, и включить в него только реально нужные методы.
Отсюда же вытекает правило узких интерфейсов. Если сервису нужен только Get, интерфейс должен содержать только Get, даже если репозиторий умеет двадцать операций. Тогда мок в тесте — три строки, а не двадцать заглушек.
И идиома «accept interfaces, return structs»: функции принимают интерфейсы (гибкость для вызывающего), но возвращают конкретные типы (полный API, никаких лишних абстракций).
Сборка графа в main#
func main() {
cfg := config.Load()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil { log.Fatal(err) }
defer pool.Close()
// снизу вверх: инфраструктура → адаптеры → логика → доставка
repo := order.NewPostgresRepository(pool)
notifier := notify.NewKafkaNotifier(producer)
svc := order.NewService(repo, notifier)
handler := httpapi.NewOrderHandler(svc)
srv := &http.Server{Addr: cfg.Addr, Handler: handler.Routes()}
// ... graceful shutdown
}Это и есть внедрение зависимостей в Go. Никакого контейнера: обычный код, который читается сверху вниз и проверяется компилятором. Пока граф умещается в экран-два, это лучший вариант из существующих.
Когда граф вырос: wire против fx#
Когда зависимостей становится несколько десятков, ручная сборка превращается в длинную функцию. Два подхода:
google/wire — кодогенерация. Ты описываешь, из чего что собирается, а wire генерирует тот же самый ручной код, который ты написал бы сам. Ошибки (забытая зависимость, несовпадение типов) обнаруживаются при генерации, то есть до запуска. Рантайм-магии ноль.
uber-go/fx / dig — контейнер на рефлексии. Граф собирается при старте приложения, зависимости ищутся по типам. Гибче, есть жизненный цикл компонентов, но ошибка конфигурации проявляется при запуске, а не при компиляции, а трассировки становятся менее читаемыми.
Для типичного Go-сервиса разумный порядок: сначала руками, при росте — wire. fx оправдан там, где действительно много модулей с общим жизненным циклом, и команда сознательно готова платить рантайм-проверками за динамичность.
Модель предметной области и слои данных#
Строгий вариант чистой архитектуры предполагает отдельные структуры на каждом слое: DTO запроса, доменная сущность, модель базы. Между ними — мапперы.
Честная оценка: для сложной логики это оправдано (домен не тащит на себе теги json и db, изменение схемы базы не меняет бизнес-правила). Для CRUD-сервиса три идентичные структуры и два маппера — это чистые накладные расходы и код, который все ненавидят поддерживать.
Разумный компромисс, который часто встречается в живых проектах: отдельная структура для внешнего контракта (её нельзя менять свободно, она часть API) и одна общая для домена и хранилища, пока их требования не разойдутся.
Как это упрощает тесты#
type fakeRepo struct {
orders map[string]*Order
saveErr error
}
func (f *fakeRepo) Save(_ context.Context, o *Order) error {
if f.saveErr != nil { return f.saveErr }
f.orders[o.ID] = o
return nil
}
func (f *fakeRepo) Get(_ context.Context, id string) (*Order, error) { ... }
func TestService_Create(t *testing.T) {
svc := NewService(&fakeRepo{orders: map[string]*Order{}}, &noopNotifier{})
got, err := svc.Create(context.Background(), CreateRequest{ /* ... */ })
// ни базы, ни докера, тест выполняется за микросекунды
}Это и есть практическая окупаемость архитектуры: логика тестируется без инфраструктуры, а инфраструктура проверяется отдельно интеграционными тестами против настоящей базы.
Когда всё это не нужно#
Честный список ситуаций, где слои только мешают:
- сервис-прокси, который принимает запрос, перекладывает поля и отправляет дальше;
- CRUD без правил: логика исчерпывается «сохранить и отдать»;
- прототип с неизвестным будущим;
- утилита или разовый обработчик данных.
В этих случаях один-два пакета и прямой вызов базы из хендлера — не «плохая архитектура», а адекватное решение. Признак, по которому пора вводить слои: бизнес-правила стало трудно тестировать из-за инфраструктуры.
Подводные камни / gotchas#
- Интерфейс рядом с реализацией. Классический перенос привычек из Java; в Go интерфейс объявляет потребитель.
- Широкие интерфейсы. Интерфейс на двадцать методов ради одного используемого превращает любой тест в гору заглушек.
- Слои ради слоёв. Четыре каталога и три маппера в сервисе на пятьсот строк — минус к читаемости без единого плюса.
- Домен, знающий про инфраструктуру. Теги
jsonиdbна доменной сущности,pgx.Rowsв сигнатуре сценария — правило зависимостей нарушено. - Контейнер вместо конструкторов. Рантайм-DI переносит ошибки со стадии компиляции на стадию запуска; в Go это осознанный размен, а не бесплатное удобство.
context.Contextкак контейнер зависимостей. Класть в контекст репозиторий или логгер — антипаттерн: контекст для данных запроса и сигнала отмены.- Циклический импорт между слоями. Верный признак, что интерфейс объявлен не там, где нужно.
- Утечка деталей через ошибки. Если наружу летит
pgx.ErrNoRows, абстракция дырявая: сценарий должен возвращатьErrOrderNotFound.
Вопросы на собеседовании#
В: В чём суть чистой архитектуры одним предложением? О: Зависимости направлены внутрь: бизнес-логика не знает об инфраструктуре, а инфраструктура зависит от абстракций, объявленных бизнес-логикой.
В: Почему в Go интерфейс объявляют у потребителя? О: Потому что удовлетворение интерфейса структурное: типу не нужно объявлять, что он что-то реализует. Это позволяет объявить интерфейс там, где он нужен, включить в него ровно необходимые методы и не создавать зависимость от пакета с реализацией.
В: Как в Go делают внедрение зависимостей?
О: Конструкторами: зависимости передаются параметрами NewX(...), а граф собирается в main. При росте графа используют wire — генератор, который создаёт тот же код на этапе компиляции.
В: Чем wire отличается от fx?
О: wire генерирует код до сборки, поэтому ошибки видит компилятор и рантайм-магии нет. fx собирает граф при старте через рефлексию — гибче и с управлением жизненным циклом, но ошибки конфигурации проявляются при запуске.
В: Нужны ли отдельные структуры для домена, базы и API? О: Зависит от сложности. Для нетривиальной предметной области — да, это защищает правила от изменений схемы и контракта. Для CRUD три одинаковые структуры и мапперы между ними приносят только накладные расходы; выделять стоит как минимум внешний контракт, потому что он не может меняться свободно.
В: Как понять, что абстракция дырявая?
О: По утечке деталей реализации наружу: тип из драйвера в сигнатуре сценария, pgx.ErrNoRows в обработке ошибок выше слоя репозитория, SQL в бизнес-логике.
В: Когда чистая архитектура не нужна? О: Когда бизнес-логики фактически нет: прокси, CRUD, прототип, утилита. Признак, что пора вводить слои, — сложность тестирования правил из-за инфраструктуры.
На что копают на senior+#
- Умение объяснить правило зависимостей и показать его на конкретном коде, а не пересказать картинку с кольцами.
- Понимание, почему структурная типизация Go делает инверсию зависимостей естественной, и почему интерфейс у реализации — антипаттерн.
- Способность назвать цену архитектуры и признать случаи, где она не окупается. Ответ «всегда делаю чистую архитектуру» слабее, чем обоснованный выбор.
- Практика тестирования: что покрывается быстрыми тестами на фейках, а что — интеграционными.
- Follow-up: «Где у вас проходят границы транзакции при таких слоях?» (обычно в сценарии, через передачу
Txили паттерн Unit of Work), «Как передаёте зависимости в фоновые воркеры?», «Почему не кладёте логгер в контекст?».