Чистая архитектура и внедрение зависимостей в 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), «Как передаёте зависимости в фоновые воркеры?», «Почему не кладёте логгер в контекст?».