Типобезопасный домен: Гексагональная архитектура на базе Effect Сравнительная таблица архитектурных стилей
Глава

Сравнительная таблица архитектурных стилей

Ставим все четыре подхода — Layered, Clean, Onion, Hexagonal — рядом и сравниваем по единым критериям: направление зависимостей, роль домена, механизм изоляции, тестируемость, сложность внедрения. Выявляем общее ядро идей и принципиальные различия.

Четыре архитектуры: хронология

1990-е                2005          2008              2012
  │                    │             │                  │
  ▼                    ▼             ▼                  ▼
Layered           Hexagonal       Onion             Clean
Architecture      Architecture    Architecture      Architecture
(Фаулер и др.)    (Cockburn)      (Palermo)         (Martin)
  │                    │             │                  │
  │    Проблема:       │  Решение:   │  Решение:       │  Обобщение:
  │    зависимости     │  порты и    │  концентри-     │  правило
  │    сверху вниз     │  адаптеры   │  ческие слои    │  зависимостей
  │                    │             │                  │
  └────────────────────┴─────────────┴──────────────────┘

                    Общая идея:
               "Домен в центре,
            инфраструктура снаружи"

Сводная таблица сравнения

Основные характеристики

ХарактеристикаLayeredHexagonalOnionClean
Год~1990-е200520082012
АвторФаулер и др.CockburnPalermoMartin
Альт. названиеN-tierPorts & Adapters
Визуальная метафораСтопка слоёвШестиугольникЛуковица (круги)Круги
Направление зависимостейСверху вниз ↓Снаружи внутрь →Снаружи внутрь →Снаружи внутрь →
Домен зависит от инфраструктуры?ДА ❌НЕТ ✅НЕТ ✅НЕТ ✅
Инфраструктура заменяема?СложноЛегкоЛегкоЛегко

Структура слоёв

Слой / РольLayeredHexagonalOnionClean
ЦентрData AccessApplication CoreDomain ModelEntities
Бизнес-логикаBusiness Logic LayerApplication CoreDomain Model + Domain ServicesEntities + Use Cases
ОркестрацияService Layer (часть Business)Application Services (часть Core)Application ServicesUse Cases (Interactors)
Интерфейс с внешним миромPresentation LayerDriving AdaptersInfrastructureInterface Adapters
Хранение данныхData Access LayerDriven AdaptersInfrastructureFrameworks & Drivers
Количество слоёв3–53 (Core, Ports, Adapters)44

Ключевые концепции

КонцепцияLayeredHexagonalOnionClean
Dependency InversionНетДа (порты)Да (интерфейсы)Да (DIP)
Порты (интерфейсы)НетПервоклассная концепцияДа (в Application)Да (Use Case Boundaries)
АдаптерыНетПервоклассная концепцияДа (Infrastructure)Да (Interface Adapters)
Driving / DrivenНетДа, явноНетНет явно
Presenter / Output BoundaryНетНетНетДа
Screaming ArchitectureНетНетНетДа
Связь с DDDСлабаяУмереннаяСильнаяУмеренная

Тестируемость

Аспект тестированияLayeredHexagonalOnionClean
Unit-тесты доменаТребуют моков БДЧистые, без моковЧистые, без моковЧистые, без моков
Замена инфраструктуры для тестовСложно (нет интерфейсов)Просто (подмена адаптера)Просто (подмена реализации)Просто (подмена реализации)
Contract TestingНеприменимоЕстественно (порт = контракт)ВозможноВозможно
E2E через подмену слояНетДа (тестовый адаптер)ДаДа

Направление зависимостей: визуальное сравнение

Layered Architecture: зависимости направлены вниз

┌──────────────┐
│ Presentation │ ──→ знает о Business Logic
├──────────────┤
│Business Logic│ ──→ знает о Data Access
├──────────────┤
│ Data Access  │ ──→ знает о Database
├──────────────┤
│   Database   │
└──────────────┘

Проблема: Business Logic ЗАВИСИТ от Data Access
          (конкретной технологии хранения)

Hexagonal Architecture: порты и адаптеры

              Driving Adapters          Driven Adapters
              (HTTP, CLI, Tests)        (SQLite, FS, API)
                    │                         ▲
                    ▼                         │
              ┌──────────┐             ┌──────────┐
              │  Driving  │             │  Driven  │
              │   Port    │             │   Port   │
              └─────┬─────┘             └────┬─────┘
                    │                         │
                    ▼                         │
              ┌───────────────────────────────┘
              │       Application Core
              │   (Domain + Application Logic)
              └──────────────────────────────

Ключевая идея: ПОРТ — это интерфейс, определённый ядром.
АДАПТЕР — реализация порта для конкретной технологии.

Onion Architecture: концентрические слои

         Infrastructure  ──→  Application Services  ──→  Domain
              │                      │                     │
         Зависит от             Зависит от            Ни от чего
         Application            Domain                не зависит

Clean Architecture: The Dependency Rule

    Frameworks ──→ Interface Adapters ──→ Use Cases ──→ Entities
        │                │                    │            │
   Зависит от      Зависит от          Зависит от   Ни от чего
   Adapters        Use Cases           Entities      не зависит

Общий стержень: три фундаментальных принципа

Несмотря на разную терминологию и визуальные метафоры, все концентрические архитектуры (Hexagonal, Onion, Clean) разделяют три фундаментальных принципа:

Принцип 1: Домен не зависит ни от чего

Бизнес-логика — самый стабильный компонент системы. Правила предметной области меняются реже, чем технологии. Поэтому домен должен быть независимым: никаких импортов из фреймворков, баз данных, HTTP-библиотек.

// ✅ Доменная логика во ВСЕХ трёх архитектурах выглядит одинаково:
// чистые типы, чистые функции, ноль внешних зависимостей

interface Todo {
  readonly id: string
  readonly title: string
  readonly completed: boolean
}

const isOverdue = (todo: Todo, dueDate: Date, now: Date): boolean =>
  !todo.completed && now > dueDate

Принцип 2: Зависимости направлены внутрь (к домену)

Внешние слои знают о внутренних, но не наоборот. Controller знает о Use Case, но Use Case не знает о Controller. Repository-реализация знает о Repository-интерфейсе, но интерфейс не знает о реализации.

// Интерфейс определён ВНУТРИ (Application / Use Cases)
interface TodoRepository {
  readonly save: (todo: Todo) => Promise<void>
}

// Реализация определена СНАРУЖИ (Infrastructure / Adapters)
// и ЗАВИСИТ от интерфейса выше
class SqliteTodoRepository implements TodoRepository {
  async save(todo: Todo): Promise<void> { /* SQL */ }
}

Принцип 3: Инфраструктура подключается через абстракции

Домен определяет что ему нужно (интерфейс/порт), а инфраструктура определяет как это предоставить (реализация/адаптер). Это позволяет заменять технологии, не трогая бизнес-логику.

// Порт (контракт) — в центре
interface TodoRepository {
  readonly save: (todo: Todo) => Promise<void>
}

// Адаптер 1 (SQLite) — снаружи
class SqliteTodoRepo implements TodoRepository { /* ... */ }

// Адаптер 2 (InMemory) — снаружи
class InMemoryTodoRepo implements TodoRepository { /* ... */ }

// Адаптер 3 (PostgreSQL) — снаружи
class PostgresTodoRepo implements TodoRepository { /* ... */ }

// Бизнес-логика не меняется при замене адаптера

Различия: что отличает архитектуры друг от друга

1. Hexagonal уникальна: концепция Driving vs Driven

Только Hexagonal Architecture явно разделяет стороны гексагона:

  • Driving (Primary) side — кто вызывает приложение: HTTP, CLI, тесты, GUI
  • Driven (Secondary) side — что приложение использует: база данных, файлы, внешние API

Это разделение важно на практике: Driving Ports определяют API приложения (что оно умеет делать), а Driven Ports определяют зависимости приложения (что ему нужно для работы).

Clean и Onion Architecture не делают такого явного разграничения.

          DRIVING SIDE                    DRIVEN SIDE
     (кто вызывает нас)              (что мы вызываем)

    ┌──────────────┐                ┌──────────────┐
    │  HTTP Client │                │    SQLite     │
    │     CLI      │ ──→ [APP] ──→ │  File System  │
    │    Tests     │                │  Email API    │
    └──────────────┘                └──────────────┘

2. Clean уникальна: Presenter и Output Boundary

Только Clean Architecture вводит паттерн Presenter — объект, который форматирует вывод Use Case для конкретного представления:

// Output Boundary — определён в Use Cases
interface CreateTodoPresenter {
  presentSuccess(output: CreateTodoOutput): void
  presentError(error: AppError): void
}

// Presenter — реализация в Interface Adapters
class HttpCreateTodoPresenter implements CreateTodoPresenter {
  private response: HttpResponse | null = null

  presentSuccess(output: CreateTodoOutput): void {
    this.response = { status: 201, body: output }
  }

  presentError(error: AppError): void {
    this.response = { status: 400, body: { error: error.message } }
  }

  getResponse(): HttpResponse {
    return this.response!
  }
}

На практике этот паттерн используется редко. Большинство проектов просто возвращают данные из Use Case.

3. Onion уникальна: явное разделение Domain Model и Domain Services

Только Onion Architecture выделяет Domain Services в отдельный слой между Domain Model и Application Services:

Clean:    Entities (domain model + domain services вместе)
Onion:    Domain Model ← Domain Services ← Application Services
Hexagonal: Application Core (всё вместе)

Это полезное различение, потому что Domain Services (операции между несколькими сущностями) имеют другую природу, чем Domain Model (отдельные сущности и их правила).

4. Hexagonal уникальна: симметрия

Hexagonal Architecture подчёркивает симметрию между входящими и исходящими адаптерами. Оба типа адаптеров работают через порты, оба взаимозаменяемы, оба тестируются одинаково. Эта симметрия не так явно выражена в Clean и Onion.


Что выбрать? Дерево решений

Насколько сложна бизнес-логика?

├─ Минимальная (CRUD, валидация полей)
│  └─→ Layered Architecture ✅
│      Просто, быстро, достаточно

├─ Средняя (бизнес-правила, но один «фронт»)
│  │
│  ├─ Знакомы с DDD?
│  │  ├─ Да → Onion Architecture ✅
│  │  └─ Нет → Clean Architecture ✅
│  │
│  └─ Используете Effect-ts?
│     └─→ Hexagonal Architecture ✅✅✅
│         (Service = Port, Layer = Adapter)

├─ Высокая (много бизнес-правил, несколько «фронтов»)
│  └─→ Hexagonal Architecture ✅
│      Порты и адаптеры — максимальная гибкость

└─ Очень высокая (финансы, медицина, логистика)
   └─→ Hexagonal + DDD + CQRS + ES
       Полный арсенал

Как выбор архитектуры влияет на код

Рассмотрим один и тот же Use Case (создание задачи) в четырёх архитектурных стилях:

Layered: прямые зависимости

import { TodoRepository } from "../data-access/todo-repository"  // конкретный класс
import { Database } from "bun:sqlite"                             // конкретная технология

class TodoService {
  constructor(private repo: TodoRepository) {}  // зависит от конкретного класса
  
  createTodo(title: string) {
    const todo = { id: crypto.randomUUID(), title, completed: false }
    this.repo.save(todo)  // маппинг в формат БД — здесь или в repo
    return todo
  }
}

Clean: через интерфейсы и Interactor

// Use Case Interactor — зависит только от интерфейсов
class CreateTodoInteractor implements CreateTodoInputBoundary {
  constructor(
    private repo: TodoRepository,        // интерфейс
    private presenter: CreateTodoOutputBoundary  // интерфейс
  ) {}

  async execute(input: CreateTodoInput): Promise<void> {
    const todo = createTodo(input.title)
    await this.repo.save(todo)
    this.presenter.presentSuccess({ id: todo.id, title: todo.title })
  }
}

Onion: через Application Service

// Application Service — зависит от интерфейсов
class CreateTodoService {
  constructor(
    private repo: TodoRepository,  // интерфейс из Application layer
    private idGen: IdGenerator     // интерфейс из Application layer
  ) {}

  async execute(title: string, priority: Priority): Promise<Todo> {
    const todo = createTodo(this.idGen.generate(), title, priority)
    await this.repo.save(todo)
    return todo
  }
}

Hexagonal с Effect-ts: через Service/Layer

import { Effect, Context, Layer } from "effect"

// Порт: интерфейс через Effect Service
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  { readonly save: (todo: Todo) => Effect.Effect<void> }
>() {}

// Use Case: Effect-программа
const createTodo = (title: string) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const todo: Todo = { id: crypto.randomUUID(), title, completed: false }
    yield* repo.save(todo)
    return todo
  })

// Адаптер: Layer
const SqliteRepo = Layer.effect(TodoRepository, /* ... */)
const InMemoryRepo = Layer.succeed(TodoRepository, /* ... */)

// Сборка — выбор адаптера при запуске
const program = createTodo("Buy milk").pipe(
  Effect.provide(SqliteRepo)  // или InMemoryRepo для тестов
)

Обратите внимание на ключевое отличие Hexagonal + Effect: зависимости видны в типе функции через R-канал. Компилятор не позволит запустить createTodo без предоставления TodoRepository. Это гарантия, которую не даёт ни один из других подходов без Effect.


Маппинг терминологии

Одни и те же концепции называются по-разному в разных архитектурах:

КонцепцияLayeredHexagonalOnionClean
Бизнес-объектыModelDomain EntityEntity / VOEntity
Бизнес-операцииServiceApplication CoreDomain ServiceUse Case
Интерфейс для БДDriven PortRepository InterfaceGateway Interface
Реализация для БДRepositoryDriven AdapterRepository ImplGateway Impl
HTTP-обработчикControllerDriving AdapterControllerController
Интерфейс для UIDriving PortInput Boundary
Форматирование выводаPresenter
«Клей» приложенияAdapter ConfigurationMain / Composition Root
В Effect-tsContext.Tag (Port) / Layer (Adapter)Context.Tag / LayerContext.Tag / Layer

Общие антипаттерны (актуальны для всех архитектур)

Независимо от выбранного стиля, следующие антипаттерны разрушают архитектуру:

1. Утечка инфраструктуры в домен

// ❌ Доменная сущность знает о JSON, HTTP, SQL
interface Todo {
  readonly id: string
  readonly title: string
  toJSON(): string           // ← утечка сериализации
  toSqlRow(): SqliteRow      // ← утечка хранения
  toHttpResponse(): Response // ← утечка представления
}

// ✅ Домен чист, маппинг — в адаптерах
interface Todo {
  readonly id: string
  readonly title: string
  readonly completed: boolean
}
// Маппинг в адаптерах:
const todoToJson = (todo: Todo): string => JSON.stringify(todo)
const todoToRow = (todo: Todo): SqliteRow => ({ /* ... */ })

2. Anemic Domain Model

// ❌ Сущность без поведения — просто структура данных
interface Todo {
  id: string
  title: string
  completed: boolean  // мутабельное!
}

// Логика размазана по сервисам
class TodoService {
  complete(todo: Todo) { todo.completed = true }  // мутация снаружи
}

// ✅ Сущность с поведением — инварианты защищены
interface Todo {
  readonly id: string
  readonly title: string
  readonly status: TodoStatus
}

// Поведение — чистая функция, инвариант внутри
const completeTodo = (todo: Todo): Todo =>
  todo.status === "pending" || todo.status === "in_progress"
    ? { ...todo, status: "completed" }
    : todo  // или ошибка InvalidTransition

3. God Service / God Use Case

// ❌ Один класс с 30 методами = нулевая cohesion
class TodoService {
  create() { /* ... */ }
  complete() { /* ... */ }
  archive() { /* ... */ }
  sendReminder() { /* ... */ }
  generateReport() { /* ... */ }
  exportToCsv() { /* ... */ }
  syncWithCalendar() { /* ... */ }
  // ... ещё 20 методов
}

// ✅ Один Use Case = одна операция
const createTodo = (input: CreateTodoInput) => Effect.gen(function* () { /* ... */ })
const completeTodo = (id: TodoId) => Effect.gen(function* () { /* ... */ })
const archiveTodo = (id: TodoId) => Effect.gen(function* () { /* ... */ })

Ключевые выводы

  1. Все концентрические архитектуры (Hexagonal, Onion, Clean) решают одну проблему — зависимость бизнес-логики от инфраструктуры — и делают это одним способом: инверсией зависимостей.

  2. Layered Architecture фундаментально отличается от остальных трёх: зависимости направлены вниз к инфраструктуре, а не внутрь к домену.

  3. Hexagonal уникальна явным разделением на Driving/Driven и концепцией симметричных портов и адаптеров.

  4. Clean уникальна формализацией Dependency Rule и паттерном Presenter/Output Boundary.

  5. Onion уникальна явным разделением Domain Model и Domain Services, а также тесной связью с DDD.

  6. Для Effect-ts все три концентрические архитектуры реализуются одинаково: Service = Port, Layer = Adapter. Различия — в организации доменного кода.

  7. На практике выбирайте терминологию и структуру, которая лучше всего понимается вашей командой. Принципы важнее названий.


Далее: Почему Ports & Adapters — лучший выбор именно для Effect-ts, и как Service/Layer/Context реализуют паттерн на уровне типов.