Классическая слоистая архитектура
Самый распространённый архитектурный стиль — 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 в слоистой архитектуре выглядит так:
- Переписать
TodoRepository(Data Access Layer) ← ожидаемо - Переписать маппинг в
TodoService(Business Logic) ← неожиданно - Обновить типы
TodoRow, которые используетTodoService← нежелательно - Обновить все тесты
TodoService, которые мокаютTodoRepository← раздражающе
В идеальном мире смена технологии хранения должна затрагивать только слой хранения. Слоистая архитектура этого не гарантирует.
6. Гравитация к базе данных (Database-Driven Design)
Слоистая архитектура провоцирует проектирование «от базы данных»:
- Создаём таблицу в БД
- Генерируем модель из таблицы (ORM)
- Пишем сервис вокруг модели
- Пишем контроллер вокруг сервиса
Это 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, которые мы рассмотрим в следующих разделах.
Ключевые выводы
-
Слоистая архитектура — самый простой и интуитивный архитектурный стиль, который хорошо работает для CRUD-приложений.
-
Направление зависимостей — её фундаментальный недостаток. Зависимости направлены вниз к инфраструктуре, что привязывает бизнес-логику к конкретным технологиям.
-
Утечка абстракций между слоями неизбежна: типы базы данных проникают в бизнес-логику, делая слои связанными сильнее, чем предполагалось.
-
Database-Driven Design — естественное следствие слоистой архитектуры, когда проектирование начинается с таблиц БД, а бизнес-логика становится тонкой обёрткой над CRUD.
-
Концентрические архитектуры (Clean, Onion, Hexagonal) решают эти проблемы путём инверсии зависимостей: бизнес-логика находится в центре и ни от чего не зависит.
Далее: Clean Architecture — как Uncle Bob формализовал идею «зависимости направлены внутрь» и какие концепции из неё стали стандартом индустрии.