14. Clean Architecture и Dependency Rule
После знакомства с концепцией портов и адаптеров в Hexagonal Architecture (13. Hexagonal Architecture. Ports and Adapters) мы переходим к родственной, но обладающей более строгим математическим порядком методологии — Clean Architecture («Чистая архитектура»), сформулированной Робертом Мартином (Дядюшкой Бобом) в 2012 году.
Если Кокбёрн в своей гексагональной метафоре уделил основное внимание симметрии входов и выходов, то Мартин пошёл глубже: он препарировал само доменное ядро, выделив в нём концентрические слои с абсолютным законом взаимодействия — Правилом зависимостей (Dependency Rule).
В этой главе мы разберём анатомию Clean Architecture сквозь призму практической разработки на Go. Мы исследуем, как организовать структуру каталогов, управлять жизненным циклом транзакций через Unit of Work, пресекать утечки абстракций с помощью линтеров и поддерживать высокую производительность без лишней нагрузки на аллокатор памяти.
Слои Clean Architecture
Классическая визуализация Clean Architecture — это набор концентрических кругов, где каждый уровень представляет собой строго очерченный пояс абстракций. Чем глубже мы погружаемся к центру кругов, тем выше уровень политики системы и тем меньше этот код знает о бренном материальном мире (базах данных, JSON-форматах, HTTP-протоколах или консольных флагах).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Core["Ядро - не зависит ни от чего внешнего"]
Entities["Entities<br>Бизнес-сущности и правила,<br>не зависящие от приложения"]:::process
UseCases["Use Cases<br>Сценарии использования,<br>специфичные для приложения"]:::process
end
subgraph Adapters["Адаптеры - преобразуют форматы"]
Controllers["Controllers / Gateways<br>Обработчики ввода, презентеры"]:::process
RepoImpl["Repository Implementations<br>Конкретные реализации доступа к данным"]:::process
end
subgraph External["Внешние фреймворки и устройства"]
HTTP["HTTP / gRPC сервер"]:::network
DB["PostgreSQL / MongoDB"]:::process
WebUI["Web UI / CLI"]:::process
end
External --> Adapters --> Core
Разберём четыре фундаментальных круга абстракций:
1. Entities (Сущности предприятия)
Это святая святых системы — наиболее стабильный, долговечный и абстрактный слой. Он инкапсулирует фундаментальные бизнес-правила масштаба предприятия, которые существовали бы, даже если бы компьютеров вовсе не изобрели (банковские проценты, расчет налогов, правила формирования скидок).
В терминологии DDD (12. Domain Driven Design. Bounded Context и Aggregate) это агрегаты, сущности и Value Objects. Они абсолютно слепы к внешнему миру: здесь нет ни единого SQL-запроса, ни тегов сериализации, ни сетевых сокетов:
// core/entity/order.go
package entity
import "errors"
type OrderStatus int
const (
StatusDraft OrderStatus = iota
StatusConfirmed
StatusCancelled
)
type Money struct {
Amount int64
Currency string
}
type OrderItem struct {
ProductID string
Quantity int
Price Money
}
type Order struct {
ID string
CustomerID string
Items []OrderItem
TotalAmount Money
Status OrderStatus
}
func (o *Order) AddItem(productID string, quantity int, price Money) error {
if o.Status != StatusDraft {
return errors.New("cannot modify confirmed order")
}
if quantity <= 0 {
return errors.New("quantity must be positive")
}
o.Items = append(o.Items, OrderItem{
ProductID: productID,
Quantity: quantity,
Price: price,
})
// пересчёт бизнес-инварианта
return nil
}
2. Use Cases (Сценарии использования приложения)
Этот слой оркеструет потоки данных между сущностями и направляет их к достижению целей конкретного сценария приложения (например: «Зарегистрировать новый заказ», «Списать бонусные баллы»).
Use Case не знает, откуда пришёл запрос — из мобильного приложения через gRPC, из веб-формы или из фонового воркера крона. Для работы с базами данных Use Case объявляет собственные абстрактные интерфейсы (потребительские контракты):
// core/usecase/order_creation.go
package usecase
import (
"context"
"yourproject/core/entity"
)
type CreateOrderInput struct {
CustomerID string
Items []entity.OrderItem
}
// OrderRepository - интерфейс объявляется на стороне потребителя
type OrderRepository interface {
Save(ctx context.Context, order *entity.Order) error
}
type CreateOrderUseCase struct {
orderRepo OrderRepository
}
func NewCreateOrderUseCase(repo OrderRepository) *CreateOrderUseCase {
return &CreateOrderUseCase{orderRepo: repo}
}
func (uc *CreateOrderUseCase) Execute(ctx context.Context, input CreateOrderInput) (*entity.Order, error) {
// 1. Создаём доменную сущность
order := &entity.Order{
CustomerID: input.CustomerID,
Items: input.Items,
Status: entity.StatusDraft,
}
// 2. Выполняем доменную валидацию
if len(order.Items) == 0 {
return nil, entity.ErrEmptyOrder
}
// 3. Персистим через абстрактный репозиторий
if err := uc.orderRepo.Save(ctx, order); err != nil {
return nil, err
}
return order, nil
}
3. Interface Adapters (Адаптеры интерфейсов)
Этот слой выполняет роль «шлюза трансляции». Он преобразует данные из формата, максимально удобного для сущностей и юзкейсов, в формат внешних устройств (SQL-таблицы, HTTP JSON, Protobuf). Сюда входят HTTP-контроллеры, презентеры, gRPC-хэндлеры и конкретные реализации репозиториев (PostgreSQL, Redis).
4. Frameworks & Drivers (Внешние фреймворки и драйверы)
Самый нестабильный и быстро меняющийся внешний пояс: драйверы баз данных (database/sql, pgx), веб-роутеры (Gin, Echo, Chi), брокеры сообщений. Этот слой является подключаемой деталью реализации (Plug-in architecture): вы можете заменить Gin на стандартный net/http, не изменив ни единой строчки в сценариях использования.
Правило зависимостей (Dependency Rule)
Краеугольный закон Clean Architecture предельно прост: Зависимости в исходном коде обязаны указывать строго внутрь — в сторону более высоких абстракций.
Ничто во внутреннем круге не имеет права знать хоть что-нибудь о внешнем круге. Имена функций, классов, переменных или форматов данных, объявленных снаружи, не должны даже упоминаться внутри.
[Внешний мир: HTTP / SQL] ──> [Адаптеры: Handlers / Repo] ──> [Use Cases] ──> [Entities]
(Стрелки импортов указывают ТОЛЬКО ВНУТРЬ)
В языке Go это выражается в строгом графе импортов пакетов:
- Пакет
entityне импортирует ничего, кроме стандартной библиотеки Go. - Пакет
usecaseимпортируетentity, но категорически не знает о пакетахpostgres,httpилиgin. - Пакеты
adapter/postgresиadapter/httpимпортируютusecaseиentity, чтобы реализовать интерфейсы и конвертировать DTO.
// ✔ Правильное направление зависимостей
usecase.OrderRepository (интерфейс) ← postgres.OrderRepository (реализация)
// ✘ Неправильно: циклическая или нисходящая зависимость
usecase.CreateOrderUseCase импортирует postgres.OrderRepository
Реализация Clean Architecture в Go
Рассмотрим эталонную структуру директорий для Go-сервиса, спроектированного по канонам Дядюшки Боба:
project/
├── cmd/
│ └── server/
│ └── main.go # Точка сборки и композиции зависимостей
├── internal/
│ ├── entity/ # Слой 1: Бизнес-сущности и доменные типы
│ │ ├── order.go
│ │ ├── customer.go
│ │ └── money.go
│ ├── usecase/ # Слой 2: Пользовательские сценарии
│ │ ├── order/
│ │ │ ├── create.go
│ │ │ ├── get.go
│ │ │ └── repository.go # Интерфейс репозитория
│ │ └── customer/
│ ├── adapter/ # Слой 3: Адаптеры интерфейсов
│ │ ├── http/
│ │ │ ├── order_handler.go
│ │ │ └── dto.go
│ │ ├── postgres/
│ │ │ ├── order_repo.go
│ │ │ └── models.go
│ │ └── external/
│ │ └── payment_client.go
│ └── pkg/ # Утилитарные библиотеки (логи, кастомные ошибки)
└── go.mod
Внедрение зависимостей
В корневой точке сборки main.go создаются конкретные экземпляры драйверов и адаптеров, которые связываются через конструкторы:
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
"github.com/go-chi/chi/v5"
_ "github.com/lib/pq"
httpadapter "yourproject/internal/adapter/http"
"yourproject/internal/adapter/postgres"
orderuc "yourproject/internal/usecase/order"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 1. Создаем адаптер БД (внешний круг -> адаптер)
orderRepo := postgres.NewOrderRepository(db)
// 2. Внедряем репозиторий в Use Case (адаптер -> сценарий)
createOrderUC := orderuc.NewCreateOrderUseCase(orderRepo)
// 3. Создаем HTTP адаптер, передавая в него сценарий
orderHandler := httpadapter.NewOrderHandler(createOrderUC)
// 4. Настраиваем маршрутизатор фреймворка
router := chi.NewRouter()
router.Post("/api/orders", orderHandler.Create)
log.Println("Server listening on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatal(err)
}
}
Работа с ошибками
Ошибки обязаны рождаться в чистом доменном виде и транслироваться наружу без утечки SQL-специфики:
// internal/usecase/order/errors.go
package order
import "errors"
var (
ErrOrderNotFound = errors.New("order not found")
ErrInvalidStatus = errors.New("invalid order status for operation")
)
// internal/adapter/http/order_handler.go
package http
import (
"encoding/json"
"errors"
"net/http"
"yourproject/internal/usecase/order"
)
func (h *OrderHandler) Get(w http.ResponseWriter, r *http.Request) {
orderID := r.URL.Query().Get("id")
result, err := h.uc.Execute(r.Context(), orderID)
if err != nil {
switch {
case errors.Is(err, order.ErrOrderNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.Is(err, order.ErrInvalidStatus):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
Mechanical Sympathy: влияние на производительность
Элегантная архитектура не должна стоить дорого на уровне машинного железа. Как Clean Architecture соотносится с железом и рантаймом Go?
- Интерфейсный оверхед: Динамическая диспетчеризация через таблицу методов (
itab) предсказуема для конвейера CPU (предсказатель переходов быстро кэширует адрес метода). -
Аллокации и Garbage Collector:
Главный скрытый налог Clean Architecture — это копирование данных при переходе границ. Запрос сначала парсится в DTO адаптера (HTTPRequestBody), затем конвертируется в структуру юзкейса (CreateOrderInput), преобразуется в доменныйOrder, а в репозитории — в модель базы данных (PostgresOrderRow).В микросервисах со скромной нагрузкой (до сотен RPS) этот накладной расход незаметен. Но если ваш сервис обрабатывает 100 000 RPS, бесконечные аллокации временных структур в куче создадут чудовищное давление на сборщик мусора (GC).
-
Оптимизации горячих путей:
В высоконагруженных узлах Go-инженеры снижают налог Clean Architecture следующими методами:- Использование пулов объектов (
sync.Pool) для переиспользования буферов DTO. - Использование высокоскоростных кодогенераторов сериализации без рефлексии:
easyjsonдля JSON или компактный бинарныйprotobufдля gRPC. - Внимательное профилирование через
pprofдля выявления точек, где escape analysis выбрасывает структуры из стека в кучу.
- Использование пулов объектов (
Clean Architecture vs Hexagonal Architecture
Хотя эти парадигмы часто используются как синонимы, между ними есть тонкие архитектурные различия:
| Параметр | Hexagonal Architecture (Алистер Кокбёрн) | Clean Architecture (Роберт Мартин) |
|---|---|---|
| Главная цель | Сделать ядро независимым от ввода/вывода через симметрию портов | Разложить всю систему на слои с единым вектором зависимостей |
| Внутреннее устройство ядра | Ядро не регламентировано (может быть единым пакетом) | Ядро строго расщеплено на Entities и Use Cases |
| Терминология | Порты (Driving/Driven) и Адаптеры | Сущности, Юзкейсы, Адаптеры интерфейсов, Фреймворки |
| Фокус правил | Одинаковое подключение тестов, БД и внешних API | Строжайшее правило направления зависимостей (Dependency Rule) |
В большинстве зрелых Go-проектов принципы сливаются: слои структурируются по Clean Architecture, а сами интерфейсы оформляются в виде входных и выходных портов.
Антипаттерны при использовании Clean Architecture в Go
При попытке перенести Clean Architecture из Java/C# в язык Go программисты регулярно наступают на четыре классических грабли:
1. Утечка инфраструктурного контекста в домен
Стандартный пакет context.Context передаётся по цепочке для поддержки отмены операций и таймаутов. Но извлечение значений вроде context.Value("userID") прямо внутри доменных сущностей entity — это грубое нарушение. Идентификаторы клиентов и роли должны передаваться явно через аргументы методов!
2. Зависимость ядра от интерфейсов адаптеров
Интерфейс репозитория обязан принадлежать слою, который его использует (т.е. usecase), а не объявляться в пакете postgres. Помните: ядро определяет контракт, а адаптер лишь подстраивается под него.
3. Беспомощный CRUD и оверинжиниринг
Разбиение примитивного сервиса заметок из 3 таблиц на 8 слоёв, 15 интерфейсов и 4 пакета превращает чтение кода в пытку. Если у вас нет сложной бизнес-логики и инвариантов — используйте плоскую структуру пакетов.
4. Игнорирование транзакционных границ (Unit of Work)
Частая ошибка — поручать управление ACID-транзакцией самому репозиторию. Если Use Case должен списать деньги со счёта и создать запись в журнале аудита, они обязаны выполниться в единой транзакции базы данных.
// Плохо: репозиторий сам закрывает транзакцию, лишая Use Case контроля
func (r *PostgresRepo) Save(ctx context.Context, order *entity.Order) error {
tx, _ := r.db.Begin()
defer tx.Rollback()
// ...
return tx.Commit()
}
// Идиоматично: Use Case управляет границей через Unit of Work
type UnitOfWork interface {
Begin(ctx context.Context) (context.Context, error)
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
OrderRepository() OrderRepository
}
func (uc *CreateOrderUseCase) Execute(ctx context.Context, input CreateOrderInput) (*entity.Order, error) {
ctx, err := uc.uow.Begin(ctx)
if err != nil {
return nil, err
}
defer uc.uow.Rollback(ctx)
repo := uc.uow.OrderRepository()
// ... выполнение операций с репозиторием в рамках транзакции ...
return order, uc.uow.Commit(ctx)
}
Когда применять Clean Architecture
Оправдано:
- Большие долгоживущие корпоративные системы (Fintech, E-commerce, Healthcare) с нетривиальными правилами учёта и валидации.
- Проекты, рассчитанные на параллельную работу нескольких команд, где интерфейсы служат строгими контрактами разграничения зон ответственности.
- Системы с обязательным требованием к высокому покрытию юнит-тестами без внешних стендов.
Не рекомендуется:
- Микросервисы-прокси, интеграционные шлюзы или типичные CRUD-приложения. Для них чистая архитектура превращается в неоправданный налог на когнитивную нагрузку и скорость разработки.
Итог
Clean Architecture Роберта Мартина даёт разработчику исчерпывающий инструментарий для возведения неприступной крепости вокруг бизнес-логики приложения. Разделяя сущности предприятия, пользовательские сценарии и инфраструктурные адаптеры, вы получаете кодовую базу, устойчивую к капризам моды на фреймворки и смене поставщиков облачных баз данных.
Теперь, исследовав высоты чистых архитектур, нам жизненно необходимо спуститься на землю и вспомнить классический фундамент, с которого начинался весь современный веб-бэкенд: 15. Layered Architecture. Классическая трехслойка.