Типобезопасный домен: Гексагональная архитектура на базе Effect Onion Architecture (Palermo)
Глава

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 ArchitectureClean Architecture
Год20082012
АвторJeffrey PalermoRobert C. Martin
Количество колец44
ЦентрDomain ModelEntities
Domain ServicesОтдельный слойЧасть Entities
Порты определяются вApplication ServicesUse 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 (адаптеры)

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

  1. Onion Architecture — конкретная, DDD-ориентированная архитектура с чётким разделением на Domain Model, Domain Services, Application Services и Infrastructure.

  2. Dependency Rule действует так же, как в Clean Architecture: зависимости направлены строго внутрь. Infrastructure зависит от Application, но не наоборот.

  3. Интерфейсы портов определяются в Application Layer — именно этот слой знает, что ему нужно от инфраструктуры.

  4. Проще, чем Clean Architecture — нет Presenters, Output Boundaries и других абстракций. Фокус на DDD-терминологии и конкретной структуре.

  5. Effect-ts естественно реализует все четыре слоя: чистые типы (Domain), чистые функции (Domain Services), Service/Tag (Application Ports), Layer (Infrastructure Adapters).


Далее: Сравнительная таблица — систематическое сопоставление всех трёх подходов (Layered, Clean, Onion) и их общий знаменатель.