Поток данных: запрос от HTTP до БД и обратно
Полный жизненный цикл запроса POST /api/todos через все слои — HTTP Adapter → Driving Port → Application Service → Domain Model → Driven Port → SQLite Adapter и обратно. Трансформация типов на каждой границе, карта маппинга (DTO → Input → Entity → Row → Response), поток ошибок между слоями.
Введение: зачем понимать поток данных
Архитектурная диаграмма показывает статику — какие компоненты существуют и как связаны. Но архитектура живёт в динамике — когда реальный HTTP-запрос проходит через все слои, трансформируется, порождает бизнес-логику и возвращается ответом.
Понимание потока данных критически важно по трём причинам:
- Debugging — когда что-то сломалось, вы точно знаете, в каком слое искать проблему.
- Трансформация типов — данные меняют форму на каждой границе. HTTP body ≠ доменная сущность ≠ SQL row.
- Ответственность слоёв — каждый слой делает ровно свою работу, не больше и не меньше.
Полный жизненный цикл запроса
Рассмотрим конкретный сценарий: создание новой 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) — это первый код, который обрабатывает запрос. Его задачи:
- Разобрать (parse) тело запроса
- Провалидировать структуру через Schema
- Трансформировать в формат, ожидаемый Driving Port
- Делегировать обработку в порт
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 — шесть разных типов для одной операции?
Ответ: каждый тип принадлежит своему слою и служит своей цели:
| Тип | Слой | Назначение |
|---|---|---|
CreateTodoRequest | HTTP Adapter | Валидация входящего JSON. Может содержать HTTP-специфику (строковые даты, пагинация) |
CreateTodoInput | Application | Контракт Use Case. Не зависит от транспорта (HTTP/CLI/gRPC) |
Todo | Domain | Бизнес-правила, инварианты, поведение. Rich entity с Value Objects |
TodoRow | SQLite Adapter | Формат хранения в конкретной БД. Может содержать SQL-специфику (snake_case, NULL) |
TodoOutput | Application | Результат Use Case. Не зависит от транспорта |
TodoResponse | HTTP Adapter | JSON-ответ. Может содержать 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), он гарантированно не зависит ни от каких инфраструктурных сервисов.
Оптимизация: когда маппинг избыточен
В небольших проектах или на ранних стадиях допустимо сокращать маппинги:
-
CreateTodoInput= часть полейTodo— если входные типы Use Case совпадают с полями Entity, можно использоватьPick<Todo, "title" | "priority">вместо отдельного типа. -
TodoOutput=Todo— если выходной тип совпадает с доменным, можно не создавать отдельный Output. -
TodoResponse=TodoOutput— если HTTP-ответ идентичен выходу Use Case, маппинг не нужен.
Но предупреждение: как только появляется расхождение (например, HTTP должен возвращать created_at в ISO-формате, а домен хранит Date), отдельные типы станут необходимы. Проще заложить маппинг сразу, чем рефакторить потом.
Резюме
Поток данных в гексагональной архитектуре — это цепочка трансформаций на каждой границе. Каждый слой:
- Принимает данные в формате предыдущего слоя
- Трансформирует их в свой формат
- Обрабатывает (валидация, бизнес-логика, SQL)
- Передаёт результат следующему слою
В Effect-ts поток данных — это Effect.gen цепочка, где каждый yield* — переход через границу слоя, а R-канал гарантирует, что все зависимости (порты) будут предоставлены через Layer (адаптеры) при запуске программы.
Данные никогда не перепрыгивают слои. HTTP-запрос не попадает напрямую в SQL. Доменная ошибка не утекает к клиенту без маппинга. Каждая граница — это точка трансформации и точка контроля.