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

Поток данных: запрос от HTTP до БД и обратно

Полный жизненный цикл запроса POST /api/todos через все слои — HTTP Adapter → Driving Port → Application Service → Domain Model → Driven Port → SQLite Adapter и обратно. Трансформация типов на каждой границе, карта маппинга (DTO → Input → Entity → Row → Response), поток ошибок между слоями.

Введение: зачем понимать поток данных

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

Понимание потока данных критически важно по трём причинам:

  1. Debugging — когда что-то сломалось, вы точно знаете, в каком слое искать проблему.
  2. Трансформация типов — данные меняют форму на каждой границе. HTTP body ≠ доменная сущность ≠ SQL row.
  3. Ответственность слоёв — каждый слой делает ровно свою работу, не больше и не меньше.

Полный жизненный цикл запроса

Рассмотрим конкретный сценарий: создание новой Todo-задачи. Пользователь отправляет POST-запрос, и мы проследим путь данных через все слои — туда и обратно.

Общая схема

Запрос (Request Flow) ──────────────────────────────────────────►

  Browser          HTTP           Driving        Application      Domain
    │             Adapter          Port           Service          Model
    │               │               │               │               │
    │  POST /todos  │               │               │               │
    │──────────────►│               │               │               │
    │               │  parse &      │               │               │
    │               │  validate     │               │               │
    │               │──────────────►│               │               │
    │               │               │  execute()    │               │
    │               │               │──────────────►│               │
    │               │               │               │  create()     │
    │               │               │               │──────────────►│
    │               │               │               │◄──────────────│
    │               │               │               │               │
    │               │               │               │  repo.save()  │
    │               │               │               │──────┐        │
    │               │               │               │      │        │

                                                    Driven        SQLite
                                                     Port         Adapter
                                                      │              │
                                                      │  save()      │
                                                      │─────────────►│
                                                      │              │ INSERT INTO
                                                      │◄─────────────│
                                                      │              │
    │               │               │               │◄─────┘        │
    │               │               │◄──────────────│               │
    │               │◄──────────────│               │               │
    │◄──────────────│               │               │               │
    │  201 Created  │               │               │               │
    │  + JSON body  │               │               │               │

◄────────────────────────────────────────────── Ответ (Response Flow)

Шаг за шагом: Request Flow

Шаг 1: HTTP Request → HTTP Adapter

Внешний мир (браузер, Postman, мобильное приложение) отправляет HTTP-запрос:

POST /api/todos HTTP/1.1
Content-Type: application/json

{
  "title": "Купить молоко",
  "priority": "high",
  "dueDate": "2025-12-31"
}

На этом этапе данные — сырой JSON. Никакой типизации, никаких гарантий. Строка может быть пустой, приоритет может быть "banana", дата может быть невалидной.

Шаг 2: HTTP Adapter — разбор и валидация входа

HTTP Adapter (Driving Adapter) — это первый код, который обрабатывает запрос. Его задачи:

  1. Разобрать (parse) тело запроса
  2. Провалидировать структуру через Schema
  3. Трансформировать в формат, ожидаемый Driving Port
  4. Делегировать обработку в порт
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform"
import { Effect, Schema } from "effect"

// DTO (Data Transfer Object) — тип для HTTP-границы
// Это НЕ доменный тип! Это тип адаптера.
class CreateTodoRequest extends Schema.Class<CreateTodoRequest>("CreateTodoRequest")({
  title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200)),
  priority: Schema.Literal("low", "medium", "high"),
  dueDate: Schema.optionalWith(Schema.DateFromString, { as: "Option" }),
}) {}

// HTTP Adapter
const createTodoRoute = HttpRouter.post(
  "/api/todos",
  Effect.gen(function* () {
    // Шаг 2a: Parse & Validate — сырой JSON → типизированный DTO
    const request = yield* HttpServerRequest.schemaBodyJson(CreateTodoRequest)
    //              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //              Если JSON невалиден → 400 Bad Request (автоматически)
    
    // Шаг 2b: Трансформация DTO → Input для Use Case
    const input: CreateTodoInput = {
      title: request.title,
      priority: request.priority,
      dueDate: request.dueDate,
    }
    
    // Шаг 2c: Делегирование в Driving Port
    const useCase = yield* CreateTodoUseCase
    const result = yield* useCase.execute(input)
    
    // Шаг 2d: Трансформация доменного результата → HTTP Response
    return yield* HttpServerResponse.json(TodoResponse.encode(result), {
      status: 201,
    })
  })
)

Ключевой момент: HTTP Adapter работает с двумя типами данных:

  • Входной: CreateTodoRequest — HTTP DTO
  • Выходной: CreateTodoInput — тип, ожидаемый портом

Маппинг CreateTodoRequest → CreateTodoInput происходит в адаптере, а не в ядре. Ядро никогда не видит HTTP-специфичных типов.

Шаг 3: Driving Port — контракт

Driving Port определяет, что приложение умеет делать, без указания как:

// Тип входа — принадлежит ядру
interface CreateTodoInput {
  readonly title: string
  readonly priority: "low" | "medium" | "high"
  readonly dueDate: Option.Option<Date>
}

// Тип выхода — принадлежит ядру
interface TodoOutput {
  readonly id: TodoId
  readonly title: string
  readonly status: TodoStatus
  readonly priority: Priority
  readonly createdAt: Date
}

// Driving Port
class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
  CreateTodoUseCase,
  {
    readonly execute: (
      input: CreateTodoInput
    ) => Effect.Effect<TodoOutput, TodoValidationError | TodoAlreadyExistsError>
  }
>() {}

На этой границе данные уже типизированы и валидны с точки зрения структуры, но ещё не валидированы с точки зрения бизнес-правил.

Шаг 4: Application Service — оркестрация

Application Service (Use Case Handler) — это реализация Driving Port. Он координирует работу: вызывает домен, сохраняет результат, отправляет события.

const CreateTodoUseCaseLive = Layer.effect(
  CreateTodoUseCase,
  Effect.gen(function* () {
    const repo = yield* TodoRepository          // Driven Port
    const clock = yield* Clock                  // Driven Port
    const idGen = yield* IdGenerator            // Driven Port
    const eventBus = yield* DomainEventBus      // Driven Port
    
    return CreateTodoUseCase.of({
      execute: (input) =>
        Effect.gen(function* () {
          // Шаг 4a: Получить текущее время (через порт, не через Date.now()!)
          const now = yield* clock.currentDateTime
          
          // Шаг 4b: Сгенерировать ID (через порт, не через crypto.randomUUID()!)
          const id = yield* idGen.generate
          
          // Шаг 4c: Создать доменную сущность (бизнес-валидация!)
          const todo = yield* Todo.create({
            id,
            title: TodoTitle.make(input.title),   // Value Object валидация
            priority: Priority.make(input.priority),
            dueDate: input.dueDate,
            createdAt: now,
          })
          // Если TodoTitle.make("") → TodoValidationError
          
          // Шаг 4d: Сохранить через Driven Port
          yield* repo.save(todo)
          
          // Шаг 4e: Опубликовать доменное событие
          yield* eventBus.publish(
            TodoCreated({ todoId: todo.id, title: todo.title.value, at: now })
          )
          
          // Шаг 4f: Вернуть результат
          return TodoOutput.fromDomain(todo)
        }),
    })
  })
)

Что делает Application Service:

  • Координирует (оркестрирует) вызовы
  • Получает зависимости через Driven Ports
  • Вызывает доменную логику
  • Сохраняет результат
  • Публикует события

Чего Application Service НЕ делает:

  • Не содержит бизнес-логики (она в домене)
  • Не знает о HTTP, SQL, файлах
  • Не занимается маппингом инфраструктурных типов

Шаг 5: Domain Model — бизнес-логика

Доменная модель содержит бизнес-правила. Это чистые функции, работающие с доменными типами:

// Value Object: TodoTitle
class TodoTitle extends Schema.Class<TodoTitle>("TodoTitle")({
  value: Schema.String.pipe(
    Schema.minLength(1),
    Schema.maxLength(200),
    Schema.trimmed(),
  ),
}) {
  static make(raw: string): Effect.Effect<TodoTitle, TodoValidationError> {
    return Schema.decode(TodoTitle)({ value: raw }).pipe(
      Effect.mapError(() => new TodoValidationError({
        field: "title",
        message: "Title must be 1-200 characters, non-empty",
      }))
    )
  }
}

// Entity: Todo
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  status: TodoStatus,
  priority: Priority,
  dueDate: Schema.OptionFromNullOr(Schema.Date),
  createdAt: Schema.Date,
}) {
  static create(params: {
    readonly id: TodoId
    readonly title: TodoTitle
    readonly priority: Priority
    readonly dueDate: Option.Option<Date>
    readonly createdAt: Date
  }): Effect.Effect<Todo, never> {
    return Effect.succeed(
      new Todo({
        ...params,
        status: TodoStatus.make("pending"),
      })
    )
  }
  
  complete(): Effect.Effect<Todo, InvalidTransitionError> {
    if (this.status.value === "completed") {
      return Effect.fail(
        new InvalidTransitionError({
          from: this.status.value,
          to: "completed",
          reason: "Todo is already completed",
        })
      )
    }
    return Effect.succeed(
      new Todo({ ...this, status: TodoStatus.make("completed") })
    )
  }
}

Ключевое наблюдение: доменная модель чистая. Нет import из @effect/platform, bun:sqlite или HTTP-библиотек. Только effect и собственные доменные типы.

Шаг 6: Driven Port → Driven Adapter (сохранение)

Application Service вызывает repo.save(todo). Это обращение к Driven Port:

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, PersistenceError>
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
    readonly findAll: Effect.Effect<ReadonlyArray<Todo>, PersistenceError>
  }
>() {}

Driven Adapter (SQLite) реализует этот порт:

const TodoRepositorySqlite = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    const sql = yield* SqliteClient
    
    return TodoRepository.of({
      save: (todo) =>
        Effect.gen(function* () {
          // Шаг 6a: Маппинг Domain → SQL
          // Todo (доменный тип) → SQL row (инфраструктурный тип)
          const row: TodoRow = {
            id: todo.id,
            title: todo.title.value,         // TodoTitle → string
            status: todo.status.value,       // TodoStatus → string
            priority: todo.priority.value,   // Priority → string
            due_date: Option.match(todo.dueDate, {
              onNone: () => null,             // Option<Date> → null
              onSome: (d) => d.toISOString(), // Option<Date> → string
            }),
            created_at: todo.createdAt.toISOString(), // Date → string
          }
          
          // Шаг 6b: SQL INSERT
          yield* sql.execute(
            `INSERT INTO todos (id, title, status, priority, due_date, created_at)
             VALUES ($id, $title, $status, $priority, $due_date, $created_at)`,
            row
          )
        }).pipe(
          // Шаг 6c: Маппинг ошибок SQL → Domain
          Effect.mapError((e) =>
            new PersistenceError({ operation: "save", cause: e })
          )
        ),
      
      findById: (id) =>
        Effect.gen(function* () {
          const row = yield* sql.executeOne(
            `SELECT * FROM todos WHERE id = $id`,
            { id }
          )
          // Маппинг SQL → Domain (обратный)
          return todoFromRow(row)
        }),
        
      findAll: Effect.gen(function* () {
        const rows = yield* sql.executeAll(`SELECT * FROM todos`)
        return rows.map(todoFromRow)
      }),
    })
  })
)

// Вспомогательная функция: SQL Row → Domain Entity
const todoFromRow = (row: TodoRow): Todo =>
  new Todo({
    id: row.id as TodoId,
    title: new TodoTitle({ value: row.title }),
    status: TodoStatus.make(row.status),
    priority: Priority.make(row.priority),
    dueDate: row.due_date
      ? Option.some(new Date(row.due_date))
      : Option.none(),
    createdAt: new Date(row.created_at),
  })

Шаг 7: Response Flow — обратный путь

После сохранения данные проходят обратный путь через все слои:

SQLite         Driven         Application      Driving        HTTP          Browser
Adapter        Port           Service          Port           Adapter
  │              │               │               │              │              │
  │──void───────►│               │               │              │              │
  │              │──void────────►│               │              │              │
  │              │               │──TodoOutput──►│              │              │
  │              │               │               │──TodoOutput─►│              │
  │              │               │               │              │──201+JSON──►│
  │              │               │               │              │              │

На каждой границе данные трансформируются:

// Domain → Application (TodoOutput)
class TodoOutput {
  static fromDomain(todo: Todo): TodoOutput {
    return {
      id: todo.id,
      title: todo.title.value,
      status: todo.status.value,
      priority: todo.priority.value,
      createdAt: todo.createdAt,
    }
  }
}

// Application → HTTP (JSON Response)
class TodoResponse extends Schema.Class<TodoResponse>("TodoResponse")({
  id: Schema.String,
  title: Schema.String,
  status: Schema.String,
  priority: Schema.String,
  createdAt: Schema.DateFromString,  // Date → ISO string в JSON
}) {
  static encode(output: TodoOutput): TodoResponse {
    return new TodoResponse({
      id: output.id,
      title: output.title,
      status: output.status,
      priority: output.priority,
      createdAt: output.createdAt,
    })
  }
}

Трансформация типов: полная карта

Вот полная карта трансформаций типов для одного запроса POST /api/todos:

Слой                  Тип данных                  Пример
─────────────────────────────────────────────────────────────
HTTP (вход)           Raw JSON string             '{"title":"Buy milk"}'

                      JSON.parse()

HTTP Adapter          CreateTodoRequest           { title: "Buy milk", ... }
(Schema.decode)       (HTTP DTO)

                      DTO → Input маппинг

Driving Port          CreateTodoInput             { title: "Buy milk", ... }
(Application)         (Application type)

                      Value Object creation

Domain Model          Todo                        { id: TodoId, title: TodoTitle("Buy milk"), ... }
                      (Rich domain entity)

                      Domain → SQL Row маппинг

Driven Adapter        TodoRow                     { id: "abc", title: "Buy milk", 
(SQLite)              (Infrastructure type)         due_date: "2025-12-31T00:00:00Z" }

                      SQL INSERT

SQLite                Raw bytes on disk           Binary SQLite format
─────────────────────────────────────────────────────────────

Обратно:

SQLite                Raw bytes                   Binary

                      SQL SELECT

Driven Adapter        TodoRow                     { id: "abc", title: "Buy milk", ... }

                      SQL Row → Domain маппинг

Domain Model          Todo                        { id: TodoId, title: TodoTitle(...), ... }

                      Domain → Output маппинг

Driving Port          TodoOutput                  { id: "abc", title: "Buy milk", ... }

                      Output → Response маппинг

HTTP Adapter          TodoResponse                { id: "abc", title: "Buy milk", ... }
(Schema.encode)           │
                      JSON.stringify()

HTTP (выход)          Raw JSON string             '{"id":"abc","title":"Buy milk",...}'

Почему столько трансформаций?

На первый взгляд, это избыточно. Зачем CreateTodoRequest, CreateTodoInput, Todo, TodoRow, TodoOutput, TodoResponse — шесть разных типов для одной операции?

Ответ: каждый тип принадлежит своему слою и служит своей цели:

ТипСлойНазначение
CreateTodoRequestHTTP AdapterВалидация входящего JSON. Может содержать HTTP-специфику (строковые даты, пагинация)
CreateTodoInputApplicationКонтракт Use Case. Не зависит от транспорта (HTTP/CLI/gRPC)
TodoDomainБизнес-правила, инварианты, поведение. Rich entity с Value Objects
TodoRowSQLite AdapterФормат хранения в конкретной БД. Может содержать SQL-специфику (snake_case, NULL)
TodoOutputApplicationРезультат Use Case. Не зависит от транспорта
TodoResponseHTTP AdapterJSON-ответ. Может содержать HTTP-специфику (ссылки, мета-данные)

Правило: если завтра вы замените HTTP на gRPC, изменятся только CreateTodoRequest и TodoResponse. Если замените SQLite на PostgreSQL, изменится только TodoRow. Ядро (Todo, CreateTodoInput, TodoOutput) — неприкосновенно.


Поток данных через Effect-пайплайн

В Effect-ts весь поток данных выражается как композиция эффектов. Вот как выглядит полный пайплайн для POST /api/todos:

// Полный пайплайн — от HTTP запроса до HTTP ответа
const createTodoHandler = Effect.gen(function* () {
  // ═══ HTTP Adapter ═══
  const request = yield* HttpServerRequest.schemaBodyJson(CreateTodoRequest)
  // Тип: CreateTodoRequest
  
  // ═══ Маппинг: HTTP → Application ═══
  const input: CreateTodoInput = {
    title: request.title,
    priority: request.priority,
    dueDate: request.dueDate,
  }
  // Тип: CreateTodoInput
  
  // ═══ Driving Port ═══
  const useCase = yield* CreateTodoUseCase
  const output = yield* useCase.execute(input)
  // Тип: TodoOutput
  
  // ═══ Маппинг: Application → HTTP ═══
  return yield* HttpServerResponse.json(
    TodoResponse.encode(output),
    { status: 201 }
  )
})
// Итоговый тип:
// Effect<HttpServerResponse, 
//   HttpBodyError | TodoValidationError | TodoAlreadyExistsError,
//   CreateTodoUseCase | HttpServerRequest>

Обратите внимание на R-канал (третий параметр Effect): он содержит CreateTodoUseCase (Driving Port). Компилятор гарантирует, что этот порт будет предоставлен через Layer до запуска.


Поток ошибок

Ошибки — тоже данные, и они тоже трансформируются на каждой границе:

Слой                  Тип ошибки                  HTTP-код
─────────────────────────────────────────────────────────────
Domain Model          TodoValidationError          → 422
                      InvalidTransitionError        → 409
                      TodoAlreadyExistsError         → 409

Application Service   (проксирует доменные ошибки)

Driven Adapter        PersistenceError             → 500
(SQLite)              ConnectionError              → 503

HTTP Adapter          HttpBodyError (parse error)  → 400
                      (маппит все ошибки → HTTP)
─────────────────────────────────────────────────────────────

В коде маппинг ошибок происходит в HTTP Adapter:

const errorToHttpResponse = (error: AppError) => {
  switch (error._tag) {
    case "TodoValidationError":
      return HttpServerResponse.json(
        { error: "Validation failed", details: error.message },
        { status: 422 }
      )
    case "TodoNotFoundError":
      return HttpServerResponse.json(
        { error: "Todo not found", id: error.todoId },
        { status: 404 }
      )
    case "InvalidTransitionError":
      return HttpServerResponse.json(
        { error: "Invalid state transition", from: error.from, to: error.to },
        { status: 409 }
      )
    case "PersistenceError":
      return HttpServerResponse.json(
        { error: "Internal server error" },
        { status: 500 }
      )
    // Domain errors → 4xx, Infrastructure errors → 5xx
  }
}

// Применение маппинга ошибок
const createTodoRoute = HttpRouter.post(
  "/api/todos",
  createTodoHandler.pipe(
    Effect.catchAll(errorToHttpResponse)
  )
)

Правило маппинга ошибок:

  • Доменные ошибки → 4xx (клиент виноват: невалидный ввод, нарушение правил)
  • Инфраструктурные ошибки → 5xx (сервер виноват: БД недоступна, диск переполнен)
  • Детали инфраструктурных ошибок никогда не утекают к клиенту

Последовательность вызовов: диаграмма взаимодействия

  Browser       HttpAdapter     CreateTodoUseCase     Todo(Domain)     TodoRepository    SqliteAdapter
     │               │                 │                   │                │                 │
     │  POST /todos  │                 │                   │                │                 │
     │──────────────►│                 │                   │                │                 │
     │               │                 │                   │                │                 │
     │               │ Schema.decode   │                   │                │                 │
     │               │ (validation)    │                   │                │                 │
     │               │                 │                   │                │                 │
     │               │  execute(input) │                   │                │                 │
     │               │────────────────►│                   │                │                 │
     │               │                 │                   │                │                 │
     │               │                 │  Todo.create()    │                │                 │
     │               │                 │──────────────────►│                │                 │
     │               │                 │                   │                │                 │
     │               │                 │  ◄── Todo ────────│                │                 │
     │               │                 │                   │                │                 │
     │               │                 │  repo.save(todo)  │                │                 │
     │               │                 │──────────────────────────────────►│                 │
     │               │                 │                   │               │                 │
     │               │                 │                   │               │  Domain→SQL Row  │
     │               │                 │                   │               │────────────────►│
     │               │                 │                   │               │                 │
     │               │                 │                   │               │  SQL INSERT      │
     │               │                 │                   │               │                 │
     │               │                 │                   │               │  ◄── void ──────│
     │               │                 │                   │               │                 │
     │               │                 │  ◄── void ────────────────────────│                 │
     │               │                 │                   │               │                 │
     │               │  ◄── TodoOutput │                   │               │                 │
     │               │                 │                   │               │                 │
     │               │ Schema.encode   │                   │               │                 │
     │               │ (serialization) │                   │               │                 │
     │               │                 │                   │               │                 │
     │  201 + JSON   │                 │                   │               │                 │
     │◄──────────────│                 │                   │               │                 │

Поток данных для Query (чтение)

Для полноты рассмотрим обратный сценарий — запрос списка задач (GET /api/todos):

// HTTP Adapter (Driving)
const listTodosRoute = HttpRouter.get(
  "/api/todos",
  Effect.gen(function* () {
    // Парсинг query-параметров
    const query = yield* HttpServerRequest.schemaSearchParams(
      Schema.Struct({
        status: Schema.optional(Schema.Literal("pending", "completed")),
        limit: Schema.optional(Schema.NumberFromString.pipe(
          Schema.int(),
          Schema.between(1, 100)
        )),
      })
    )
    
    const useCase = yield* ListTodosUseCase
    const todos = yield* useCase.execute({
      filter: query.status
        ? { status: query.status }
        : undefined,
      limit: query.limit ?? 20,
    })
    
    return yield* HttpServerResponse.json(
      todos.map(TodoResponse.encode)
    )
  })
)

// Application Service
const ListTodosUseCaseLive = Layer.effect(
  ListTodosUseCase,
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    
    return ListTodosUseCase.of({
      execute: (params) =>
        Effect.gen(function* () {
          const todos = yield* repo.findAll
          
          // Фильтрация — бизнес-логика (в Application, не в SQL!)
          // Для простых случаев. Для сложных — Specification Pattern
          const filtered = params.filter
            ? todos.filter((t) => t.status.value === params.filter!.status)
            : todos
          
          const limited = filtered.slice(0, params.limit)
          
          return limited.map(TodoOutput.fromDomain)
        }),
    })
  })
)

Поток данных при чтении:

GET /api/todos?status=pending&limit=10

HTTP Adapter          Application         Domain          SQLite Adapter
    │                     │                  │                  │
    │ parse query params  │                  │                  │
    │ (Schema.decode)     │                  │                  │
    │                     │                  │                  │
    │  execute(filter)    │                  │                  │
    │────────────────────►│                  │                  │
    │                     │  repo.findAll    │                  │
    │                     │─────────────────────────────────────►│
    │                     │                  │                  │
    │                     │                  │  SQL SELECT      │
    │                     │                  │  Rows → Todo[]   │
    │                     │                  │                  │
    │                     │  ◄── Todo[] ─────────────────────────│
    │                     │                  │                  │
    │                     │  filter & limit  │                  │
    │                     │  (business logic)│                  │
    │                     │                  │                  │
    │                     │  Todo[] →        │                  │
    │                     │  TodoOutput[]    │                  │
    │                     │                  │                  │
    │  ◄── TodoOutput[]   │                  │                  │
    │                     │                  │                  │
    │  TodoOutput[] →     │                  │                  │
    │  TodoResponse[]     │                  │                  │
    │  (Schema.encode)    │                  │                  │
    │                     │                  │                  │
    │  200 + JSON array   │                  │                  │

Правило: данные не перепрыгивают слои

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

✗ НЕЛЬЗЯ: HTTP Adapter → SQLite напрямую
  (HTTP-контроллер не должен писать SQL-запросы)

✗ НЕЛЬЗЯ: Domain Model → SQL Row напрямую
  (Entity не должна знать о формате хранения)

✗ НЕЛЬЗЯ: HTTP Request → Domain Model напрямую
  (Домен не должен парсить JSON)

✓ МОЖНО: HTTP → Application → Domain → Repository → SQLite
  (каждый слой трансформирует данные и делегирует дальше)

В Effect-ts это гарантируется типовой системой: если Domain Model возвращает Effect<Todo, E, never> (R = never), он гарантированно не зависит ни от каких инфраструктурных сервисов.


Оптимизация: когда маппинг избыточен

В небольших проектах или на ранних стадиях допустимо сокращать маппинги:

  1. CreateTodoInput = часть полей Todo — если входные типы Use Case совпадают с полями Entity, можно использовать Pick<Todo, "title" | "priority"> вместо отдельного типа.

  2. TodoOutput = Todo — если выходной тип совпадает с доменным, можно не создавать отдельный Output.

  3. TodoResponse = TodoOutput — если HTTP-ответ идентичен выходу Use Case, маппинг не нужен.

Но предупреждение: как только появляется расхождение (например, HTTP должен возвращать created_at в ISO-формате, а домен хранит Date), отдельные типы станут необходимы. Проще заложить маппинг сразу, чем рефакторить потом.


Резюме

Поток данных в гексагональной архитектуре — это цепочка трансформаций на каждой границе. Каждый слой:

  1. Принимает данные в формате предыдущего слоя
  2. Трансформирует их в свой формат
  3. Обрабатывает (валидация, бизнес-логика, SQL)
  4. Передаёт результат следующему слою

В Effect-ts поток данных — это Effect.gen цепочка, где каждый yield* — переход через границу слоя, а R-канал гарантирует, что все зависимости (порты) будут предоставлены через Layer (адаптеры) при запуске программы.

Данные никогда не перепрыгивают слои. HTTP-запрос не попадает напрямую в SQL. Доменная ошибка не утекает к клиенту без маппинга. Каждая граница — это точка трансформации и точка контроля.