Onion Architecture (Palermo)
«Луковая» архитектура Джеффри Палермо: Domain Model в центре, Domain Services, Application Services и Infrastructure на внешнем кольце. Разберём её отличия от Clean Architecture, концепцию инверсии инфраструктуры и почему она ближе всего к Hexagonal по духу.
Происхождение и контекст
Onion Architecture была описана Джеффри Палермо в серии статей 2008 года. Она появилась раньше Clean Architecture Мартина (2012) и стала одним из первых формализованных ответов на проблемы слоистой архитектуры в enterprise-разработке.
Палермо работал с .NET-проектами, где стандартом был трёхслойный подход (UI → Business Logic → Data Access). Он наблюдал одну и ту же проблему в проекте за проектом: бизнес-логика становилась рабой инфраструктуры. Смена ORM или базы данных превращалась в масштабный рефакторинг бизнес-слоя, хотя правила предметной области не менялись.
Его ответ — Onion Architecture — предложил радикальную (на тот момент) идею: инфраструктура должна зависеть от домена, а не наоборот.
Диаграмма Onion Architecture
Onion Architecture представлена как серия концентрических кругов (отсюда название «луковица»):
┌───────────────────────────────────────────────────────┐
│ Infrastructure │
│ (Database, File System, Web Services, UI Framework) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Application Services │ │
│ │ (Use Cases, Orchestration, DTOs) │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Domain Services │ │ │
│ │ │ (Operations between Entities) │ │ │
│ │ │ ┌─────────────────────────────────────┐ │ │ │
│ │ │ │ Domain Model │ │ │ │
│ │ │ │ (Entities, Value Objects, Enums) │ │ │ │
│ │ │ └─────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
Зависимости направлены строго ВНУТРЬ →
Четыре слоя: от центра к периферии
Слой 1: Domain Model (Модель предметной области)
Самое ядро — чистая доменная модель. Здесь живут:
- Entities — объекты с уникальной идентичностью и жизненным циклом
- Value Objects — неизменяемые объекты без идентичности, определяемые значениями
- Enums и константы — перечисления бизнес-значений
- Domain Events — факты, произошедшие в предметной области
// domain-model/todo.ts — ЯДРО луковицы
/** Value Object: приоритет задачи */
type Priority = "low" | "medium" | "high" | "critical"
/** Value Object: статус задачи */
type TodoStatus = "pending" | "in_progress" | "completed" | "archived"
/** Entity: задача */
interface Todo {
readonly id: string
readonly title: string
readonly status: TodoStatus
readonly priority: Priority
readonly createdAt: Date
readonly completedAt: Date | null
}
/** Domain Event: задача завершена */
interface TodoCompleted {
readonly _tag: "TodoCompleted"
readonly todoId: string
readonly completedAt: Date
}
/** Бизнес-правило: допустимые переходы статуса */
const VALID_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlyArray<TodoStatus>> = new Map([
["pending", ["in_progress", "archived"]],
["in_progress", ["completed", "pending", "archived"]],
["completed", ["archived"]],
["archived", []],
])
/** Чистая функция: попытка перехода статуса */
const transitionStatus = (
todo: Todo,
newStatus: TodoStatus
): Todo | null => {
const allowed = VALID_TRANSITIONS.get(todo.status) ?? []
return allowed.includes(newStatus)
? {
...todo,
status: newStatus,
completedAt: newStatus === "completed" ? new Date() : todo.completedAt,
}
: null
}
Ключевое свойство: Domain Model не имеет зависимостей. Ноль import-ов из внешних библиотек. Ни ORM, ни HTTP, ни logging framework. Только язык (TypeScript) и стандартная библиотека.
Слой 2: Domain Services (Доменные сервисы)
Второй слой содержит операции, которые работают с несколькими сущностями или реализуют бизнес-логику, не принадлежащую ни одной конкретной сущности.
// domain-services/todo-prioritizer.ts
/**
* Domain Service: логика приоритизации задач.
* Это бизнес-правило, но оно не принадлежит ни одной конкретной задаче —
* оно работает с коллекцией задач.
*/
const prioritizeTodos = (
todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> => {
const priorityWeight: Record<Priority, number> = {
critical: 4,
high: 3,
medium: 2,
low: 1,
} as const
return [...todos].sort((a, b) => {
// 1. По приоритету (descending)
const weightDiff = priorityWeight[b.priority] - priorityWeight[a.priority]
if (weightDiff !== 0) return weightDiff
// 2. По дате создания (ascending — старые первые)
return a.createdAt.getTime() - b.createdAt.getTime()
})
}
/**
* Domain Service: проверка на дубликаты.
* Оперирует коллекцией, а не отдельной сущностью.
*/
const hasDuplicateTitle = (
todos: ReadonlyArray<Todo>,
title: string
): boolean =>
todos.some(
(todo) => todo.title.toLowerCase() === title.toLowerCase()
)
Domain Services зависят только от Domain Model (слой 1). Они не знают о базе данных, HTTP или любой инфраструктуре.
Слой 3: Application Services (Прикладные сервисы)
Третий слой — оркестраторы. Application Services координируют workflow: получают данные, вызывают доменную логику, сохраняют результат. Здесь же определяются интерфейсы портов — контракты для инфраструктуры.
Это критически важная деталь: интерфейсы портов определяются на уровне Application, а реализации — на уровне Infrastructure.
// application-services/ports.ts
/**
* ПОРТ: интерфейс репозитория.
* Определён здесь (Application), реализован снаружи (Infrastructure).
* Зависимость ИНВЕРТИРОВАНА: Infrastructure зависит от Application.
*/
interface TodoRepository {
readonly save: (todo: Todo) => Promise<void>
readonly findById: (id: string) => Promise<Todo | null>
readonly findAll: () => Promise<ReadonlyArray<Todo>>
readonly delete: (id: string) => Promise<void>
}
/** ПОРТ: генерация уникальных идентификаторов */
interface IdGenerator {
readonly generate: () => string
}
/** ПОРТ: получение текущего времени (для детерминированности) */
interface Clock {
readonly now: () => Date
}
/** ПОРТ: отправка уведомлений */
interface NotificationService {
readonly notify: (message: string) => Promise<void>
}
// application-services/create-todo-service.ts
/**
* Application Service: оркестрация создания задачи.
* Зависит от ИНТЕРФЕЙСОВ (портов), а не от реализаций.
*/
class CreateTodoService {
constructor(
private readonly todoRepo: TodoRepository,
private readonly idGen: IdGenerator,
private readonly clock: Clock
) {}
async execute(title: string, priority: Priority): Promise<Todo> {
// Валидация на уровне Application
if (title.trim().length === 0) {
throw new Error("Title cannot be empty")
}
// Проверка дубликатов через репозиторий
const existing = await this.todoRepo.findAll()
if (hasDuplicateTitle(existing, title)) {
throw new Error(`Todo "${title}" already exists`)
}
// Создание доменного объекта
const todo: Todo = {
id: this.idGen.generate(),
title: title.trim(),
status: "pending",
priority,
createdAt: this.clock.now(),
completedAt: null,
}
// Персистентность через порт
await this.todoRepo.save(todo)
return todo
}
}
Слой 4: Infrastructure (Инфраструктура)
Самый внешний слой — реализации портов и всё, что связано с конкретными технологиями:
// infrastructure/sqlite-todo-repository.ts
import { Database } from "bun:sqlite"
/** Реализация порта TodoRepository для SQLite */
class SqliteTodoRepository implements TodoRepository {
constructor(private readonly db: Database) {}
async save(todo: Todo): Promise<void> {
this.db.query(
`INSERT OR REPLACE INTO todos (id, title, status, priority, created_at, completed_at)
VALUES ($id, $title, $status, $priority, $created, $completed)`
).run({
$id: todo.id,
$title: todo.title,
$status: todo.status,
$priority: todo.priority,
$created: todo.createdAt.toISOString(),
$completed: todo.completedAt?.toISOString() ?? null,
})
}
async findById(id: string): Promise<Todo | null> {
const row = this.db
.query("SELECT * FROM todos WHERE id = ?")
.get(id) as TodoRow | null
return row ? this.toDomain(row) : null
}
async findAll(): Promise<ReadonlyArray<Todo>> {
const rows = this.db
.query("SELECT * FROM todos ORDER BY created_at DESC")
.all() as ReadonlyArray<TodoRow>
return rows.map(this.toDomain)
}
async delete(id: string): Promise<void> {
this.db.query("DELETE FROM todos WHERE id = ?").run(id)
}
// Маппинг хранения → домен (ТОЛЬКО в Infrastructure)
private toDomain(row: TodoRow): Todo {
return {
id: row.id,
title: row.title,
status: row.status as TodoStatus,
priority: row.priority as Priority,
createdAt: new Date(row.created_at),
completedAt: row.completed_at ? new Date(row.completed_at) : null,
}
}
}
// Тип данных SQLite — знает только Infrastructure
interface TodoRow {
readonly id: string
readonly title: string
readonly status: string
readonly priority: string
readonly created_at: string
readonly completed_at: string | null
}
Ключевой момент: SqliteTodoRepository знает о TodoRepository (интерфейс из Application layer) и реализует его. Зависимость направлена внутрь: Infrastructure → Application. Domain Model не знает ни о SQLite, ни о SqliteTodoRepository.
Отличительные черты Onion Architecture
1. Явное разделение Domain Model и Domain Services
В отличие от Clean Architecture, где Entities — один слой, Onion Architecture разделяет:
- Domain Model — данные и правила внутри отдельных объектов
- Domain Services — операции между несколькими объектами
Это полезное разделение, потому что Domain Services имеют другой характер: они координируют взаимодействие сущностей, но сами не содержат состояния.
2. Интерфейсы портов живут в Application Layer
Палермо явно указывает, что интерфейсы для инфраструктуры определяются в Application Layer, а не в Domain. Это логично: именно Application Services знают, какие операции им нужны от инфраструктуры.
3. Фокус на Domain-Driven Design
Onion Architecture тесно связана с DDD. Палермо использует терминологию DDD (Entities, Value Objects, Repositories, Domain Services) и рассматривает архитектуру как инструмент для реализации DDD-принципов.
4. Меньше абстракций, чем в Clean Architecture
Onion Architecture не вводит Input/Output Boundaries, Presenters, Interactors. Она проще и конкретнее:
- Домен в центре
- Application Services оркестрируют
- Infrastructure реализует порты
Onion vs Clean Architecture
| Аспект | Onion Architecture | Clean Architecture |
|---|---|---|
| Год | 2008 | 2012 |
| Автор | Jeffrey Palermo | Robert C. Martin |
| Количество колец | 4 | 4 |
| Центр | Domain Model | Entities |
| Domain Services | Отдельный слой | Часть Entities |
| Порты определяются в | Application Services | Use Cases |
| Output Boundary / Presenter | Нет | Да |
| Связь с DDD | Сильная | Умеренная |
| Конкретность | Высокая | Низкая (принципы) |
| Dependency Rule | Да, неявно | Да, явно сформулировано |
На практике эти архитектуры очень близки. Главное различие — в уровне абстракции: Onion Architecture даёт конкретную структуру, Clean Architecture даёт принципы.
Onion Architecture и Effect-ts
Effect-ts позволяет элегантно реализовать каждый слой Onion Architecture:
import { Effect, Context, Layer } from "effect"
// ═══════════════════════════════════════════════════
// Слой 1: Domain Model — чистые типы и функции
// ═══════════════════════════════════════════════════
interface Todo {
readonly id: string
readonly title: string
readonly status: TodoStatus
readonly priority: Priority
}
type TodoStatus = "pending" | "in_progress" | "completed" | "archived"
type Priority = "low" | "medium" | "high" | "critical"
// Доменное правило — чистая функция
const completeTodo = (todo: Todo): Todo => ({
...todo,
status: "completed" as const,
})
// ═══════════════════════════════════════════════════
// Слой 2: Domain Services — операции между сущностями
// ═══════════════════════════════════════════════════
const prioritize = (
todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
[...todos].sort(/* ... */)
// ═══════════════════════════════════════════════════
// Слой 3: Application Services — порты и оркестрация
// ═══════════════════════════════════════════════════
// Порт: интерфейс через Effect Service
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void>
readonly findById: (id: string) => Effect.Effect<Todo | null>
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>>
}
>() {}
// Use Case: Effect-программа с зависимостью от порта
const createTodo = (
title: string,
priority: Priority
): Effect.Effect<Todo, Error, TodoRepository> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const todos = yield* repo.findAll()
if (hasDuplicateTitle(todos, title)) {
return yield* Effect.fail(new Error("Duplicate title"))
}
const todo: Todo = {
id: crypto.randomUUID(),
title,
status: "pending",
priority,
}
yield* repo.save(todo)
return todo
})
// ═══════════════════════════════════════════════════
// Слой 4: Infrastructure — реализация портов
// ═══════════════════════════════════════════════════
// Адаптер: Layer, реализующий порт
const InMemoryTodoRepository = Layer.succeed(
TodoRepository,
{
save: (_todo) => Effect.void,
findById: (_id) => Effect.succeed(null),
findAll: () => Effect.succeed([]),
}
)
// Сборка: Use Case + адаптер
const program = createTodo("Buy milk", "medium").pipe(
Effect.provide(InMemoryTodoRepository)
)
// Тип program: Effect<Todo, Error, never>
// never в R-канале = все зависимости удовлетворены
Обратите внимание на параллель:
- Domain Model (слой 1) = чистые типы и функции TypeScript
- Domain Services (слой 2) = чистые функции над коллекциями
- Application Services (слой 3) =
Context.Tag(порты) +Effect.gen(use cases) - Infrastructure (слой 4) =
Layer(адаптеры)
Ключевые выводы
-
Onion Architecture — конкретная, DDD-ориентированная архитектура с чётким разделением на Domain Model, Domain Services, Application Services и Infrastructure.
-
Dependency Rule действует так же, как в Clean Architecture: зависимости направлены строго внутрь. Infrastructure зависит от Application, но не наоборот.
-
Интерфейсы портов определяются в Application Layer — именно этот слой знает, что ему нужно от инфраструктуры.
-
Проще, чем Clean Architecture — нет Presenters, Output Boundaries и других абстракций. Фокус на DDD-терминологии и конкретной структуре.
-
Effect-ts естественно реализует все четыре слоя: чистые типы (Domain), чистые функции (Domain Services), Service/Tag (Application Ports), Layer (Infrastructure Adapters).
Далее: Сравнительная таблица — систематическое сопоставление всех трёх подходов (Layered, Clean, Onion) и их общий знаменатель.