Типобезопасный домен: Гексагональная архитектура на базе Effect Маппинг: HTTP → Port → UseCase → Port → SQLite
Глава

Маппинг: HTTP → Port → UseCase → Port → SQLite

Полный компилируемый пример Todo-приложения — от доменных Value Objects (TodoTitle, Priority, TodoStatus) и Entity (Todo), через порты (TodoRepository, CreateTodoUseCase), Application Service, SQLite-адаптер с маппером (todoToRow/todoFromRow), HTTP-адаптер с DTO, до Composition Root и точки входа. Проверка заменяемости: SQLite → InMemory за одну строку.

Введение: от абстрактных диаграмм к реальному коду

В предыдущих статьях мы нарисовали диаграммы и описали потоки данных. Теперь пришло время написать реальный код — полный, компилируемый, запускаемый. Мы пройдём весь путь от HTTP-запроса до записи в SQLite и обратно, показав каждый компонент гексагона в действии.

Сценарий: создание задачи (POST /api/todos → сохранение в SQLite → ответ 201).


Шаг 0: Доменная модель (Application Core)

Начинаем с самого центра гексагона — домена. Домен не знает ни о HTTP, ни о SQLite. Он определяет бизнес-типы и бизнес-правила.

Branded Types и Value Objects

// src/domain/model/todo-id.ts
import { Brand, Schema } from "effect"

export type TodoId = string & Brand.Brand<"TodoId">

export const TodoId = Brand.nominal<TodoId>()

export const TodoIdSchema = Schema.String.pipe(
  Schema.brand("TodoId"),
  Schema.annotations({ identifier: "TodoId" })
)
// src/domain/model/todo-title.ts
import { Schema, Effect } from "effect"
import { TodoValidationError } from "../errors/index.js"

export class TodoTitle extends Schema.Class<TodoTitle>("TodoTitle")({
  value: Schema.String.pipe(
    Schema.trimmed(),
    Schema.minLength(1, {
      message: () => "Title cannot be empty",
    }),
    Schema.maxLength(200, {
      message: () => "Title cannot exceed 200 characters",
    }),
  ),
}) {
  static readonly make = (raw: string): Effect.Effect<TodoTitle, TodoValidationError> =>
    Schema.decode(TodoTitle)({ value: raw }).pipe(
      Effect.mapError((parseError) =>
        new TodoValidationError({
          field: "title",
          message: `Invalid title: ${raw}`,
          cause: parseError,
        })
      )
    )
}
// src/domain/model/priority.ts
import { Schema } from "effect"

export const PriorityValues = ["low", "medium", "high"] as const
export type PriorityValue = typeof PriorityValues[number]

export class Priority extends Schema.Class<Priority>("Priority")({
  value: Schema.Literal(...PriorityValues),
}) {
  static readonly make = (raw: PriorityValue): Priority =>
    new Priority({ value: raw })
  
  readonly isHigherThan = (other: Priority): boolean => {
    const order: Record<PriorityValue, number> = {
      low: 0,
      medium: 1,
      high: 2,
    }
    return order[this.value] > order[other.value]
  }
}
// src/domain/model/todo-status.ts
import { Schema } from "effect"

export const StatusValues = ["pending", "in_progress", "completed", "archived"] as const
export type StatusValue = typeof StatusValues[number]

export class TodoStatus extends Schema.Class<TodoStatus>("TodoStatus")({
  value: Schema.Literal(...StatusValues),
}) {
  static readonly make = (raw: StatusValue): TodoStatus =>
    new TodoStatus({ value: raw })
  
  static readonly initial = (): TodoStatus =>
    new TodoStatus({ value: "pending" })
  
  readonly canTransitionTo = (target: StatusValue): boolean => {
    const transitions: Record<StatusValue, ReadonlyArray<StatusValue>> = {
      pending: ["in_progress", "completed", "archived"],
      in_progress: ["completed", "archived"],
      completed: ["archived"],
      archived: [],
    }
    return transitions[this.value].includes(target)
  }
}

Entity

// src/domain/model/todo.ts
import { Schema, Effect, Option } from "effect"
import type { TodoId } from "./todo-id.js"
import { TodoTitle } from "./todo-title.js"
import { TodoStatus, type StatusValue } from "./todo-status.js"
import { Priority, type PriorityValue } from "./priority.js"
import { InvalidTransitionError } from "../errors/index.js"

export class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String,        // TodoId (brand applied at creation)
  title: TodoTitle,
  status: TodoStatus,
  priority: Priority,
  dueDate: Schema.OptionFromNullOr(Schema.DateFromSelf),
  createdAt: Schema.DateFromSelf,
  updatedAt: Schema.DateFromSelf,
}) {
  // ═══ Factory Method ═══
  static readonly create = (params: {
    readonly id: TodoId
    readonly title: TodoTitle
    readonly priority: Priority
    readonly dueDate: Option.Option<Date>
    readonly now: Date
  }): Todo =>
    new Todo({
      id: params.id,
      title: params.title,
      status: TodoStatus.initial(),
      priority: params.priority,
      dueDate: params.dueDate,
      createdAt: params.now,
      updatedAt: params.now,
    })
  
  // ═══ Domain Behavior ═══
  readonly complete = (now: Date): Effect.Effect<Todo, InvalidTransitionError> => {
    if (!this.status.canTransitionTo("completed")) {
      return Effect.fail(
        new InvalidTransitionError({
          from: this.status.value,
          to: "completed",
          reason: `Cannot complete a ${this.status.value} todo`,
        })
      )
    }
    return Effect.succeed(
      new Todo({
        ...this,
        status: TodoStatus.make("completed"),
        updatedAt: now,
      })
    )
  }
  
  readonly changeTitle = (
    newTitle: TodoTitle,
    now: Date
  ): Todo =>
    new Todo({ ...this, title: newTitle, updatedAt: now })
  
  readonly changePriority = (
    newPriority: Priority,
    now: Date
  ): Todo =>
    new Todo({ ...this, priority: newPriority, updatedAt: now })
}

Доменные ошибки

// src/domain/errors/todo-validation-error.ts
import { Data } from "effect"

export class TodoValidationError extends Data.TaggedError("TodoValidationError")<{
  readonly field: string
  readonly message: string
  readonly cause?: unknown
}> {}

// src/domain/errors/todo-not-found-error.ts
export class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
  readonly todoId: string
}> {}

// src/domain/errors/invalid-transition-error.ts
export class InvalidTransitionError extends Data.TaggedError("InvalidTransitionError")<{
  readonly from: string
  readonly to: string
  readonly reason: string
}> {}

// src/domain/errors/persistence-error.ts
export class PersistenceError extends Data.TaggedError("PersistenceError")<{
  readonly operation: string
  readonly cause: unknown
}> {}

Шаг 1: Определение портов

Driven Port: TodoRepository

// src/ports/driven/todo-repository.ts
import { Context, Effect } from "effect"
import type { Todo } from "../../domain/model/todo.js"
import type { TodoId } from "../../domain/model/todo-id.js"
import type { TodoNotFoundError, PersistenceError } from "../../domain/errors/index.js"

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

Driven Port: IdGenerator

// src/ports/driven/id-generator.ts
import { Context, Effect } from "effect"
import type { TodoId } from "../../domain/model/todo-id.js"

export class IdGenerator extends Context.Tag("IdGenerator")<
  IdGenerator,
  {
    readonly generate: Effect.Effect<TodoId>
  }
>() {}

Driven Port: Clock (детерминированное время)

// src/ports/driven/app-clock.ts
import { Context, Effect } from "effect"

export class AppClock extends Context.Tag("AppClock")<
  AppClock,
  {
    readonly now: Effect.Effect<Date>
  }
>() {}

Driving Port: CreateTodoUseCase

// src/ports/driving/create-todo.ts
import { Context, Effect } from "effect"
import type { Todo } from "../../domain/model/todo.js"
import type { TodoValidationError, PersistenceError } from "../../domain/errors/index.js"

export interface CreateTodoInput {
  readonly title: string
  readonly priority: "low" | "medium" | "high"
  readonly dueDate: string | undefined
}

export class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
  CreateTodoUseCase,
  {
    readonly execute: (
      input: CreateTodoInput
    ) => Effect.Effect<Todo, TodoValidationError | PersistenceError>
  }
>() {}

Шаг 2: Application Service (реализация Driving Port)

// src/application/commands/create-todo.handler.ts
import { Effect, Layer, Option } from "effect"
import { CreateTodoUseCase, type CreateTodoInput } from "../../ports/driving/create-todo.js"
import { TodoRepository } from "../../ports/driven/todo-repository.js"
import { IdGenerator } from "../../ports/driven/id-generator.js"
import { AppClock } from "../../ports/driven/app-clock.js"
import { Todo } from "../../domain/model/todo.js"
import { TodoTitle } from "../../domain/model/todo-title.js"
import { Priority } from "../../domain/model/priority.js"

export const CreateTodoHandlerLive = Layer.effect(
  CreateTodoUseCase,
  Effect.gen(function* () {
    // Получаем зависимости (Driven Ports) из контекста
    const repo = yield* TodoRepository
    const idGen = yield* IdGenerator
    const clock = yield* AppClock
    
    return CreateTodoUseCase.of({
      execute: (input: CreateTodoInput) =>
        Effect.gen(function* () {
          // ══════════════════════════════════════════
          // 1. Валидация через доменные Value Objects
          // ══════════════════════════════════════════
          const title = yield* TodoTitle.make(input.title)
          const priority = Priority.make(input.priority)
          const dueDate = input.dueDate
            ? Option.some(new Date(input.dueDate))
            : Option.none<Date>()
          
          // ══════════════════════════════════════════
          // 2. Получение инфраструктурных данных через порты
          // ══════════════════════════════════════════
          const id = yield* idGen.generate
          const now = yield* clock.now
          
          // ══════════════════════════════════════════
          // 3. Создание доменной сущности (бизнес-логика)
          // ══════════════════════════════════════════
          const todo = Todo.create({
            id,
            title,
            priority,
            dueDate,
            now,
          })
          
          // ══════════════════════════════════════════
          // 4. Персистентность через Driven Port
          // ══════════════════════════════════════════
          yield* repo.save(todo)
          
          // ══════════════════════════════════════════
          // 5. Возврат результата
          // ══════════════════════════════════════════
          return todo
        }),
    })
  })
)

Обратите внимание: Application Service не содержит бизнес-логики. Он:

  • Вызывает доменные Value Objects для валидации (TodoTitle.make)
  • Вызывает доменную фабрику для создания Entity (Todo.create)
  • Координирует сохранение через порт (repo.save)
  • Всё. Никакого SQL, никакого HTTP.

Шаг 3: Driven Adapter — SQLite

SQLite Client (инфраструктурный сервис)

// src/adapters/driven/sqlite/client.ts
import { Context, Effect, Layer } from "effect"
import { Database } from "bun:sqlite"

// Инфраструктурный сервис — НЕ порт, а деталь реализации адаптера
export class SqliteClient extends Context.Tag("SqliteClient")<
  SqliteClient,
  {
    readonly execute: (sql: string, params?: Record<string, unknown>) => Effect.Effect<void>
    readonly queryOne: <T>(sql: string, params?: Record<string, unknown>) => Effect.Effect<T | null>
    readonly queryAll: <T>(sql: string, params?: Record<string, unknown>) => Effect.Effect<ReadonlyArray<T>>
  }
>() {}

export const SqliteClientLive = (dbPath: string) =>
  Layer.sync(SqliteClient, () => {
    const db = new Database(dbPath)
    
    // WAL mode для лучшей производительности
    db.run("PRAGMA journal_mode = WAL")
    db.run("PRAGMA foreign_keys = ON")
    
    return SqliteClient.of({
      execute: (sql, params = {}) =>
        Effect.try({
          try: () => { db.run(sql, params as any) },
          catch: (e) => e,
        }),
      
      queryOne: <T>(sql: string, params: Record<string, unknown> = {}) =>
        Effect.try({
          try: () => db.query(sql).get(params as any) as T | null,
          catch: (e) => e,
        }),
      
      queryAll: <T>(sql: string, params: Record<string, unknown> = {}) =>
        Effect.try({
          try: () => db.query(sql).all(params as any) as ReadonlyArray<T>,
          catch: (e) => e,
        }),
    })
  })

// In-memory вариант для тестов
export const SqliteClientInMemory = SqliteClientLive(":memory:")

Маппер: Domain ↔ SQL

// src/adapters/driven/sqlite/mappers/todo.mapper.ts
import { Option } from "effect"
import { Todo } from "../../../../domain/model/todo.js"
import { TodoTitle } from "../../../../domain/model/todo-title.js"
import { TodoStatus } from "../../../../domain/model/todo-status.js"
import { Priority } from "../../../../domain/model/priority.js"
import type { TodoId } from "../../../../domain/model/todo-id.js"

// ══════════════════════════════════════════
// SQL Row Type — инфраструктурный тип
// Принадлежит адаптеру, НЕ домену
// ══════════════════════════════════════════
export interface TodoRow {
  readonly id: string
  readonly title: string
  readonly status: string
  readonly priority: string
  readonly due_date: string | null       // snake_case — SQL convention
  readonly created_at: string            // ISO 8601 string — SQLite хранит текст
  readonly updated_at: string
}

// ══════════════════════════════════════════
// Domain → SQL Row
// Трансформация при записи
// ══════════════════════════════════════════
export const todoToRow = (todo: Todo): 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.none → NULL
    onSome: (d) => d.toISOString(),                // Date → ISO string
  }),
  created_at: todo.createdAt.toISOString(),        // Date → ISO string
  updated_at: todo.updatedAt.toISOString(),
})

// ══════════════════════════════════════════
// SQL Row → Domain
// Трансформация при чтении
// ══════════════════════════════════════════
export const todoFromRow = (row: TodoRow): Todo =>
  new Todo({
    id: row.id as TodoId,                          // string → TodoId
    title: new TodoTitle({ value: row.title }),     // string → TodoTitle
    status: TodoStatus.make(row.status as any),     // string → TodoStatus
    priority: Priority.make(row.priority as any),   // string → Priority
    dueDate: row.due_date !== null
      ? Option.some(new Date(row.due_date))        // string → Option<Date>
      : Option.none(),                              // NULL → Option.none
    createdAt: new Date(row.created_at),           // ISO string → Date
    updatedAt: new Date(row.updated_at),
  })

Реализация TodoRepository для SQLite

// src/adapters/driven/sqlite/repositories/todo.repository.sqlite.ts
import { Effect, Layer } from "effect"
import { TodoRepository } from "../../../../ports/driven/todo-repository.js"
import { SqliteClient } from "../client.js"
import { todoToRow, todoFromRow, type TodoRow } from "../mappers/todo.mapper.js"
import { TodoNotFoundError, PersistenceError } from "../../../../domain/errors/index.js"
import type { TodoId } from "../../../../domain/model/todo-id.js"

export const TodoRepositorySqliteLive = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    const sql = yield* SqliteClient
    
    return TodoRepository.of({
      // ══════════════════════════════════════════
      // SAVE — Domain Entity → SQL INSERT/REPLACE
      // ══════════════════════════════════════════
      save: (todo) =>
        Effect.gen(function* () {
          const row = todoToRow(todo)  // Domain → SQL mapping
          
          yield* sql.execute(
            `INSERT OR REPLACE INTO todos 
             (id, title, status, priority, due_date, created_at, updated_at)
             VALUES ($id, $title, $status, $priority, $due_date, $created_at, $updated_at)`,
            {
              $id: row.id,
              $title: row.title,
              $status: row.status,
              $priority: row.priority,
              $due_date: row.due_date,
              $created_at: row.created_at,
              $updated_at: row.updated_at,
            }
          )
        }).pipe(
          Effect.mapError((cause) =>
            new PersistenceError({ operation: "save", cause })
          )
        ),
      
      // ══════════════════════════════════════════
      // FIND BY ID — SQL SELECT → Domain Entity
      // ══════════════════════════════════════════
      findById: (id: TodoId) =>
        Effect.gen(function* () {
          const row = yield* sql.queryOne<TodoRow>(
            `SELECT * FROM todos WHERE id = $id`,
            { $id: id }
          )
          
          if (row === null) {
            return yield* Effect.fail(
              new TodoNotFoundError({ todoId: id })
            )
          }
          
          return todoFromRow(row)  // SQL → Domain mapping
        }).pipe(
          Effect.mapError((e) =>
            e instanceof TodoNotFoundError
              ? e
              : new PersistenceError({ operation: "findById", cause: e })
          )
        ),
      
      // ══════════════════════════════════════════
      // FIND ALL — SQL SELECT * → ReadonlyArray<Domain Entity>
      // ══════════════════════════════════════════
      findAll: Effect.gen(function* () {
        const rows = yield* sql.queryAll<TodoRow>(
          `SELECT * FROM todos ORDER BY created_at DESC`
        )
        return rows.map(todoFromRow)  // SQL → Domain mapping для каждой строки
      }).pipe(
        Effect.mapError((cause) =>
          new PersistenceError({ operation: "findAll", cause })
        )
      ),
      
      // ══════════════════════════════════════════
      // DELETE — SQL DELETE
      // ══════════════════════════════════════════
      delete: (id: TodoId) =>
        Effect.gen(function* () {
          // Сначала проверяем, что Todo существует
          const row = yield* sql.queryOne<TodoRow>(
            `SELECT id FROM todos WHERE id = $id`,
            { $id: id }
          )
          if (row === null) {
            return yield* Effect.fail(
              new TodoNotFoundError({ todoId: id })
            )
          }
          yield* sql.execute(
            `DELETE FROM todos WHERE id = $id`,
            { $id: id }
          )
        }).pipe(
          Effect.mapError((e) =>
            e instanceof TodoNotFoundError
              ? e
              : new PersistenceError({ operation: "delete", cause: e })
          )
        ),
      
      // ══════════════════════════════════════════
      // EXISTS BY TITLE — SQL SELECT COUNT
      // ══════════════════════════════════════════
      existsByTitle: (title: string) =>
        Effect.gen(function* () {
          const result = yield* sql.queryOne<{ count: number }>(
            `SELECT COUNT(*) as count FROM todos WHERE title = $title`,
            { $title: title }
          )
          return (result?.count ?? 0) > 0
        }).pipe(
          Effect.mapError((cause) =>
            new PersistenceError({ operation: "existsByTitle", cause })
          )
        ),
    })
  })
)

Остальные Driven Adapters

// src/adapters/driven/id-generator/uuid.id-generator.ts
import { Effect, Layer } from "effect"
import { IdGenerator } from "../../../ports/driven/id-generator.js"
import type { TodoId } from "../../../domain/model/todo-id.js"

export const UuidIdGeneratorLive = Layer.succeed(
  IdGenerator,
  IdGenerator.of({
    generate: Effect.sync(() => crypto.randomUUID() as TodoId),
  })
)
// src/adapters/driven/clock/real-clock.ts
import { Effect, Layer } from "effect"
import { AppClock } from "../../../ports/driven/app-clock.js"

export const RealClockLive = Layer.succeed(
  AppClock,
  AppClock.of({
    now: Effect.sync(() => new Date()),
  })
)

Шаг 4: Driving Adapter — HTTP

DTO: HTTP-специфичные типы

// src/adapters/driving/http/dto/create-todo.request.ts
import { Schema } from "effect"

// DTO для HTTP-входа. НЕ доменный тип!
// Может содержать HTTP-специфику:
// - строковые даты (JSON не имеет типа Date)
// - optional поля, которые в домене обязательны
// - форматы, удобные для клиента
export class CreateTodoRequestBody extends Schema.Class<CreateTodoRequestBody>(
  "CreateTodoRequestBody"
)({
  title: Schema.String.pipe(
    Schema.minLength(1, { message: () => "Title is required" }),
    Schema.maxLength(200, { message: () => "Title is too long" }),
  ),
  priority: Schema.optionalWith(
    Schema.Literal("low", "medium", "high"),
    { default: () => "medium" as const }  // HTTP default ≠ доменное правило
  ),
  dueDate: Schema.optionalWith(Schema.String, { as: "Option" }),
}) {}
// src/adapters/driving/http/dto/todo.response.ts
import { Schema } from "effect"
import type { Todo } from "../../../../domain/model/todo.js"
import { Option } from "effect"

// DTO для HTTP-выхода
export class TodoResponseBody extends Schema.Class<TodoResponseBody>(
  "TodoResponseBody"
)({
  id: Schema.String,
  title: Schema.String,
  status: Schema.String,
  priority: Schema.String,
  dueDate: Schema.NullOr(Schema.String),
  createdAt: Schema.String,           // ISO 8601 string для JSON
  updatedAt: Schema.String,
}) {
  // Маппинг Domain → HTTP Response
  static readonly fromDomain = (todo: Todo): TodoResponseBody =>
    new TodoResponseBody({
      id: todo.id,
      title: todo.title.value,
      status: todo.status.value,
      priority: todo.priority.value,
      dueDate: Option.match(todo.dueDate, {
        onNone: () => null,
        onSome: (d) => d.toISOString(),
      }),
      createdAt: todo.createdAt.toISOString(),
      updatedAt: todo.updatedAt.toISOString(),
    })
}

HTTP Routes

// src/adapters/driving/http/routes/todo.routes.ts
import {
  HttpRouter,
  HttpServerRequest,
  HttpServerResponse,
} from "@effect/platform"
import { Effect, Option } from "effect"
import { CreateTodoUseCase } from "../../../../ports/driving/create-todo.js"
import { CompleteTodoUseCase } from "../../../../ports/driving/complete-todo.js"
import { ListTodosUseCase } from "../../../../ports/driving/list-todos.js"
import { GetTodoUseCase } from "../../../../ports/driving/get-todo.js"
import { CreateTodoRequestBody } from "../dto/create-todo.request.js"
import { TodoResponseBody } from "../dto/todo.response.js"
import type { CreateTodoInput } from "../../../../ports/driving/create-todo.js"

export const todoRoutes = HttpRouter.empty.pipe(
  // ══════════════════════════════════════════
  // POST /api/todos — Create Todo
  // ══════════════════════════════════════════
  HttpRouter.post(
    "/api/todos",
    Effect.gen(function* () {
      // 1. Parse HTTP body → DTO
      const body = yield* HttpServerRequest.schemaBodyJson(CreateTodoRequestBody)
      
      // 2. DTO → Use Case Input (маппинг в адаптере!)
      const input: CreateTodoInput = {
        title: body.title,
        priority: body.priority,
        dueDate: Option.match(body.dueDate, {
          onNone: () => undefined,
          onSome: (d) => d,
        }),
      }
      
      // 3. Вызов Driving Port
      const useCase = yield* CreateTodoUseCase
      const todo = yield* useCase.execute(input)
      
      // 4. Domain → HTTP Response DTO
      const response = TodoResponseBody.fromDomain(todo)
      
      // 5. HTTP Response
      return yield* HttpServerResponse.json(response, { status: 201 })
    })
  ),
  
  // ══════════════════════════════════════════
  // GET /api/todos — List Todos
  // ══════════════════════════════════════════
  HttpRouter.get(
    "/api/todos",
    Effect.gen(function* () {
      const useCase = yield* ListTodosUseCase
      const todos = yield* useCase.execute()
      
      const response = todos.map(TodoResponseBody.fromDomain)
      return yield* HttpServerResponse.json(response)
    })
  ),
  
  // ══════════════════════════════════════════
  // GET /api/todos/:id — Get Todo by ID
  // ══════════════════════════════════════════
  HttpRouter.get(
    "/api/todos/:id",
    Effect.gen(function* () {
      const params = yield* HttpRouter.params
      const useCase = yield* GetTodoUseCase
      const todo = yield* useCase.execute(params.id)
      
      const response = TodoResponseBody.fromDomain(todo)
      return yield* HttpServerResponse.json(response)
    })
  ),
  
  // ══════════════════════════════════════════
  // PATCH /api/todos/:id/complete — Complete Todo
  // ══════════════════════════════════════════
  HttpRouter.patch(
    "/api/todos/:id/complete",
    Effect.gen(function* () {
      const params = yield* HttpRouter.params
      const useCase = yield* CompleteTodoUseCase
      const todo = yield* useCase.execute(params.id)
      
      const response = TodoResponseBody.fromDomain(todo)
      return yield* HttpServerResponse.json(response)
    })
  ),
)

Error Handler: маппинг доменных ошибок → HTTP

// src/adapters/driving/http/middleware/error-handler.ts
import { HttpServerResponse } from "@effect/platform"
import { Effect } from "effect"

interface ErrorResponseBody {
  readonly error: string
  readonly message: string
  readonly details?: unknown
}

const makeErrorResponse = (
  status: number,
  body: ErrorResponseBody
) => HttpServerResponse.json(body, { status })

// ══════════════════════════════════════════
// Маппинг: Domain/Application Error → HTTP Status Code
// ══════════════════════════════════════════
export const handleErrors = <A, R>(
  effect: Effect.Effect<A, unknown, R>
): Effect.Effect<A | HttpServerResponse.HttpServerResponse, never, R> =>
  effect.pipe(
    Effect.catchTags({
      // Domain Errors → 4xx
      TodoValidationError: (e) =>
        makeErrorResponse(422, {
          error: "VALIDATION_ERROR",
          message: e.message,
          details: { field: e.field },
        }),
      
      TodoNotFoundError: (e) =>
        makeErrorResponse(404, {
          error: "NOT_FOUND",
          message: `Todo ${e.todoId} not found`,
        }),
      
      InvalidTransitionError: (e) =>
        makeErrorResponse(409, {
          error: "INVALID_TRANSITION",
          message: e.reason,
          details: { from: e.from, to: e.to },
        }),
      
      // Infrastructure Errors → 5xx
      PersistenceError: (_e) =>
        makeErrorResponse(500, {
          error: "INTERNAL_ERROR",
          message: "An internal error occurred",
          // НЕ выдаём детали инфраструктурных ошибок клиенту!
        }),
    }),
    
    // Неожиданные ошибки (defects)
    Effect.catchAll((_unknown) =>
      makeErrorResponse(500, {
        error: "INTERNAL_ERROR",
        message: "An unexpected error occurred",
      })
    )
  )

HTTP Server

// src/adapters/driving/http/server.ts
import { HttpRouter, HttpServer } from "@effect/platform"
import { BunHttpServer } from "@effect/platform-bun"
import { Layer, Effect } from "effect"
import { todoRoutes } from "./routes/todo.routes.js"
import { handleErrors } from "./middleware/error-handler.js"

// Собираем все роуты
const allRoutes = HttpRouter.empty.pipe(
  HttpRouter.mount("/", todoRoutes),
  
  // Health check
  HttpRouter.get("/health",
    Effect.succeed(HttpServerResponse.json({ status: "ok" }))
  ),
)

// Оборачиваем в error handler
const app = allRoutes.pipe(
  HttpRouter.use(handleErrors)
)

// HTTP Server Layer
export const HttpServerLive = BunHttpServer.layer(app, { port: 3000 })

Шаг 5: Composition Root — сборка всех Layer

// src/composition/production.ts
import { Layer } from "effect"

// Driven Adapters
import { TodoRepositorySqliteLive } from "../adapters/driven/sqlite/repositories/todo.repository.sqlite.js"
import { SqliteClientLive } from "../adapters/driven/sqlite/client.js"
import { UuidIdGeneratorLive } from "../adapters/driven/id-generator/uuid.id-generator.js"
import { RealClockLive } from "../adapters/driven/clock/real-clock.js"

// Application Handlers (реализации Driving Ports)
import { CreateTodoHandlerLive } from "../application/commands/create-todo.handler.js"

// Driving Adapter
import { HttpServerLive } from "../adapters/driving/http/server.js"

// ═══════════════════════════════════════════
// ГРАФ ЗАВИСИМОСТЕЙ
// ═══════════════════════════════════════════
//
//  HttpServerLive
//       │
//       ├── CreateTodoHandlerLive (Application)
//       │        │
//       │        ├── TodoRepository (Port) ← TodoRepositorySqliteLive
//       │        │        │
//       │        │        └── SqliteClient ← SqliteClientLive("./data/todos.db")
//       │        │
//       │        ├── IdGenerator (Port) ← UuidIdGeneratorLive
//       │        │
//       │        └── AppClock (Port) ← RealClockLive
//       │
//       └── ... (другие handlers)

// Инфраструктурный слой
const InfraLayer = Layer.mergeAll(
  SqliteClientLive("./data/todos.db"),
  UuidIdGeneratorLive,
  RealClockLive,
)

// Repository слой (зависит от Infrastructure)
const RepositoryLayer = TodoRepositorySqliteLive.pipe(
  Layer.provide(InfraLayer)
)

// Application слой (зависит от Repository + Infra)
const AppLayer = Layer.mergeAll(
  CreateTodoHandlerLive,
  // CompleteTodoHandlerLive,
  // ListTodosHandlerLive,
  // ...
).pipe(
  Layer.provide(Layer.mergeAll(RepositoryLayer, InfraLayer))
)

// Полный Production Layer
export const ProductionLayer = HttpServerLive.pipe(
  Layer.provide(AppLayer)
)

Entry Point

// src/main.ts
import { Effect } from "effect"
import { BunRuntime } from "@effect/platform-bun"
import { ProductionLayer } from "./composition/production.js"

const program = Effect.gen(function* () {
  yield* Effect.log("🚀 Todo App starting...")
  yield* Effect.never // HTTP server keeps running
})

BunRuntime.runMain(
  program.pipe(Effect.provide(ProductionLayer))
)

Полная карта маппинга

Суммируем все трансформации данных в одной таблице:

┌─────────────────────────────────────────────────────────────────────────────┐
│                        ПОЛНАЯ КАРТА МАППИНГА                               │
├─────────────┬──────────────────────┬────────────────────┬──────────────────┤
│ Граница     │ Источник             │ Цель               │ Где происходит   │
├─────────────┼──────────────────────┼────────────────────┼──────────────────┤
│ HTTP → App  │ JSON string          │ CreateTodoRequest  │ Schema.decode    │
│             │ CreateTodoRequest    │ CreateTodoInput    │ HTTP Adapter     │
├─────────────┼──────────────────────┼────────────────────┼──────────────────┤
│ App → Domain│ CreateTodoInput      │ TodoTitle,Priority │ TodoTitle.make() │
│             │ TodoTitle + ...      │ Todo               │ Todo.create()    │
├─────────────┼──────────────────────┼────────────────────┼──────────────────┤
│ Domain → SQL│ Todo                 │ TodoRow            │ todoToRow()      │
│             │ TodoRow              │ SQL INSERT         │ SqliteClient     │
├─────────────┼──────────────────────┼────────────────────┼──────────────────┤
│ SQL → Domain│ SQL SELECT           │ TodoRow            │ SqliteClient     │
│             │ TodoRow              │ Todo               │ todoFromRow()    │
├─────────────┼──────────────────────┼────────────────────┼──────────────────┤
│ Domain → App│ Todo                 │ Todo (as is)       │ Return value     │
├─────────────┼──────────────────────┼────────────────────┼──────────────────┤
│ App → HTTP  │ Todo                 │ TodoResponseBody   │ fromDomain()     │
│             │ TodoResponseBody     │ JSON string        │ Schema.encode    │
├─────────────┼──────────────────────┼────────────────────┼──────────────────┤
│ Ошибки      │ TodoValidationError  │ 422 + JSON         │ Error Handler    │
│             │ TodoNotFoundError    │ 404 + JSON         │ Error Handler    │
│             │ PersistenceError     │ 500 + JSON         │ Error Handler    │
└─────────────┴──────────────────────┴────────────────────┴──────────────────┘

Проверка: замена SQLite на InMemory

Главный тест архитектуры: можем ли мы заменить SQLite на InMemory-хранилище, не меняя ни строчки в домене, портах, application или HTTP-адаптере?

// src/adapters/driven/in-memory/todo.repository.memory.ts
import { Effect, Layer, Ref } from "effect"
import { TodoRepository } from "../../../ports/driven/todo-repository.js"
import { TodoNotFoundError, PersistenceError } from "../../../domain/errors/index.js"
import type { Todo } from "../../../domain/model/todo.js"
import type { TodoId } from "../../../domain/model/todo-id.js"

export const TodoRepositoryInMemoryLive = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    // Mutable state, изолированный внутри Layer
    const storeRef = yield* Ref.make<ReadonlyMap<string, Todo>>(new Map())
    
    return TodoRepository.of({
      save: (todo) =>
        Ref.update(storeRef, (store) =>
          new Map([...store, [todo.id, todo]])
        ),
      
      findById: (id: TodoId) =>
        Effect.gen(function* () {
          const store = yield* Ref.get(storeRef)
          const todo = store.get(id)
          if (!todo) {
            return yield* Effect.fail(
              new TodoNotFoundError({ todoId: id })
            )
          }
          return todo
        }),
      
      findAll: Effect.gen(function* () {
        const store = yield* Ref.get(storeRef)
        return [...store.values()]
      }),
      
      delete: (id: TodoId) =>
        Effect.gen(function* () {
          const store = yield* Ref.get(storeRef)
          if (!store.has(id)) {
            return yield* Effect.fail(
              new TodoNotFoundError({ todoId: id })
            )
          }
          yield* Ref.update(storeRef, (s) => {
            const next = new Map(s)
            next.delete(id)
            return next
          })
        }),
      
      existsByTitle: (title: string) =>
        Effect.gen(function* () {
          const store = yield* Ref.get(storeRef)
          return [...store.values()].some((t) => t.title.value === title)
        }),
    })
  })
)

Замена — одна строка в Composition Root:

// Было:
const RepositoryLayer = TodoRepositorySqliteLive.pipe(
  Layer.provide(SqliteClientLive("./data/todos.db"))
)

// Стало:
const RepositoryLayer = TodoRepositoryInMemoryLive
// Всё! Ни domain, ни application, ни HTTP-адаптер не изменились.

Это и есть сила гексагональной архитектуры: замена инфраструктуры — это замена одного Layer. Ядро остаётся неприкосновенным.


Резюме

В этой статье мы прошли весь путь от HTTP-запроса до SQLite и обратно, показав каждый компонент:

  1. Domain — чистые типы и бизнес-правила (TodoTitle, Priority, Todo)
  2. Ports — контракты через Context.Tag (TodoRepository, CreateTodoUseCase)
  3. Application — оркестрация через Layer.effect (CreateTodoHandlerLive)
  4. Driven Adapter — SQLite реализация с маппингом Domain ↔ SQL Row
  5. Driving Adapter — HTTP роуты с маппингом HTTP DTO ↔ Domain
  6. Composition — сборка всех Layer в граф зависимостей
  7. Замена — InMemory вместо SQLite за одну строку

Каждая граница — это точка маппинга, где данные меняют форму, а ошибки трансформируются. Effect-ts делает этот маппинг типобезопасным: если вы забудете обработать ошибку или предоставить зависимость, компилятор скажет об этом до запуска.