Clean Architecture
Суть
Onion + каждый бизнес-сценарий — отдельный Use Case с явным входом и выходом. Главный закон — Dependency Rule: код снаружи зависит от кода внутри, никогда наоборот.
Задача и подход
Проблема, которую решает
В Onion есть Application Service — точка входа сценариев. Со временем он превращается в мешок методов:
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:
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 — главное правило
+---------------------------------------+
| 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 со своими инвариантами.
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.Request → CreateOrderInput. Postgres-репозиторий конвертирует *Order → INSERT 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/Output | CreateOrderInput, OrderRepo |
В оригинальной книге Мартина Use Case = Interactor (термины-синонимы). В Go комьюнити прижился UseCase или просто пакет usecase.
Input/Output DTO — зачем
Use Case принимает свой собственный тип, не HTTP-запрос:
// Плохо: 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, а не метод сервиса
Часто спорят: «Зачем структура с одним методом? Это же просто функция».
Аргументы за отдельный тип:
- Конструктор объявляет ровно те зависимости, что нужны сценарию.
CreateOrderнуженrepoиpricing,CancelOrder—repoиnotifier. Не общий god-object с 10 полями. - Тестируется изолированно. Поднимаешь моки только для нужных портов.
- Найти код легко.
CreateOrder— это и тип, и файл, и use case. Нет «где-то вOrderService.Create». - Метрики/логи/трейсы по сценариям.
metric("usecase.create_order.duration")— естественно. По методам god-object — гадание. - Decorator pattern бесплатно. Логгирование, транзакции, ретраи оборачиваются вокруг конкретного use case без затрагивания других.
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. Иначе смена транспорта = переписать сценарий.
// 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-приложение»):
internal/
controllers/
models/
routes/
middlewares/
Хорошо (кричит «я обрабатываю заказы»):
internal/
domain/
order/
customer/
usecase/
create_order.go
cancel_order.go
refund_order.go
adapter/
http/
postgres/
Когда новый разработчик открывает репозиторий — он видит, чем сервис занимается, а не на каком фреймворке написан.
Boundaries и Interface Adapters
Boundary = граница слоя, выраженная интерфейсом. На каждой границе — два DTO (вход/выход) или порт-интерфейс.
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 чаще выглядит как прагматичный микс:
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 отдаёт историю операций, админский список или отчёт, не обязательно тащить полный агрегат и доменную модель.
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 тестируется в вакууме:
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()
Вопросы со сборов
- Что такое Dependency Rule и куда направлены зависимости? — Снаружи внутрь. Внутренний слой не знает о внешнем.
- Какие слои в Clean Architecture? — Entities, Use Cases, Interface Adapters, Frameworks & Drivers.
- Чем Use Case отличается от метода сервиса? — Отдельный тип на сценарий с явными Input/Output, изолированный набор зависимостей, тестируется в вакууме.
- Зачем Input/Output DTO, если можно передать domain entity? — Изоляция домена от транспорта, возможность переиспользовать use case из gRPC/CLI, явная валидация на границе.
- Где в Clean живут интерфейсы репозиториев? — В слое use case (или в domain), там где они используются. Реализация — снаружи.
- Что такое Screaming Architecture? — Структура проекта отражает бизнес, а не фреймворк.
- Когда Clean — оверкилл? — Простой CRUD, MVP, ETL без бизнес-правил.
- Чем Clean отличается от Onion? — В Onion сценарии живут в Application Service (god-object risk). В Clean — каждый сценарий отдельный Use Case.
- Можно ли вызвать один use case из другого? — Нежелательно. Признак пропущенного domain service.
- Куда положить транзакцию? — Use case оркестрирует, но абстракция UnitOfWork/TxManager — это порт. Реализация — adapter.
Практика
Что точнее всего описывает Dependency Rule?
Что выведет этот код?
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"))
}
Реализуй UseCaseImportReview: use case может импортировать context, но не должен импортировать HTTP-фреймворк или DB-driver.