Сравнительная таблица архитектурных стилей
Ставим все четыре подхода — Layered, Clean, Onion, Hexagonal — рядом и сравниваем по единым критериям: направление зависимостей, роль домена, механизм изоляции, тестируемость, сложность внедрения. Выявляем общее ядро идей и принципиальные различия.
Четыре архитектуры: хронология
1990-е 2005 2008 2012
│ │ │ │
▼ ▼ ▼ ▼
Layered Hexagonal Onion Clean
Architecture Architecture Architecture Architecture
(Фаулер и др.) (Cockburn) (Palermo) (Martin)
│ │ │ │
│ Проблема: │ Решение: │ Решение: │ Обобщение:
│ зависимости │ порты и │ концентри- │ правило
│ сверху вниз │ адаптеры │ ческие слои │ зависимостей
│ │ │ │
└────────────────────┴─────────────┴──────────────────┘
│
Общая идея:
"Домен в центре,
инфраструктура снаружи"
Сводная таблица сравнения
Основные характеристики
| Характеристика | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| Год | ~1990-е | 2005 | 2008 | 2012 |
| Автор | Фаулер и др. | Cockburn | Palermo | Martin |
| Альт. название | N-tier | Ports & Adapters | — | — |
| Визуальная метафора | Стопка слоёв | Шестиугольник | Луковица (круги) | Круги |
| Направление зависимостей | Сверху вниз ↓ | Снаружи внутрь → | Снаружи внутрь → | Снаружи внутрь → |
| Домен зависит от инфраструктуры? | ДА ❌ | НЕТ ✅ | НЕТ ✅ | НЕТ ✅ |
| Инфраструктура заменяема? | Сложно | Легко | Легко | Легко |
Структура слоёв
| Слой / Роль | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| Центр | Data Access | Application Core | Domain Model | Entities |
| Бизнес-логика | Business Logic Layer | Application Core | Domain Model + Domain Services | Entities + Use Cases |
| Оркестрация | Service Layer (часть Business) | Application Services (часть Core) | Application Services | Use Cases (Interactors) |
| Интерфейс с внешним миром | Presentation Layer | Driving Adapters | Infrastructure | Interface Adapters |
| Хранение данных | Data Access Layer | Driven Adapters | Infrastructure | Frameworks & Drivers |
| Количество слоёв | 3–5 | 3 (Core, Ports, Adapters) | 4 | 4 |
Ключевые концепции
| Концепция | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| Dependency Inversion | Нет | Да (порты) | Да (интерфейсы) | Да (DIP) |
| Порты (интерфейсы) | Нет | Первоклассная концепция | Да (в Application) | Да (Use Case Boundaries) |
| Адаптеры | Нет | Первоклассная концепция | Да (Infrastructure) | Да (Interface Adapters) |
| Driving / Driven | Нет | Да, явно | Нет | Нет явно |
| Presenter / Output Boundary | Нет | Нет | Нет | Да |
| Screaming Architecture | Нет | Нет | Нет | Да |
| Связь с DDD | Слабая | Умеренная | Сильная | Умеренная |
Тестируемость
| Аспект тестирования | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| 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.
Маппинг терминологии
Одни и те же концепции называются по-разному в разных архитектурах:
| Концепция | Layered | Hexagonal | Onion | Clean |
|---|---|---|---|---|
| Бизнес-объекты | Model | Domain Entity | Entity / VO | Entity |
| Бизнес-операции | Service | Application Core | Domain Service | Use Case |
| Интерфейс для БД | — | Driven Port | Repository Interface | Gateway Interface |
| Реализация для БД | Repository | Driven Adapter | Repository Impl | Gateway Impl |
| HTTP-обработчик | Controller | Driving Adapter | Controller | Controller |
| Интерфейс для UI | — | Driving Port | — | Input Boundary |
| Форматирование вывода | — | — | — | Presenter |
| «Клей» приложения | — | Adapter Configuration | — | Main / Composition Root |
| В Effect-ts | — | Context.Tag (Port) / Layer (Adapter) | Context.Tag / Layer | Context.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* () { /* ... */ })
Ключевые выводы
-
Все концентрические архитектуры (Hexagonal, Onion, Clean) решают одну проблему — зависимость бизнес-логики от инфраструктуры — и делают это одним способом: инверсией зависимостей.
-
Layered Architecture фундаментально отличается от остальных трёх: зависимости направлены вниз к инфраструктуре, а не внутрь к домену.
-
Hexagonal уникальна явным разделением на Driving/Driven и концепцией симметричных портов и адаптеров.
-
Clean уникальна формализацией Dependency Rule и паттерном Presenter/Output Boundary.
-
Onion уникальна явным разделением Domain Model и Domain Services, а также тесной связью с DDD.
-
Для Effect-ts все три концентрические архитектуры реализуются одинаково: Service = Port, Layer = Adapter. Различия — в организации доменного кода.
-
На практике выбирайте терминологию и структуру, которая лучше всего понимается вашей командой. Принципы важнее названий.
Далее: Почему Ports & Adapters — лучший выбор именно для Effect-ts, и как Service/Layer/Context реализуют паттерн на уровне типов.