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

Классическая слоистая архитектура

Самый распространённый архитектурный стиль — Presentation → Business → Data. Мы разберём его механику, поймём почему он стал стандартом индустрии, и увидим его фундаментальные ограничения: транзитивные зависимости, «утечку» инфраструктуры вверх и невозможность заменить нижний слой без каскада изменений.

Историческая справка

Слоистая (layered) архитектура — самый древний и самый распространённый архитектурный паттерн в программировании. Его корни уходят в сетевую модель OSI (1977), где каждый уровень выполнял свою функцию и общался только с соседними уровнями. В мире бизнес-приложений этот подход утвердился в 1990-х годах с массовым распространением клиент-серверных систем и получил каноническое описание в книге «Patterns of Enterprise Application Architecture» Мартина Фаулера (2002).

На протяжении десятилетий именно слоистая архитектура была (и часто остаётся) ответом по умолчанию на вопрос «как организовать код». Если вы когда-либо создавали проект с папками controllers/, services/, repositories/, models/ — вы использовали слоистую архитектуру.


Каноническая трёхслойная модель

Классическая трёхслойная архитектура делит приложение на три уровня:

┌─────────────────────────────────┐
│      Presentation Layer         │  ← UI, HTTP Controllers, CLI
│   (Представление / Интерфейс)   │
├─────────────────────────────────┤
│       Business Logic Layer      │  ← Сервисы, правила, вычисления
│      (Бизнес-логика / Домен)    │
├─────────────────────────────────┤
│       Data Access Layer         │  ← SQL-запросы, ORM, файлы
│    (Доступ к данным / Хранение) │
└─────────────────────────────────┘

Presentation Layer отвечает за взаимодействие с пользователем или внешней системой. Это HTTP-контроллеры, CLI-парсеры, GraphQL-резолверы — всё, что принимает запрос и формирует ответ.

Business Logic Layer содержит правила предметной области. Здесь живут вычисления, валидации, workflow — всё, ради чего приложение вообще существует.

Data Access Layer отвечает за хранение и извлечение данных. SQL-запросы, работа с файлами, вызовы внешних API — всё, что взаимодействует с внешним миром за пределами интерфейса.


Расширенная модель: N-уровневая архитектура

На практике трёх слоёв часто недостаточно. Реальные приложения используют 4–5 слоёв:

┌─────────────────────────────────┐
│      Presentation Layer         │  Controllers, Views, Serializers
├─────────────────────────────────┤
│      Application Layer          │  Use Cases, Orchestration, DTOs
├─────────────────────────────────┤
│       Domain Layer              │  Entities, Value Objects, Rules
├─────────────────────────────────┤
│     Infrastructure Layer        │  Repositories, External APIs, DB
├─────────────────────────────────┤
│       Cross-cutting Concerns    │  Logging, Auth, Config, Caching
└─────────────────────────────────┘

Application Layer появился как прослойка между интерфейсом и бизнес-логикой. Он оркестрирует вызовы: «получи данные → проверь правила → сохрани → отправь уведомление». Этот слой не содержит бизнес-правил, но знает порядок операций.

Cross-cutting Concerns — сквозная функциональность, которая не принадлежит ни одному конкретному слою: логирование, аутентификация, кеширование, обработка ошибок.


Ключевое правило: однонаправленность зависимостей

Главное правило слоистой архитектуры — каждый слой может зависеть только от слоя непосредственно под ним:

Presentation → Business Logic → Data Access
     ✓              ✓               ✓

Presentation → Data Access  (МИНУЯ бизнес-логику)
                  ✗ НАРУШЕНИЕ

Data Access → Business Logic  (ОБРАТНАЯ зависимость)
                  ✗ НАРУШЕНИЕ

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

Существует два варианта этого правила:

Strict layering (строгое) — слой может обращаться ТОЛЬКО к слою непосредственно под ним. Presentation → Business Logic, Business Logic → Data Access. Presentation не может обращаться к Data Access.

Relaxed layering (ослабленное) — слой может обращаться к любому слою ниже. Presentation может вызвать и Business Logic, и Data Access. На практике чаще используется именно ослабленный вариант, что и становится первым шагом к деградации архитектуры.


Пример: слоистая архитектура на TypeScript

Рассмотрим классическую реализацию Todo-приложения в слоистой архитектуре:

Data Access Layer

// data-access/todo-repository.ts
import { Database } from "bun:sqlite"

// Репозиторий напрямую зависит от конкретной технологии
export class TodoRepository {
  constructor(private readonly db: Database) {}

  findById(id: string): TodoRow | null {
    return this.db
      .query("SELECT * FROM todos WHERE id = ?")
      .get(id) as TodoRow | null
  }

  findAll(): ReadonlyArray<TodoRow> {
    return this.db
      .query("SELECT * FROM todos ORDER BY created_at DESC")
      .all() as ReadonlyArray<TodoRow>
  }

  save(todo: TodoRow): void {
    this.db
      .query(
        `INSERT OR REPLACE INTO todos (id, title, completed, created_at)
         VALUES ($id, $title, $completed, $created_at)`
      )
      .run({
        $id: todo.id,
        $title: todo.title,
        $completed: todo.completed ? 1 : 0,
        $created_at: todo.createdAt,
      })
  }

  deleteById(id: string): void {
    this.db.query("DELETE FROM todos WHERE id = ?").run(id)
  }
}

// Типы Data Access Layer — привязаны к структуре таблицы
interface TodoRow {
  readonly id: string
  readonly title: string
  readonly completed: number  // SQLite хранит boolean как 0/1
  readonly created_at: string // SQLite хранит дату как строку
}

Business Logic Layer

// business-logic/todo-service.ts
import { TodoRepository } from "../data-access/todo-repository"

// ❌ Прямая зависимость от конкретного класса Data Access
export class TodoService {
  constructor(private readonly repo: TodoRepository) {}

  createTodo(title: string): Todo {
    if (title.trim().length === 0) {
      throw new Error("Title cannot be empty")
    }
    if (title.length > 200) {
      throw new Error("Title too long")
    }

    const todo: Todo = {
      id: crypto.randomUUID(),
      title: title.trim(),
      completed: false,
      createdAt: new Date(),
    }

    // ❌ Бизнес-логика знает о формате хранения
    this.repo.save({
      id: todo.id,
      title: todo.title,
      completed: todo.completed ? 1 : 0,      // маппинг boolean → number
      created_at: todo.createdAt.toISOString(), // маппинг Date → string
    })

    return todo
  }

  completeTodo(id: string): Todo {
    const row = this.repo.findById(id)
    if (!row) {
      throw new Error(`Todo ${id} not found`)
    }

    // ❌ Бизнес-логика занимается маппингом из формата БД
    const todo: Todo = {
      id: row.id,
      title: row.title,
      completed: true,
      createdAt: new Date(row.created_at),
    }

    this.repo.save({
      id: todo.id,
      title: todo.title,
      completed: 1,
      created_at: row.created_at,
    })

    return todo
  }

  listTodos(): ReadonlyArray<Todo> {
    // ❌ Каждый метод занимается маппингом
    return this.repo.findAll().map((row) => ({
      id: row.id,
      title: row.title,
      completed: row.completed === 1,
      createdAt: new Date(row.created_at),
    }))
  }
}

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

Presentation Layer

// presentation/todo-controller.ts
import { TodoService } from "../business-logic/todo-service"

export class TodoController {
  constructor(private readonly service: TodoService) {}

  handleCreate(req: Request): Response {
    try {
      // ❌ Нет нормальной валидации входных данных
      const body = JSON.parse(req.body ?? "{}")
      const todo = this.service.createTodo(body.title)
      return new Response(JSON.stringify(todo), {
        status: 201,
        headers: { "Content-Type": "application/json" },
      })
    } catch (error) {
      // ❌ Один catch на все ошибки — невозможно отличить
      // бизнес-ошибку от инфраструктурной
      return new Response(
        JSON.stringify({ error: (error as Error).message }),
        { status: 400 }
      )
    }
  }

  handleList(_req: Request): Response {
    try {
      const todos = this.service.listTodos()
      return new Response(JSON.stringify(todos), {
        headers: { "Content-Type": "application/json" },
      })
    } catch (error) {
      return new Response(
        JSON.stringify({ error: "Internal server error" }),
        { status: 500 }
      )
    }
  }
}

Сборка

// main.ts — ручная сборка зависимостей
import { Database } from "bun:sqlite"
import { TodoRepository } from "./data-access/todo-repository"
import { TodoService } from "./business-logic/todo-service"
import { TodoController } from "./presentation/todo-controller"

const db = new Database("todos.sqlite")
const repo = new TodoRepository(db)
const service = new TodoService(repo)
const controller = new TodoController(service)

Направление зависимостей: ключевая проблема

Рассмотрим граф зависимостей в коде выше:

TodoController → TodoService → TodoRepository → Database (bun:sqlite)

Стрелка означает «импортирует / знает о / зависит от». Проблема в том, что зависимости направлены вниз к инфраструктуре:

Presentation ──→ Business Logic ──→ Data Access ──→ SQLite

                      │  Бизнес-логика ЗАВИСИТ
                      │  от формата хранения

              TodoRow (number для boolean,
                       string для Date)

TodoService — слой бизнес-логики — знает, что SQLite хранит boolean как 0/1 и Date как строку. Он вынужден заниматься маппингом (completed ? 1 : 0), что означает утечку инфраструктурных деталей в бизнес-логику.

Это нарушение фундаментального принципа: бизнес-логика не должна зависеть от деталей хранения. Если мы заменим SQLite на PostgreSQL (где boolean — настоящий тип), нам придётся переписывать TodoService, хотя бизнес-правила не изменились.


Преимущества слоистой архитектуры

Несмотря на недостатки, слоистая архитектура не случайно стала стандартом. Она даёт ряд ощутимых преимуществ:

1. Простота понимания

Слоистая архитектура интуитивна. Любой разработчик, впервые открывший проект, сразу понимает: контроллеры обрабатывают запросы, сервисы содержат логику, репозитории работают с данными. Это снижает порог входа и ускоряет онбординг.

2. Разделение ответственности

Каждый слой занимается своим делом. Пусть разделение несовершенно, но оно существует: вы не увидите SQL-запросы в контроллере (если правила соблюдаются). Это лучше, чем monolithic script, где всё в одном файле.

3. Привычность и экосистема

Большинство фреймворков построены вокруг слоистой модели: Express/Fastify (controller → service → repository), Spring (тот же паттерн), Django (views → models), Rails (controllers → models). Это означает обилие примеров, паттернов, библиотек и инструментов.

4. Достаточна для простых приложений

Для CRUD-приложения с минимальной бизнес-логикой слоистая архитектура — отличный выбор. Если ваш «бизнес-слой» сводится к «получи данные, проверь пару условий, сохрани» — не нужна сложная архитектура.


Фундаментальные ограничения

1. Направление зависимостей привязывает бизнес-логику к инфраструктуре

Это главная проблема. В слоистой архитектуре зависимости указывают сверху вниз:

Presentation → Business → Data Access → Database

Бизнес-слой зависит от Data Access Layer. Это означает, что TodoService импортирует TodoRepository, который привязан к конкретной технологии. Смена технологии хранения требует изменения бизнес-логики.

Сравните с подходом, где зависимости указывают внутрь (как в Hexagonal):

HTTP Adapter → [Business Logic (центр)] ← SQLite Adapter

Бизнес-логика не знает ни о HTTP, ни о SQLite. Она определяет контракт (порт), а конкретные технологии реализуют этот контракт.

2. Тестирование бизнес-логики требует инфраструктуры

Поскольку TodoService напрямую зависит от TodoRepository, а тот — от Database, для тестирования бизнес-логики нужно либо поднимать реальную базу данных, либо создавать моки/стабы. Оба варианта проблематичны:

// Тест для TodoService в слоистой архитектуре
// Нужен мок на конкретный класс — хрупкий тест
import { mock } from "bun:test"

test("createTodo validates title", () => {
  // ❌ Мы мокаем конкретную реализацию, а не контракт
  const mockRepo = {
    save: mock(() => {}),
    findById: mock(() => null),
    findAll: mock(() => []),
    deleteById: mock(() => {}),
  } as unknown as TodoRepository

  const service = new TodoService(mockRepo)
  expect(() => service.createTodo("")).toThrow("Title cannot be empty")
})

Проблема: мок привязан к конкретным методам конкретного класса. Если TodoRepository добавит метод, мок сломается. Если изменится сигнатура — тест перестанет компилироваться, хотя бизнес-правило «заголовок не может быть пустым» не изменилось.

3. Утечка абстракций между слоями

В теории слои изолированы. На практике типы Data Access Layer «просачиваются» вверх:

// ❌ Бизнес-слой работает с TodoRow — типом базы данных
const row = this.repo.findById(id) // возвращает TodoRow | null
// Нужно маппить row.completed (number) → todo.completed (boolean)

Это означает, что изменение схемы таблицы в БД потребует изменений в бизнес-логике. Формально слои разделены, фактически — связаны через типы данных.

4. «Сквозная болезнь»: God Service

По мере роста приложения бизнес-слой превращается в «God Service» — класс с десятками методов:

// ❌ Через 6 месяцев разработки
export class TodoService {
  createTodo(title: string): Todo { /* ... */ }
  completeTodo(id: string): Todo { /* ... */ }
  reopenTodo(id: string): Todo { /* ... */ }
  archiveTodo(id: string): void { /* ... */ }
  changePriority(id: string, priority: Priority): Todo { /* ... */ }
  assignTo(id: string, userId: string): Todo { /* ... */ }
  addTag(id: string, tag: string): Todo { /* ... */ }
  removeTag(id: string, tag: string): Todo { /* ... */ }
  setDueDate(id: string, date: Date): Todo { /* ... */ }
  moveTodoToList(id: string, listId: string): Todo { /* ... */ }
  duplicateTodo(id: string): Todo { /* ... */ }
  bulkComplete(ids: ReadonlyArray<string>): void { /* ... */ }
  bulkDelete(ids: ReadonlyArray<string>): void { /* ... */ }
  getStatistics(): TodoStats { /* ... */ }
  searchTodos(query: string): ReadonlyArray<Todo> { /* ... */ }
  exportTodos(format: "json" | "csv"): string { /* ... */ }
  // ... ещё 20 методов
}

Каждый новый метод увеличивает число зависимостей класса, усложняет тестирование и снижает cohesion. Класс отвечает за слишком многое.

5. Невозможность независимой замены технологий

Переход с SQLite на PostgreSQL в слоистой архитектуре выглядит так:

  1. Переписать TodoRepository (Data Access Layer) ← ожидаемо
  2. Переписать маппинг в TodoService (Business Logic) ← неожиданно
  3. Обновить типы TodoRow, которые использует TodoServiceнежелательно
  4. Обновить все тесты TodoService, которые мокают TodoRepositoryраздражающе

В идеальном мире смена технологии хранения должна затрагивать только слой хранения. Слоистая архитектура этого не гарантирует.

6. Гравитация к базе данных (Database-Driven Design)

Слоистая архитектура провоцирует проектирование «от базы данных»:

  1. Создаём таблицу в БД
  2. Генерируем модель из таблицы (ORM)
  3. Пишем сервис вокруг модели
  4. Пишем контроллер вокруг сервиса

Это Database-Driven Design — противоположность Domain-Driven Design. Бизнес-логика становится «обёрткой» над CRUD-операциями, а не самостоятельной ценностью. Сложные бизнес-правила некуда положить, кроме как в раздутые сервисы.


Когда слоистая архитектура — правильный выбор

Слоистая архитектура не «плохая». Она неуместна для определённого класса задач:

Используйте слоистую архитектуру, когда:

  • Приложение преимущественно CRUD (создание, чтение, обновление, удаление)
  • Бизнес-логика минимальна (валидация полей, простые проверки)
  • Команда маленькая (1–3 человека) и проект короткоживущий
  • Прототип или MVP, где скорость важнее устойчивости

НЕ используйте слоистую архитектуру, когда:

  • Сложная бизнес-логика (финансы, медицина, логистика)
  • Необходимость заменять инфраструктуру (миграция БД, смена провайдера)
  • Высокие требования к тестируемости
  • Долгоживущий проект с растущей командой
  • Несколько «фронтов» (HTTP, CLI, очереди, WebSocket)

Эволюция от слоистой к концентрической

Ограничения слоистой архитектуры привели к появлению альтернативных подходов. Ключевая идея эволюции — инверсия зависимостей: вместо «сверху вниз» зависимости направлены снаружи внутрь:

СЛОИСТАЯ:          КОНЦЕНТРИЧЕСКАЯ:

  Presentation        ┌────────────┐
       │               │            │
       ▼               │  Adapters  │
  Business Logic       │  ┌──────┐  │
       │               │  │Domain│  │
       ▼               │  └──────┘  │
  Data Access          │            │
       │               └────────────┘

  Database           Всё указывает внутрь →

Эта идея — стержень Clean Architecture, Onion Architecture и Hexagonal Architecture, которые мы рассмотрим в следующих разделах.


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

  1. Слоистая архитектура — самый простой и интуитивный архитектурный стиль, который хорошо работает для CRUD-приложений.

  2. Направление зависимостей — её фундаментальный недостаток. Зависимости направлены вниз к инфраструктуре, что привязывает бизнес-логику к конкретным технологиям.

  3. Утечка абстракций между слоями неизбежна: типы базы данных проникают в бизнес-логику, делая слои связанными сильнее, чем предполагалось.

  4. Database-Driven Design — естественное следствие слоистой архитектуры, когда проектирование начинается с таблиц БД, а бизнес-логика становится тонкой обёрткой над CRUD.

  5. Концентрические архитектуры (Clean, Onion, Hexagonal) решают эти проблемы путём инверсии зависимостей: бизнес-логика находится в центре и ни от чего не зависит.


Далее: Clean Architecture — как Uncle Bob формализовал идею «зависимости направлены внутрь» и какие концепции из неё стали стандартом индустрии.