Driving (Primary) vs Driven (Secondary): направление потока
Driving — внешний мир управляет приложением; Driven — приложение использует внешний мир. Полный поток от HTTP до SQLite. Асимметрия реализации, множественные адаптеры, типичные ошибки смешения направлений.
Введение: два направления взаимодействия
Каждое взаимодействие Application Core с внешним миром имеет направление. Кто инициирует взаимодействие? Кто кого вызывает? Ответ на этот вопрос делит все порты и адаптеры на две категории:
- Driving (Primary) — внешний мир управляет приложением: «Хочу создать задачу»
- Driven (Secondary) — приложение использует внешний мир: «Нужно сохранить задачу»
Это фундаментальное разделение, определяющее архитектуру всей системы.
Driving (Primary): кто управляет приложением
Определение
Driving-взаимодействие инициируется извне. Внешний актор (пользователь, другой сервис, тестовый фреймворк) отправляет команду или запрос Application Core.
Driving Actor → Driving Adapter → Driving Port → Application Core
Driving Actor Driving Adapter Driving Port Application Core
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌───────────────┐
│Пользо- │ HTTP │ HTTP │ execute│ CreateTodo│ │ Use Case: │
│ватель в │ Request │ Router + │───────►│ UseCase │──────►│ createTodo() │
│браузере │─────────►│ Handler │ │ │ │ │
└──────────┘ └──────────────┘ └──────────┘ └───────────────┘
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌───────────────┐
│Разработ- │ CLI │ CLI │ execute│ ListTodos│ │ Use Case: │
│чик в │ command │ Parser + │───────►│ UseCase │──────►│ listTodos() │
│терминале │─────────►│ Handler │ │ │ │ │
└──────────┘ └──────────────┘ └──────────┘ └───────────────┘
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌───────────────┐
│Тестовый │ direct │ Test │ execute│ Complete │ │ Use Case: │
│фреймворк│ call │ Setup + │───────►│ TodoUC │──────►│completeTodo() │
│(bun:test)│─────────►│ Assertion │ │ │ │ │
└──────────┘ └──────────────┘ └──────────┘ └───────────────┘
Характеристики Driving-стороны
| Свойство | Описание |
|---|---|
| Инициатор | Внешний актор (пользователь, сервис, тест) |
| Направление | Снаружи → Внутрь |
| Порт определяет | Что можно попросить у приложения |
| Адаптер знает | Как преобразовать внешний формат в доменный |
| Порт реализует | Application Core (Use Cases) |
| Примеры адаптеров | HTTP Server, CLI Parser, gRPC Server, Message Consumer, Test Harness |
Driving Port в Effect-ts
Driving Port — это обычно интерфейс Use Case, определённый через Context.Tag:
// Driving Port: что внешний мир может попросить
class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
CreateTodoUseCase,
{
readonly execute: (
input: CreateTodoInput
) => Effect.Effect<Todo, ValidationError | DuplicateTitle>
}
>() {}
class CompleteTodoUseCase extends Context.Tag("CompleteTodoUseCase")<
CompleteTodoUseCase,
{
readonly execute: (
todoId: TodoId
) => Effect.Effect<Todo, TodoNotFound | InvalidTransition>
}
>() {}
class ListTodosUseCase extends Context.Tag("ListTodosUseCase")<
ListTodosUseCase,
{
readonly execute: (
filter: TodoFilter
) => Effect.Effect<ReadonlyArray<TodoView>>
}
>() {}
Driving Adapter в Effect-ts
Driving Adapter преобразует внешний запрос в вызов Use Case:
// HTTP Driving Adapter
const httpDrivingAdapter = HttpRouter.make(
HttpRouter.post("/todos",
Effect.gen(function* () {
// 1. Получить данные из HTTP-запроса
const request = yield* HttpServerRequest.HttpServerRequest
const body = yield* request.json
// 2. Преобразовать в доменный Input
const input = yield* Schema.decode(CreateTodoInput)(body)
// 3. Вызвать Driving Port (Use Case)
const useCase = yield* CreateTodoUseCase
const todo = yield* useCase.execute(input)
// 4. Преобразовать доменный результат в HTTP-ответ
return HttpServerResponse.json(
yield* Schema.encode(TodoResponse)(todo),
{ status: 201 }
)
})
)
)
Альтернативный подход: функция как Driving Port
Не обязательно оформлять каждый Use Case как Context.Tag. Часто бывает достаточно простой функции, которая объявляет свои зависимости через R-канал:
// Use Case как функция (более лёгкий подход)
const createTodo = (
input: CreateTodoInput
): Effect.Effect<
Todo,
ValidationError | DuplicateTitle | RepositoryError,
TodoRepository | Clock | IdGenerator
> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const clock = yield* Clock
const idGen = yield* IdGenerator
const title = yield* Schema.decode(TodoTitle)({ value: input.title })
yield* checkTitleUniqueness(title)
const todo = new Todo({
id: yield* idGen.generate(),
title,
status: "active",
createdAt: yield* clock.now(),
})
yield* repo.save(todo)
return todo
})
// Driving Adapter вызывает функцию напрямую
HttpRouter.post("/todos",
Effect.gen(function* () {
const body = yield* parseBody(CreateTodoInput)
const todo = yield* createTodo(body) // ← прямой вызов Use Case функции
return HttpServerResponse.json(todo, { status: 201 })
})
)
Оба подхода валидны. Context.Tag для Use Cases полезен, когда:
- Use Case имеет сложную инициализацию
- Нужно подменять реализацию Use Case (редко)
- В системе есть middleware на уровне Use Case
Функциональный подход проще и предпочтителен для большинства случаев.
Driven (Secondary): что приложение использует
Определение
Driven-взаимодействие инициируется изнутри. Application Core нуждается в чём-то от внешнего мира: сохранить данные, отправить письмо, получить время.
Application Core → Driven Port → Driven Adapter → External System
Application Core Driven Port Driven Adapter External System
┌───────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Use Case: │ │ Todo │ │ SQLite │ │ SQLite │
│ createTodo() │────►│Repository│───────►│ Repository │─────►│ Database │
│ │ │ │ │ Adapter │ │ │
└───────────────┘ └──────────┘ └──────────────┘ └──────────┘
┌───────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Use Case: │ │Notifica- │ │ SMTP │ │ Email │
│ createTodo() │────►│tion │───────►│ Notification│─────►│ Server │
│ │ │Service │ │ Adapter │ │ │
└───────────────┘ └──────────┘ └──────────────┘ └──────────┘
┌───────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Use Case: │ │ │ │ System │ │ OS │
│ createTodo() │────►│ Clock │───────►│ Clock │─────►│ Time │
│ │ │ │ │ Adapter │ │ │
└───────────────┘ └──────────┘ └──────────────┘ └──────────┘
Характеристики Driven-стороны
| Свойство | Описание |
|---|---|
| Инициатор | Application Core |
| Направление | Изнутри → Наружу |
| Порт определяет | Что приложению нужно от инфраструктуры |
| Адаптер знает | Как реализовать потребность конкретной технологией |
| Порт реализует | Адаптер (инфраструктурный код) |
| Примеры адаптеров | SQLite Repository, SMTP Mailer, FileSystem, System Clock, HTTP Client |
Driven Port в Effect-ts
// Driven Port: что приложению нужно от инфраструктуры
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly findAll: (filter: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>>
readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
}
>() {}
class NotificationService extends Context.Tag("NotificationService")<
NotificationService,
{
readonly send: (n: Notification) => Effect.Effect<void, NotificationError>
}
>() {}
class Clock extends Context.Tag("Clock")<
Clock,
{
readonly now: () => Effect.Effect<Date>
}
>() {}
Полный поток данных: от HTTP до SQLite и обратно
Рассмотрим полный поток запроса на создание задачи — от момента, когда пользователь нажимает кнопку в браузере, до момента, когда данные оказываются в базе:
Шаг 1: HTTP Request
POST /todos { "title": "Купить молоко" }
│
▼
Шаг 2: Driving Adapter (HTTP Router)
• Парсит JSON из body
• Декодирует через Schema → CreateTodoInput
• Маппит HTTP-ошибки парсинга → 400
│
▼
Шаг 3: Driving Port (CreateTodoUseCase.execute)
• Получает CreateTodoInput
• Возвращает Effect<Todo, ValidationError | DuplicateTitle, ...>
│
▼
Шаг 4: Application Core (Use Case)
• Валидирует через Schema → TodoTitle
• Проверяет уникальность через Driven Port (TodoRepository.existsByTitle)
• Генерирует ID через Driven Port (IdGenerator.generate)
• Получает время через Driven Port (Clock.now)
• Создаёт Todo (доменная модель)
• Сохраняет через Driven Port (TodoRepository.save)
│
▼
Шаг 5: Driven Port (TodoRepository.save)
• Получает Todo (доменный тип)
• Ожидает Effect<void, RepositoryError>
│
▼
Шаг 6: Driven Adapter (SQLite Repository)
• Маппит Todo → TodoRow (SQL-формат)
• Выполняет INSERT OR REPLACE INTO todos ...
• Маппит SQLite Error → RepositoryError (если есть)
│
▼
Шаг 7: SQLite Database
• Записывает строку в файл todos.sqlite
│
▼
Шаг 8: Обратный путь
• Driven Adapter возвращает Effect<void>
• Use Case возвращает Effect<Todo>
• Driving Adapter маппит Todo → TodoResponse (JSON)
• Driving Adapter возвращает HTTP 201 + JSON body
│
▼
Шаг 9: HTTP Response
201 Created { "id": "abc-123", "title": "Купить молоко", "status": "active" }
В коде:
// Шаги 2-3: Driving Adapter → Use Case
const httpHandler = HttpRouter.post("/todos",
Effect.gen(function* () {
// Шаг 2: HTTP → Domain Input
const body = yield* HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((r) => r.json),
Effect.flatMap(Schema.decode(CreateTodoInput))
)
// Шаг 3-4: Use Case execution
const todo = yield* createTodo(body)
// Шаг 8: Domain Output → HTTP Response
return HttpServerResponse.json(
yield* Schema.encode(TodoResponse)(todo),
{ status: 201 }
)
})
)
// Шаг 4: Use Case (Application Core)
const createTodo = (input: CreateTodoInput) =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const clock = yield* Clock
const idGen = yield* IdGenerator
const title = yield* Schema.decode(TodoTitle)({ value: input.title })
// Шаг 4a: проверка уникальности → Driven Port
const exists = yield* repo.existsByTitle(title)
if (exists) yield* Effect.fail(new DuplicateTitle({ title }))
// Шаг 4b: генерация ID → Driven Port
const id = yield* idGen.generate()
// Шаг 4c: получение времени → Driven Port
const now = yield* clock.now()
// Шаг 4d: создание доменной сущности
const todo = new Todo({ id, title, status: "active", createdAt: now })
// Шаг 5-7: сохранение → Driven Port → Adapter → SQLite
yield* repo.save(todo)
return todo
})
Визуальная модель: левая и правая стороны гексагона
Традиционно Driving-сторона рисуется слева от гексагона, а Driven-сторона — справа:
DRIVING (Left) DRIVEN (Right)
Кто управляет Что использует
приложением приложение
┌──────────────┐ ┌──────────────┐
│ HTTP Server │ │ SQLite DB │
│ (REST API) │ │ │
└──────┬───────┘ └──────▲───────┘
│ │
│ Driving ╔══════════════════╗ Driven │
│ Adapter ║ ║ Adapter │
├────────────►║ APPLICATION ║────────────►│
│ ║ CORE ║ │
┌──────┴───────┐ ║ ║ ┌───────┴──────┐
│ CLI Client │ ║ Domain Model ║ │ Email SMTP │
│ │ ║ Use Cases ║ │ Server │
└──────┬───────┘ ║ Domain Services║ └──────▲───────┘
│ ║ ║ │
├────────────►║ ║───────────►│
│ Driving ╚══════════════════╝ Driven │
│ Adapter Adapter │
┌──────┴───────┐ ┌──────┴───────┐
│ Test Runner │ │ File System │
│ (bun:test) │ │ │
└──────────────┘ └──────────────┘
Асимметрия реализации
Хотя Driving и Driven-стороны симметричны концептуально (обе используют пары порт/адаптер), их реализация асимметрична:
Driving Port: реализуется Application Core
// Driving Port: определяет, что можно попросить
class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<...>() {}
// Реализация — внутри Application Core
const CreateTodoUseCaseLive = Layer.effect(
CreateTodoUseCase,
Effect.gen(function* () {
// Use Case РЕАЛИЗУЕТ Driving Port
return {
execute: (input) => createTodo(input),
}
})
)
Driven Port: реализуется адаптером (вне Application Core)
// Driven Port: определяет, что нужно приложению
class TodoRepository extends Context.Tag("TodoRepository")<...>() {}
// Реализация — вне Application Core (в адаптере)
const SqliteTodoRepository = Layer.scoped(
TodoRepository,
Effect.gen(function* () {
// Адаптер РЕАЛИЗУЕТ Driven Port
const db = yield* SqliteClient
return { /* SQL-реализация */ }
})
)
Таблица сравнения
| Аспект | Driving | Driven |
|---|---|---|
| Кто инициирует | Внешний актор | Application Core |
| Кто определяет контракт | Application Core | Application Core |
| Кто реализует порт | Application Core | Адаптер |
| Кто реализует адаптер | Инфраструктурный код | Инфраструктурный код |
| Направление данных | Снаружи → Внутрь | Изнутри → Наружу |
| Направление зависимостей | Адаптер зависит от порта | Адаптер зависит от порта |
| Типичные примеры | HTTP, CLI, gRPC, тесты | DB, Email, FS, Clock |
| Количество адаптеров | Обычно 2–3 (HTTP+CLI+Test) | Обычно 2+ (Prod + Test) |
Множественные Driving-адаптеры: один Application Core, много входов
Одно из главных преимуществ разделения на Driving/Driven — возможность подключать несколько Driving-адаптеров к одному Application Core одновременно:
// Application Core: один набор Use Cases
const appLayer = Layer.mergeAll(
CreateTodoUseCaseLive,
CompleteTodoUseCaseLive,
ListTodosUseCaseLive,
)
// Driving Adapter 1: HTTP API
const httpApp = HttpServer.serve(TodoHttpAdapter).pipe(
Layer.provide(appLayer),
Layer.provide(productionInfrastructure),
)
// Driving Adapter 2: CLI
const cliApp = TodoCliAdapter.pipe(
Effect.provide(appLayer),
Effect.provide(productionInfrastructure),
)
// Driving Adapter 3: Cron Job
const cronApp = Effect.gen(function* () {
const useCase = yield* ArchiveCompletedTodosUseCase
yield* useCase.execute({ olderThan: Duration.days(30) })
}).pipe(
Effect.provide(appLayer),
Effect.provide(productionInfrastructure),
)
// Все три используют ОДИН Application Core
// с ОДНИМИ бизнес-правилами
Пример: одновременная работа HTTP + CLI
// main.ts — точка входа приложения
const main = Effect.gen(function* () {
const args = process.argv.slice(2)
if (args[0] === "serve") {
// Запуск HTTP-сервера (Driving Adapter: HTTP)
yield* HttpServer.serve(TodoHttpAdapter).pipe(
HttpServer.withLogAddress
)
} else {
// Выполнение CLI-команды (Driving Adapter: CLI)
yield* TodoCliAdapter
}
})
// Обе ветки используют одну инфраструктуру
const program = main.pipe(
Effect.provide(CreateTodoUseCaseLive),
Effect.provide(CompleteTodoUseCaseLive),
Effect.provide(ListTodosUseCaseLive),
Effect.provide(SqliteTodoRepository),
Effect.provide(SystemClock),
Effect.provide(UuidGenerator),
)
Effect.runFork(program)
Множественные Driven-адаптеры: подмена инфраструктуры
Для каждого Driven-порта существует минимум два адаптера: production и тестовый:
// Driven Port
class TodoRepository extends Context.Tag("TodoRepository")<...>() {}
// Production Driven Adapter
const SqliteTodoRepository = Layer.scoped(TodoRepository, /* SQLite */)
// Test Driven Adapter
const InMemoryTodoRepository = Layer.sync(TodoRepository, /* Map */)
// Dev Driven Adapter (с логированием)
const LoggedTodoRepository = Layer.effect(TodoRepository, /* log + delegate */)
// Все три реализуют ОДИН контракт
// Выбор адаптера определяется при сборке приложения:
// В тестах:
const testProgram = createTodo(input).pipe(
Effect.provide(InMemoryTodoRepository),
Effect.provide(FixedClock),
)
// В production:
const prodProgram = createTodo(input).pipe(
Effect.provide(SqliteTodoRepository),
Effect.provide(SystemClock),
)
Распространённые ошибки
Ошибка 1: путать «кто реализует» Driving Port
// ❌ ОШИБКА: Driving Adapter пытается реализовать бизнес-логику
const httpHandler = HttpRouter.post("/todos",
Effect.gen(function* () {
const body = yield* parseBody()
// Бизнес-логика НЕ ДОЛЖНА быть в Driving Adapter!
const title = new TodoTitle({ value: body.title })
const id = crypto.randomUUID()
const todo = new Todo({ id, title, status: "active", createdAt: new Date() })
const repo = yield* TodoRepository
yield* repo.save(todo)
return HttpServerResponse.json(todo, { status: 201 })
})
)
// ✅ ПРАВИЛЬНО: Driving Adapter только транслирует
const httpHandler = HttpRouter.post("/todos",
Effect.gen(function* () {
const body = yield* parseBody()
const input = yield* Schema.decode(CreateTodoInput)(body)
// Делегирует Use Case (Application Core)
const todo = yield* createTodo(input)
return HttpServerResponse.json(
yield* Schema.encode(TodoResponse)(todo),
{ status: 201 }
)
})
)
Ошибка 2: Driven Adapter знает о Driving Adapter
// ❌ ОШИБКА: SQLite-адаптер знает о HTTP-контексте
save: (todo) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest // !!!
const userId = request.headers["x-user-id"] // HTTP в Driven Adapter!
db.run("INSERT ... VALUES (?, ?)", [todo.title, userId])
}),
// ✅ ПРАВИЛЬНО: все нужные данные уже в доменном типе
save: (todo) =>
Effect.gen(function* () {
db.run("INSERT ... VALUES (?, ?, ?)",
[todo.id.value, todo.title.value, todo.ownerId.value]
)
// ownerId — часть доменной модели, а не HTTP-заголовка
}),
Ошибка 3: смешивать Driving и Driven в одном порте
// ❌ ОШИБКА: один порт для входа и выхода
class TodoService extends Context.Tag("TodoService")<
TodoService,
{
readonly handleRequest: (req: HttpRequest) => Effect.Effect<HttpResponse> // Driving
readonly saveToDb: (todo: Todo) => Effect.Effect<void> // Driven
readonly sendNotification: (msg: string) => Effect.Effect<void> // Driven
}
>() {}
// ✅ ПРАВИЛЬНО: каждый аспект — отдельный порт
class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<...>() {} // Driving Port
class TodoRepository extends Context.Tag("TodoRepository")<...>() {} // Driven Port
class NotificationService extends Context.Tag("NotificationService")<...>() {} // Driven Port
Резюме
Разделение на Driving и Driven — это не абстрактная классификация, а практический инструмент проектирования:
-
Driving (Primary) — кто управляет приложением: HTTP, CLI, тесты, cron. Driving Adapter вызывает Use Case. Use Case реализуется Application Core.
-
Driven (Secondary) — что использует приложение: БД, email, файлы, время. Driven Adapter реализует порт. Application Core вызывает порт.
-
Контракт всегда определяет Application Core — и для Driving, и для Driven портов. Это обеспечивает, что зависимости всегда «указывают внутрь».
-
Симметрия — оба типа следуют паттерну порт/адаптер, но реализуются разными сторонами.
-
Независимость — Driving-адаптер не знает о Driven-адаптерах. HTTP-роутер не знает, что данные хранятся в SQLite. Тест не знает, что в production используется SMTP.