Clean Architecture (Uncle Bob)
Концентрические круги Роберта Мартина: Entities → Use Cases → Interface Adapters → Frameworks. Разберём ключевые идеи — Dependency Rule, разделение на политики и механизмы, границы между слоями — и оценим, как Clean Architecture повлияла на индустрию и где она избыточна.
Контекст и происхождение
Clean Architecture была представлена Робертом Мартином (Uncle Bob) в 2012 году в статье на блоге, а позже подробно описана в книге «Clean Architecture: A Craftsman’s Guide to Software Structure and Design» (2017). Это не изобретение с нуля, а синтез нескольких предшествующих идей:
- Hexagonal Architecture (Alistair Cockburn, 2005) — порты и адаптеры
- Onion Architecture (Jeffrey Palermo, 2008) — концентрические слои
- DCI Architecture (Trygve Reenskaug, James Coplien) — Data, Context, Interaction
- BCE (Ivar Jacobson) — Boundary, Control, Entity
Мартин заметил, что все эти подходы разделяют одну фундаментальную идею: разделение ответственности через концентрические слои с зависимостями, направленными внутрь. Clean Architecture — его попытка формализовать эту общую идею.
Диаграмма Clean Architecture
Каноническая диаграмма Clean Architecture — это четыре концентрических кольца:
┌──────────────────────────────────────────────────────────┐
│ Frameworks & Drivers │
│ (Web, UI, DB, Devices, External Interfaces) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ (Controllers, Gateways, Presenters) │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Application Business Rules │ │ │
│ │ │ (Use Cases) │ │ │
│ │ │ ┌────────────────────────────────────────┐ │ │ │
│ │ │ │ Enterprise Business Rules │ │ │ │
│ │ │ │ (Entities) │ │ │ │
│ │ │ └────────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Все стрелки зависимостей → внутрь
Четыре кольца: от центра к периферии
Кольцо 1: Entities (Enterprise Business Rules)
Самый внутренний слой — сущности. Это объекты, инкапсулирующие самые общие и высокоуровневые бизнес-правила. Entities не зависят ни от чего: ни от фреймворков, ни от базы данных, ни от UI.
В терминологии DDD это Domain Model: Entity, Value Object, Aggregate.
// entities/todo.ts
// ❗ Нулевые зависимости от инфраструктуры
/** Идентификатор задачи — Branded Type для типобезопасности */
type TodoId = string & { readonly _brand: unique symbol }
/** Приоритет как перечисление бизнес-значений */
type Priority = "low" | "medium" | "high" | "critical"
/** Статус с бизнес-правилами переходов */
type TodoStatus = "pending" | "in_progress" | "completed" | "archived"
/** Сущность Todo — чистая бизнес-логика */
interface Todo {
readonly id: TodoId
readonly title: string
readonly description: string
readonly status: TodoStatus
readonly priority: Priority
readonly createdAt: Date
readonly completedAt: Date | null
}
/** Бизнес-правило: какие переходы статуса допустимы */
const VALID_TRANSITIONS: Record<TodoStatus, ReadonlyArray<TodoStatus>> = {
pending: ["in_progress", "archived"],
in_progress: ["completed", "pending", "archived"],
completed: ["archived"],
archived: [],
} as const
/** Чистая функция: проверка допустимости перехода */
const canTransition = (
from: TodoStatus,
to: TodoStatus
): boolean =>
VALID_TRANSITIONS[from].includes(to)
/** Чистая функция: создание новой задачи */
const createTodo = (
id: TodoId,
title: string,
priority: Priority
): Todo => ({
id,
title: title.trim(),
description: "",
status: "pending",
priority,
createdAt: new Date(),
completedAt: null,
})
/** Чистая функция: завершение задачи */
const completeTodo = (
todo: Todo
): Todo =>
canTransition(todo.status, "completed")
? { ...todo, status: "completed", completedAt: new Date() }
: todo // или бросить доменную ошибку
Обратите внимание: никаких import из внешних библиотек, никаких SQL, HTTP, JSON. Чистый TypeScript, чистые функции, чистые типы.
Кольцо 2: Use Cases (Application Business Rules)
Второе кольцо — варианты использования (use cases). Они описывают, что система делает: принимают входные данные, оркестрируют вызов сущностей и возвращают результат. Use Cases содержат application-specific бизнес-правила — те, что специфичны для конкретного приложения, а не для предметной области в целом.
// use-cases/create-todo.ts
/** Входные данные Use Case — DTO на границе */
interface CreateTodoInput {
readonly title: string
readonly priority: Priority
}
/** Выходные данные Use Case — DTO на границе */
interface CreateTodoOutput {
readonly id: TodoId
readonly title: string
readonly status: TodoStatus
readonly createdAt: Date
}
/**
* Интерфейс Use Case — контракт, НЕ реализация.
* Use Case зависит от интерфейсов (портов), а не от конкретных реализаций.
*/
interface CreateTodoUseCase {
execute(input: CreateTodoInput): Promise<CreateTodoOutput>
}
/**
* Интерфейс репозитория — это ПОРТ, определённый на уровне Use Cases.
* Реализация будет в слое Interface Adapters или Frameworks.
*/
interface TodoRepository {
save(todo: Todo): Promise<void>
existsByTitle(title: string): Promise<boolean>
}
/**
* Реализация Use Case.
* Зависит от интерфейсов (TodoRepository), а не от конкретных классов.
*/
class CreateTodoInteractor implements CreateTodoUseCase {
constructor(
private readonly todoRepo: TodoRepository,
private readonly idGenerator: () => TodoId
) {}
async execute(input: CreateTodoInput): Promise<CreateTodoOutput> {
// Application rule: проверка дубликатов (не доменное правило!)
const exists = await this.todoRepo.existsByTitle(input.title)
if (exists) {
throw new DuplicateTodoError(input.title)
}
// Domain rule: создание сущности
const todo = createTodo(
this.idGenerator(),
input.title,
input.priority
)
// Persistence через интерфейс (порт)
await this.todoRepo.save(todo)
// Output DTO — только нужные данные
return {
id: todo.id,
title: todo.title,
status: todo.status,
createdAt: todo.createdAt,
}
}
}
Ключевой момент: CreateTodoInteractor зависит от TodoRepository — интерфейса, а не конкретного класса. Это Dependency Inversion Principle (DIP) в действии.
Кольцо 3: Interface Adapters
Третье кольцо — адаптеры интерфейсов. Здесь живут Controllers, Presenters, Gateways. Их задача — конвертировать данные из формата, удобного для Use Cases и Entities, в формат, удобный для внешних агентов (web, DB, файлы).
// interface-adapters/controllers/todo-controller.ts
/** Controller — адаптер между HTTP и Use Case */
class TodoHttpController {
constructor(private readonly createTodo: CreateTodoUseCase) {}
async handleCreate(request: HttpRequest): Promise<HttpResponse> {
// Конвертация HTTP → Use Case Input
const input: CreateTodoInput = {
title: request.body.title,
priority: request.body.priority ?? "medium",
}
try {
const output = await this.createTodo.execute(input)
// Конвертация Use Case Output → HTTP Response
return {
status: 201,
body: {
id: output.id,
title: output.title,
status: output.status,
createdAt: output.createdAt.toISOString(),
},
}
} catch (error) {
if (error instanceof DuplicateTodoError) {
return { status: 409, body: { error: error.message } }
}
return { status: 500, body: { error: "Internal error" } }
}
}
}
// interface-adapters/gateways/sqlite-todo-repository.ts
/** Gateway — адаптер между Use Case Interface и конкретной технологии */
class SqliteTodoRepository implements TodoRepository {
constructor(private readonly db: Database) {}
async save(todo: Todo): Promise<void> {
// Конвертация Domain Entity → SQLite Row
this.db.query(
`INSERT OR REPLACE INTO todos (id, title, description, status, priority, created_at, completed_at)
VALUES ($id, $title, $desc, $status, $priority, $created, $completed)`
).run({
$id: todo.id,
$title: todo.title,
$desc: todo.description,
$status: todo.status,
$priority: todo.priority,
$created: todo.createdAt.toISOString(),
$completed: todo.completedAt?.toISOString() ?? null,
})
}
async existsByTitle(title: string): Promise<boolean> {
const row = this.db
.query("SELECT 1 FROM todos WHERE title = ?")
.get(title)
return row !== null
}
}
Кольцо 4: Frameworks & Drivers
Самое внешнее кольцо — фреймворки и драйверы. Здесь живёт конкретный код: Express/Bun HTTP server, SQLite driver, React UI. Это «клей», который соединяет адаптеры с реальным миром.
// frameworks/web/bun-server.ts
import { serve } from "bun"
// Инициализация: сборка всех слоёв
const db = new Database("todos.sqlite")
const todoRepo = new SqliteTodoRepository(db)
const idGen = () => crypto.randomUUID() as TodoId
const createTodoUseCase = new CreateTodoInteractor(todoRepo, idGen)
const controller = new TodoHttpController(createTodoUseCase)
serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url)
if (req.method === "POST" && url.pathname === "/todos") {
return controller.handleCreate(req)
}
return new Response("Not Found", { status: 404 })
},
})
The Dependency Rule (Правило зависимостей)
Самое важное правило Clean Architecture:
Зависимости в исходном коде могут указывать только ВНУТРЬ.
Ничто во внутреннем кольце не может знать о чём-либо во внешнем кольце.
Это означает:
- Entities не импортируют ничего из Use Cases, Adapters, Frameworks
- Use Cases не импортируют ничего из Adapters, Frameworks
- Interface Adapters не импортируют ничего из Frameworks
Правило работает через Dependency Inversion Principle: внутренний слой определяет интерфейс (абстракцию), а внешний слой его реализует:
Use Cases определяет: interface TodoRepository { save(todo: Todo): Promise<void> }
▲
│ implements
│
Adapters реализует: class SqliteTodoRepository implements TodoRepository { ... }
Зависимость в исходном коде направлена внутрь (SqliteTodoRepository знает о TodoRepository), хотя зависимость на этапе выполнения направлена наружу (Use Case вызывает метод SqliteTodoRepository).
Crossing Boundaries: как данные пересекают границы слоёв
Когда данные перемещаются между слоями, они конвертируются в формат, удобный для принимающего слоя. Мартин настаивает на использовании простых структур данных (DTOs) для передачи:
HTTP Request (JSON)
│
▼
[Controller] → CreateTodoInput (DTO)
│
▼
[Use Case] → Todo (Entity)
│
▼
[Repository] → SQL INSERT
На каждой границе происходит маппинг: Controller преобразует HTTP-запрос в Input DTO. Use Case работает с Entity. Repository преобразует Entity в SQL-запрос. Каждый слой работает со «своими» типами данных.
Ключевые концепции Clean Architecture
Input/Output Boundaries
Мартин вводит понятие Input Boundary и Output Boundary — интерфейсы, через которые данные входят и выходят из Use Case:
// Input Boundary — интерфейс для входящего потока
interface CreateTodoInputBoundary {
execute(input: CreateTodoInput): Promise<void>
}
// Output Boundary — интерфейс для исходящего потока
interface CreateTodoOutputBoundary {
presentSuccess(output: CreateTodoOutput): void
presentError(error: ApplicationError): void
}
// Use Case Interactor — реализует Input Boundary, использует Output Boundary
class CreateTodoInteractor implements CreateTodoInputBoundary {
constructor(
private readonly todoRepo: TodoRepository,
private readonly presenter: CreateTodoOutputBoundary
) {}
async execute(input: CreateTodoInput): Promise<void> {
try {
const todo = createTodo(/* ... */)
await this.todoRepo.save(todo)
this.presenter.presentSuccess({ /* ... */ })
} catch (error) {
this.presenter.presentError(error as ApplicationError)
}
}
}
Обратите внимание: Interactor не возвращает данные. Вместо этого он вызывает Presenter через Output Boundary. Это позволяет Use Case не знать, как будет представлен результат — как JSON-ответ, как view в UI, или как сообщение в очередь.
Этот паттерн (Output Boundary + Presenter) — одна из наиболее спорных деталей Clean Architecture. На практике многие проекты упрощают его до простого return:
// Упрощённый вариант — используется чаще
async execute(input: CreateTodoInput): Promise<CreateTodoOutput> {
const todo = createTodo(/* ... */)
await this.todoRepo.save(todo)
return { id: todo.id, title: todo.title, /* ... */ }
}
Screaming Architecture
Мартин утверждает, что архитектура приложения должна «кричать» о его назначении. Глядя на верхний уровень директорий, вы должны понимать: «это система управления задачами», а не «это приложение на Express с SQLite»:
❌ Техническая структура (кричит о фреймворках):
src/
controllers/
services/
repositories/
models/
middleware/
✅ Доменная структура (кричит о бизнесе):
src/
todo/
entities/
use-cases/
adapters/
user/
entities/
use-cases/
adapters/
shared/
domain/
Clean Architecture и SOLID
Clean Architecture тесно связана с принципами SOLID, особенно с двумя:
Dependency Inversion Principle (DIP)
«Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.»
Это и есть The Dependency Rule. Use Case (верхний уровень) не зависит от SQLite Repository (нижний уровень). Оба зависят от интерфейса TodoRepository (абстракция).
Interface Segregation Principle (ISP)
«Клиенты не должны зависеть от интерфейсов, которые они не используют.»
Если Use Case нужен только метод save(), интерфейс порта должен содержать только save(), а не весь CRUD:
// ✅ ISP: минимальный интерфейс для конкретного Use Case
interface TodoSaver {
save(todo: Todo): Promise<void>
}
// ❌ Нарушение ISP: Use Case зависит от методов, которые не использует
interface TodoRepository {
save(todo: Todo): Promise<void>
findById(id: TodoId): Promise<Todo | null>
findAll(): Promise<ReadonlyArray<Todo>>
delete(id: TodoId): Promise<void>
count(): Promise<number>
}
На практике обычно используют один интерфейс TodoRepository с несколькими методами — это компромисс между чистотой ISP и практичностью.
Критика Clean Architecture
1. Избыточность для простых приложений
Четыре кольца, Input/Output Boundaries, Presenters, DTOs на каждой границе — это много «церемониала» для простого CRUD. Если бизнес-логика сводится к «сохрани в базу», Clean Architecture превращается в over-engineering.
2. Presenter/Output Boundary: усложнение без выгоды
Паттерн Output Boundary + Presenter редко используется в реальных проектах. Большинство команд возвращают данные из Use Case напрямую:
// Как описано в книге:
interactor → presenter → viewModel → view
// Как делают на практике:
interactor → return DTO → controller → HTTP response
3. Количество маппингов
Данные конвертируются на каждой границе: HTTP → Input DTO → Entity → Output DTO → HTTP Response. Для Todo с 5 полями это терпимо. Для системы с десятками сущностей по 50 полей — это сотни строк маппинга, который нужно писать и поддерживать.
4. Слабая конкретика
Книга Мартина описывает принципы, но даёт мало конкретных рецептов. Два разработчика, прочитавших книгу, могут построить совершенно разные архитектуры и оба будут утверждать, что следуют Clean Architecture.
5. Нечёткая граница между Entities и Use Cases
На практике бывает сложно определить, является ли правило «доменным» (Entities) или «прикладным» (Use Cases). Проверка «заголовок не может быть пустым» — это доменное правило или прикладное? Разные разработчики ответят по-разному.
Clean Architecture и Effect-ts: первый взгляд
Effect-ts предоставляет инструменты, которые естественно реализуют ключевые концепции Clean Architecture:
import { Effect, Context, Layer } from "effect"
// 1. Entity (Enterprise Business Rules) — чистые типы и функции
interface Todo {
readonly id: string
readonly title: string
readonly completed: boolean
}
// 2. Use Case Port — интерфейс через Effect Service
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void>
readonly findById: (id: string) => Effect.Effect<Todo | null>
}
>() {}
// 3. Use Case — Effect-программа с зависимостями в R-канале
const createTodo = (title: string): Effect.Effect<
Todo, // A: результат
ValidationError, // E: ошибка
TodoRepository // R: зависимость (= порт)
> =>
Effect.gen(function* () {
if (title.trim().length === 0) {
return yield* Effect.fail(new ValidationError("Empty title"))
}
const todo: Todo = { id: crypto.randomUUID(), title, completed: false }
const repo = yield* TodoRepository
yield* repo.save(todo)
return todo
})
// 4. Adapter — Layer, реализующий порт
const InMemoryTodoRepository = Layer.succeed(
TodoRepository,
{
save: (_todo) => Effect.void,
findById: (_id) => Effect.succeed(null),
}
)
// 5. Dependency Rule обеспечивается КОМПИЛЯТОРОМ
// createTodo зависит от TodoRepository (интерфейса),
// а не от InMemoryTodoRepository (реализации)
Здесь Context.Tag выступает ролью Input/Output Boundary, Layer — ролью Interface Adapter, а R-канал Effect<A, E, R> делает зависимости видимыми на уровне типов.
Ключевые выводы
-
Clean Architecture — синтез предшествующих концентрических архитектур (Hexagonal, Onion, DCI) с единой формализацией через The Dependency Rule.
-
Четыре кольца (Entities → Use Cases → Interface Adapters → Frameworks & Drivers) организуют код от самого стабильного (бизнес-правила) к самому изменчивому (технологии).
-
The Dependency Rule — ядро Clean Architecture: зависимости в исходном коде указывают только внутрь. Это достигается через Dependency Inversion Principle.
-
Screaming Architecture — структура проекта должна отражать предметную область, а не используемые технологии.
-
Критика — избыточность для простых приложений, слишком много маппинга, нечёткие границы между слоями, недостаток конкретных рецептов.
-
Effect-ts естественно реализует The Dependency Rule через систему Service/Layer/Context, делая зависимости явными на уровне типов.
Далее: Onion Architecture Джеффри Палермо — более ранняя и более компактная версия тех же идей.