Типобезопасный домен: Гексагональная архитектура на базе Effect Driving (Primary) vs Driven (Secondary): направление потока
Глава

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-реализация */ }
  })
)

Таблица сравнения

АспектDrivingDriven
Кто инициируетВнешний акторApplication Core
Кто определяет контрактApplication CoreApplication 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 — это не абстрактная классификация, а практический инструмент проектирования:

  1. Driving (Primary) — кто управляет приложением: HTTP, CLI, тесты, cron. Driving Adapter вызывает Use Case. Use Case реализуется Application Core.

  2. Driven (Secondary) — что использует приложение: БД, email, файлы, время. Driven Adapter реализует порт. Application Core вызывает порт.

  3. Контракт всегда определяет Application Core — и для Driving, и для Driven портов. Это обеспечивает, что зависимости всегда «указывают внутрь».

  4. Симметрия — оба типа следуют паттерну порт/адаптер, но реализуются разными сторонами.

  5. Независимость — Driving-адаптер не знает о Driven-адаптерах. HTTP-роутер не знает, что данные хранятся в SQLite. Тест не знает, что в production используется SMTP.