Clean Architecture

Суть

Onion + каждый бизнес-сценарий — отдельный Use Case с явным входом и выходом. Главный закон — Dependency Rule: код снаружи зависит от кода внутри, никогда наоборот.

Задача и подход

Проблема, которую решает

В Onion есть Application Service — точка входа сценариев. Со временем он превращается в мешок методов:

go
type OrderService struct { repo OrderRepo; mailer Mailer; payments PaymentGateway } func (s *OrderService) Create(...) {} func (s *OrderService) Cancel(...) {} func (s *OrderService) Refund(...) {} func (s *OrderService) Ship(...) {} func (s *OrderService) Reopen(...) {} // ... и так до 30 методов и 12 зависимостей в конструкторе

Симптомы god-object:

  • В конструкторе 10+ зависимостей, хотя каждый метод использует 2–3
  • Любое изменение требует тащить весь сервис в тест
  • Невозможно понять «что вообще делает этот класс»
  • Merge-конфликты — все правят один файл

Решение: Use Case как первый класс

Каждый бизнес-сценарий — отдельная структура с одной публичной точкой входа Execute:

go
type CreateOrderInput struct { UserID string Items []Item } type CreateOrderOutput struct { OrderID string CreatedAt time.Time } type CreateOrder struct { repo OrderRepo pricing PricingService clock Clock } func (uc *CreateOrder) Execute(ctx context.Context, in CreateOrderInput) (CreateOrderOutput, error) { order, err := domain.NewOrder(in.UserID, in.Items) if err != nil { return CreateOrderOutput{}, err } order.SetPrice(uc.pricing.Calc(order)) if err := uc.repo.Save(ctx, order); err != nil { return CreateOrderOutput{}, err } return CreateOrderOutput{OrderID: order.ID(), CreatedAt: uc.clock.Now()}, nil }

Один файл = один сценарий = один Use Case = одна точка ответственности.

Dependency Rule — главное правило

text
+---------------------------------------+ | Frameworks & Drivers (HTTP, DB, MQ) | | +-------------------------------+ | | | Interface Adapters | | | | (handlers, repo impls) | | | | +-----------------------+ | | | | | Use Cases | | | | | | +---------------+ | | | | | | | Entities | | | | | | | +---------------+ | | | | | +-----------------------+ | | | +-------------------------------+ | +---------------------------------------+ Зависимости направлены ТОЛЬКО внутрь: ──→

Формулировка от Мартина: «Source code dependencies must point only inward, toward higher-level policies». Внутренний слой ничего не знает о внешнем. Если домену нужен внешний мир — он объявляет интерфейс, а внешний слой его реализует.

Слои, роли и границы

Четыре слоя

Entities

Корпоративные правила, существующие и без приложения. Order, Money, Customer со своими инвариантами.

go
type Order struct { id string items []Item status Status } func NewOrder(userID string, items []Item) (*Order, error) { if len(items) == 0 { return nil, ErrEmptyOrder } return &Order{id: uuid.New().String(), items: items, status: StatusDraft}, nil } func (o *Order) Cancel() error { if o.status == StatusShipped { return ErrAlreadyShipped } o.status = StatusCancelled return nil }

Никаких импортов кроме stdlib. Никаких ORM-тегов, JSON-тегов, БД.

Use Cases (Application Business Rules)

Сценарий приложения. Знает про Entities, оркестрирует их и зовёт порты (интерфейсы инфраструктуры).

Interface Adapters

Переводчики. HTTP-handler конвертирует *http.RequestCreateOrderInput. Postgres-репозиторий конвертирует *OrderINSERT INTO orders.

Frameworks & Drivers

Echo, Gin, pgx, Kafka, Redis. Самый внешний и самый «грязный» слой. Меняется чаще всего, поэтому он снаружи.

Entity vs Use Case vs Interactor

ПонятиеЧто этоПример
EntityОбъект с инвариантами, живёт без приложенияOrder с правилом «нельзя отменить отгруженный»
Use Case (Interactor)Сценарий приложения, оркестрация Entity и портовCreateOrder.Execute
BoundaryИнтерфейс на границе слоя — Input/OutputCreateOrderInput, OrderRepo

В оригинальной книге Мартина Use Case = Interactor (термины-синонимы). В Go комьюнити прижился UseCase или просто пакет usecase.

Input/Output DTO — зачем

Use Case принимает свой собственный тип, не HTTP-запрос:

go
// Плохо: usecase зависит от echo func (uc *CreateOrder) Execute(c echo.Context) error { ... } // Плохо: usecase принимает domain entity (теряем валидацию входа) func (uc *CreateOrder) Execute(ctx context.Context, order *domain.Order) error { ... } // Хорошо: явный input DTO func (uc *CreateOrder) Execute(ctx context.Context, in CreateOrderInput) (CreateOrderOutput, error) { ... }

Что даёт явный DTO:

  • Use Case вызывают и из HTTP, и из gRPC, и из CLI — без переписывания
  • Входная валидация на границе очевидна
  • Тесты пишутся без mock-ов фреймворка
  • Domain entity не «протекает» наружу с её приватными полями

Почему отдельный Use Case, а не метод сервиса

Часто спорят: «Зачем структура с одним методом? Это же просто функция».

Аргументы за отдельный тип:

  1. Конструктор объявляет ровно те зависимости, что нужны сценарию. CreateOrder нужен repo и pricing, CancelOrderrepo и notifier. Не общий god-object с 10 полями.
  2. Тестируется изолированно. Поднимаешь моки только для нужных портов.
  3. Найти код легко. CreateOrder — это и тип, и файл, и use case. Нет «где-то в OrderService.Create».
  4. Метрики/логи/трейсы по сценариям. metric("usecase.create_order.duration") — естественно. По методам god-object — гадание.
  5. Decorator pattern бесплатно. Логгирование, транзакции, ретраи оборачиваются вокруг конкретного use case без затрагивания других.
go
type WithTx struct { uc CreateOrder; db *sql.DB } func (w *WithTx) Execute(ctx context.Context, in CreateOrderInput) (CreateOrderOutput, error) { return runInTx(w.db, func(tx *sql.Tx) (CreateOrderOutput, error) { return w.uc.Execute(injectTx(ctx, tx), in) }) }

Минус — больше файлов. Это нормальная плата за изоляцию.

Frameworks & Drivers — как не зависеть от Echo

Use Case не должен импортировать echo или net/http. Иначе смена транспорта = переписать сценарий.

go
// internal/adapter/handler/http/order.go type OrderHandler struct { create *usecase.CreateOrder } func (h *OrderHandler) Create(c echo.Context) error { var req CreateOrderRequest if err := c.Bind(&req); err != nil { return c.JSON(400, ErrorResp{"invalid body"}) } out, err := h.create.Execute(c.Request().Context(), CreateOrderInput{ UserID: req.UserID, Items: mapItems(req.Items), }) if err != nil { return mapError(c, err) } return c.JSON(201, OrderResp{ID: out.OrderID}) }

Завтра gRPC — пишется новый handler, use case не трогается.

Screaming Architecture

Структура проекта должна «кричать» о бизнесе, а не о фреймворке.

Плохо (кричит «я Echo-приложение»):

text
internal/ controllers/ models/ routes/ middlewares/

Хорошо (кричит «я обрабатываю заказы»):

text
internal/ domain/ order/ customer/ usecase/ create_order.go cancel_order.go refund_order.go adapter/ http/ postgres/

Когда новый разработчик открывает репозиторий — он видит, чем сервис занимается, а не на каком фреймворке написан.

Boundaries и Interface Adapters

Boundary = граница слоя, выраженная интерфейсом. На каждой границе — два DTO (вход/выход) или порт-интерфейс.

text
HTTP Request ─→ [Handler] ─→ Input DTO ─→ [UseCase] ─→ Output DTO ─→ [Handler] ─→ HTTP Response │ ↓ (через port-interface) [Repository Adapter] ─→ SQL

Каждая стрелка пересекает boundary с маппингом. Маппинг — цена изоляции.

Clean без фанатизма

Clean Architecture в Go работает, когда она уменьшает стоимость изменений, а не когда заставляет на каждый чих заводить InteractorFactoryBuilder. Практичный критерий:

  • если сценарий содержит бизнесовое решение, транзакцию, несколько зависимостей или важный error contract — делайте отдельный use case;
  • если это один CRUD-endpoint без правил — не обязательно размазывать его на 8 файлов;
  • если интерфейс нужен только одной реализации и не помогает тесту — возможно, он лишний;
  • если use case начал знать про pgx, echo.Context, Kafka topic или Redis key format — граница уже сломана.

В продакшене Clean чаще выглядит как прагматичный микс:

text
internal/domain # entities, value objects, domain services internal/usecase # application scenarios + ports they consume internal/adapter # postgres, redis, providers, broker, http/grpc mapping internal/platform # logger, metrics, config, tracing helpers cmd/server # composition root

platform не является «магическим слоем, который можно импортировать откуда угодно». Domain всё ещё не должен зависеть от logger/tracer/config. Use case может принимать интерфейсы для clock, transaction runner или event sink, но не должен знать формат Prometheus labels или Kafka headers.

Read side и query models

Clean не запрещает отдельные модели чтения. Если API отдаёт историю операций, админский список или отчёт, не обязательно тащить полный агрегат и доменную модель.

go
type ConversionHistoryReader interface { ListForClient(ctx context.Context, clientID string, limit int) ([]ConversionHistoryRow, error) }

Это read-side port. Он не обязан возвращать aggregate root, если сценарий ничего не меняет и не проверяет инварианты. Главное — честно назвать это query model и не использовать её для записи бизнесовых изменений.

Observability placement

Observability живёт на границах:

  • middleware добавляет request id, trace id, latency, status;
  • adapter измеряет latency/errors внешней зависимости;
  • use case может логировать бизнесовый outcome через абстракцию, если это действительно часть application flow;
  • domain entity не импортирует logger, tracer и metrics.

Тестирование

Каждый use case тестируется в вакууме:

go
func TestCreateOrder_OK(t *testing.T) { repo := &mockOrderRepo{} pricing := &stubPricing{price: money.From(100)} uc := &CreateOrder{repo: repo, pricing: pricing, clock: stubClock{}} out, err := uc.Execute(ctx, CreateOrderInput{ UserID: "u-1", Items: []Item{{SKU: "A", Qty: 2}}, }) require.NoError(t, err) require.NotEmpty(t, out.OrderID) require.Len(t, repo.saved, 1) require.Equal(t, money.From(100), repo.saved[0].TotalPrice()) }

Без httptest, без pgxmock, без поднятой инфраструктуры. Только бизнес-правило.

Когда применять

  • Сложная доменная логика, много сценариев и инвариантов
  • Несколько транспортов (HTTP + gRPC + CLI + cron)
  • Долгоживущий сервис, который будут переписывать частями
  • Команда 5+ человек — слои дают чёткие границы ответственности

Когда НЕ применять

  • CRUD-микросервис на 5 эндпоинтов — 90% кода будет маппинг
  • Прототип, MVP — Clean заморозит вас в маппингах вместо проверки гипотезы
  • Один разработчик на проекте — выгода от чётких границ ниже накладных
  • ETL/скрипт — нет «бизнес-правил», есть pipeline

Подводные камни

  • Анемичный домен. Entity без поведения, всё в use case → получили обычный «service + struct», а не Clean.
  • Use case вызывает use case. Признак, что один из них — на самом деле domain service. Выноси общую логику в домен.
  • Repository возвращает DTO с JSON-тегами. Протечка. Repository должен возвращать domain entity.
  • Domain импортирует database/sql. Сразу красная тряпка. Domain — только stdlib + другие пакеты domain.
  • Один Input DTO на 5 полей-флагов. Use case делает несколько вещей. Разбивай.
  • Logger в domain. fmt/log/zap в Entity = боль. Логгирование — забота use case или handler.
  • interface{}/any в Input/Output. Уничтожает смысл boundary. Всегда конкретные типы.

Чек-лист ревью

  • Domain не импортирует ничего, кроме stdlib и других пакетов domain
  • Use Case не импортирует net/http, echo, gin, pgx
  • У каждого use case свой Input/Output (или error без output)
  • Один use case = один публичный метод Execute
  • Интерфейсы (порты) объявлены рядом с use case, а не рядом с реализацией
  • Handler не содержит бизнес-логики (только маппинг + вызов use case)
  • Repository возвращает domain entity, а не raw row
  • DI собирается в cmd/server/main.go, а не разбросана по init()

Вопросы со сборов

  1. Что такое Dependency Rule и куда направлены зависимости? — Снаружи внутрь. Внутренний слой не знает о внешнем.
  2. Какие слои в Clean Architecture? — Entities, Use Cases, Interface Adapters, Frameworks & Drivers.
  3. Чем Use Case отличается от метода сервиса? — Отдельный тип на сценарий с явными Input/Output, изолированный набор зависимостей, тестируется в вакууме.
  4. Зачем Input/Output DTO, если можно передать domain entity? — Изоляция домена от транспорта, возможность переиспользовать use case из gRPC/CLI, явная валидация на границе.
  5. Где в Clean живут интерфейсы репозиториев? — В слое use case (или в domain), там где они используются. Реализация — снаружи.
  6. Что такое Screaming Architecture? — Структура проекта отражает бизнес, а не фреймворк.
  7. Когда Clean — оверкилл? — Простой CRUD, MVP, ETL без бизнес-правил.
  8. Чем Clean отличается от Onion? — В Onion сценарии живут в Application Service (god-object risk). В Clean — каждый сценарий отдельный Use Case.
  9. Можно ли вызвать один use case из другого? — Нежелательно. Признак пропущенного domain service.
  10. Куда положить транзакцию? — Use case оркестрирует, но абстракция UnitOfWork/TxManager — это порт. Реализация — adapter.

Практика

Quiz+10 XP

Что точнее всего описывает Dependency Rule?

  • Код снаружи может зависеть от кода внутри, но не наоборот
  • Любой слой может импортировать любой другой, если есть интерфейс
  • Use case должен импортировать HTTP handler
  • Entity должна знать, как сохранить себя в PostgreSQL
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func layer(name string) string { switch name { case "Order": return "entity" case "CreateOrder": return "usecase" default: return "adapter" } } func main() { fmt.Println(layer("Order")) fmt.Println(layer("CreateOrder")) fmt.Println(layer("EchoHandler")) }
Задача+20 XP

Реализуй UseCaseImportReview: use case может импортировать context, но не должен импортировать HTTP-фреймворк или DB-driver.