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-запроса, ни тегов сериализации, ни сетевых сокетов:

Go
// 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 объявляет собственные абстрактные интерфейсы (потребительские контракты):

Go
// 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 предельно прост: Зависимости в исходном коде обязаны указывать строго внутрь — в сторону более высоких абстракций.

Ничто во внутреннем круге не имеет права знать хоть что-нибудь о внешнем круге. Имена функций, классов, переменных или форматов данных, объявленных снаружи, не должны даже упоминаться внутри.

DIAGRAM
 [Внешний мир: HTTP / SQL] ──> [Адаптеры: Handlers / Repo] ──> [Use Cases] ──> [Entities]
                      (Стрелки импортов указывают ТОЛЬКО ВНУТРЬ)

В языке Go это выражается в строгом графе импортов пакетов:

  • Пакет entity не импортирует ничего, кроме стандартной библиотеки Go.
  • Пакет usecase импортирует entity, но категорически не знает о пакетах postgres, http или gin.
  • Пакеты adapter/postgres и adapter/http импортируют usecase и entity, чтобы реализовать интерфейсы и конвертировать DTO.
Go
// ✔ Правильное направление зависимостей
usecase.OrderRepository (интерфейс) ← postgres.OrderRepository (реализация)

// ✘ Неправильно: циклическая или нисходящая зависимость
usecase.CreateOrderUseCase импортирует postgres.OrderRepository

Реализация Clean Architecture в Go

Рассмотрим эталонную структуру директорий для Go-сервиса, спроектированного по канонам Дядюшки Боба:

DIAGRAM
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 создаются конкретные экземпляры драйверов и адаптеров, которые связываются через конструкторы:

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-специфики:

Go
// 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?

  1. Интерфейсный оверхед: Динамическая диспетчеризация через таблицу методов (itab) предсказуема для конвейера CPU (предсказатель переходов быстро кэширует адрес метода).
  2. Аллокации и Garbage Collector:
    Главный скрытый налог Clean Architecture — это копирование данных при переходе границ. Запрос сначала парсится в DTO адаптера (HTTPRequestBody), затем конвертируется в структуру юзкейса (CreateOrderInput), преобразуется в доменный Order, а в репозитории — в модель базы данных (PostgresOrderRow).

    В микросервисах со скромной нагрузкой (до сотен RPS) этот накладной расход незаметен. Но если ваш сервис обрабатывает 100 000 RPS, бесконечные аллокации временных структур в куче создадут чудовищное давление на сборщик мусора (GC).

  3. Оптимизации горячих путей:
    В высоконагруженных узлах 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 должен списать деньги со счёта и создать запись в журнале аудита, они обязаны выполниться в единой транзакции базы данных.

Go
// Плохо: репозиторий сам закрывает транзакцию, лишая 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. Классическая трехслойка.