Маппинг: 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 и обратно, показав каждый компонент:
- Domain — чистые типы и бизнес-правила (TodoTitle, Priority, Todo)
- Ports — контракты через
Context.Tag(TodoRepository, CreateTodoUseCase) - Application — оркестрация через
Layer.effect(CreateTodoHandlerLive) - Driven Adapter — SQLite реализация с маппингом Domain ↔ SQL Row
- Driving Adapter — HTTP роуты с маппингом HTTP DTO ↔ Domain
- Composition — сборка всех Layer в граф зависимостей
- Замена — InMemory вместо SQLite за одну строку
Каждая граница — это точка маппинга, где данные меняют форму, а ошибки трансформируются. Effect-ts делает этот маппинг типобезопасным: если вы забудете обработать ошибку или предоставить зависимость, компилятор скажет об этом до запуска.