Адаптеры: реализации портов для конкретных технологий
Адаптер как Layer в Effect-ts. Driving-адаптеры (HTTP, CLI) и Driven-адаптеры (Repository, Mailer). Три обязанности: маппинг данных, ошибок, управление ресурсами. Жизненный цикл, мемоизация, паттерн адаптер-декоратор.
Введение: что такое адаптер
Если порт — это «розетка стандартной формы», то адаптер — это «вилка конкретного устройства, подогнанная под эту розетку». Адаптер берёт контракт, определённый портом, и реализует его с помощью конкретной технологии.
Адаптер — единственное место в системе, где разрешено знать о технологических деталях. SQL-запросы, HTTP-заголовки, файловые пути, формат JSON — всё это живёт исключительно в адаптерах.
Адаптер = Layer в Effect-ts
В Effect-ts адаптер реализуется через Layer — механизм, который создаёт конкретную реализацию сервиса (порта) и управляет её жизненным циклом:
import { Layer, Effect, Context } from "effect"
// ПОРТ (определён в Application Core)
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly findAll: (filter: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>>
readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
}
>() {}
// АДАПТЕР: конкретная реализация для SQLite
// Тип: Layer<TodoRepository, never, SqliteClient>
const SqliteTodoRepository: Layer.Layer<TodoRepository, never, SqliteClient> =
Layer.effect(
TodoRepository,
Effect.gen(function* () {
const db = yield* SqliteClient
return {
findById: (id) =>
Effect.gen(function* () {
const row = db
.query("SELECT * FROM todos WHERE id = ?")
.get(id.value) as TodoRow | null
if (!row) {
return yield* Effect.fail(new TodoNotFound({ id }))
}
return yield* rowToTodo(row)
}),
save: (todo) =>
Effect.try({
try: () => {
db.run(
`INSERT OR REPLACE INTO todos (id, title, status, priority, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[
todo.id.value,
todo.title.value,
todo.status,
todo.priority,
todo.createdAt.toISOString(),
Option.getOrNull(todo.completedAt)?.toISOString() ?? null,
]
)
},
catch: (error) => new RepositoryError({
operation: "save",
cause: error
}),
}),
findAll: (filter) =>
Effect.gen(function* () {
const rows = db
.query(buildFilterQuery(filter))
.all() as ReadonlyArray<TodoRow>
return yield* Effect.forEach(rows, rowToTodo)
}),
delete: (id) =>
Effect.gen(function* () {
const changes = db
.run("DELETE FROM todos WHERE id = ?", [id.value])
.changes
if (changes === 0) {
return yield* Effect.fail(new TodoNotFound({ id }))
}
}),
}
})
)
Типы адаптеров
Driving Adapters (Primary Adapters)
Driving Adapter вызывает Application Core. Он преобразует внешний запрос (HTTP, CLI, событие) в вызов Use Case:
HTTP Request
│
▼
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
│ HTTP Server │────►│ HTTP Adapter │────►│ CreateTodoUseCase│
│ (Bun/Node) │ │ (маппинг) │ │ (Application) │
└──────────────┘ └──────────────┘ └─────────────────┘
HTTP-адаптер — классический Driving Adapter:
import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform"
// Driving Adapter: HTTP → Application Core
const TodoHttpAdapter = HttpRouter.make(
// POST /todos → CreateTodoUseCase
HttpRouter.post(
"/todos",
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
// 1. Маппинг: HTTP Request → доменный Input
const body = yield* request.json
const input = yield* Schema.decode(CreateTodoInput)(body)
// 2. Вызов Application Core через Driving Port
const useCase = yield* CreateTodoUseCase
const todo = yield* useCase.execute(input)
// 3. Маппинг: доменный Output → HTTP Response
const response = yield* Schema.encode(TodoResponse)(todo)
return HttpServerResponse.json(response, { status: 201 })
}).pipe(
// 4. Маппинг: доменные ошибки → HTTP-коды
Effect.catchTag("ValidationError", (e) =>
Effect.succeed(HttpServerResponse.json(
{ error: e.message },
{ status: 400 }
))
),
Effect.catchTag("DuplicateTitle", (e) =>
Effect.succeed(HttpServerResponse.json(
{ error: `Задача с заголовком "${e.title.value}" уже существует` },
{ status: 409 }
))
)
)
),
// GET /todos → ListTodosUseCase
HttpRouter.get(
"/todos",
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const filter = yield* parseFilterFromQuery(request.url)
const useCase = yield* ListTodosUseCase
const todos = yield* useCase.execute(filter)
const response = yield* Schema.encode(Schema.Array(TodoResponse))(todos)
return HttpServerResponse.json(response)
})
),
// PATCH /todos/:id/complete → CompleteTodoUseCase
HttpRouter.patch(
"/todos/:id/complete",
Effect.gen(function* () {
const params = yield* HttpRouter.params
const todoId = yield* Schema.decode(TodoId)({ value: params.id })
const useCase = yield* CompleteTodoUseCase
const todo = yield* useCase.execute(todoId)
const response = yield* Schema.encode(TodoResponse)(todo)
return HttpServerResponse.json(response)
}).pipe(
Effect.catchTag("TodoNotFound", () =>
Effect.succeed(HttpServerResponse.json(
{ error: "Задача не найдена" },
{ status: 404 }
))
),
Effect.catchTag("InvalidTransition", (e) =>
Effect.succeed(HttpServerResponse.json(
{ error: e.reason },
{ status: 422 }
))
)
)
)
)
CLI-адаптер — ещё один Driving Adapter для того же Application Core:
// Driving Adapter: CLI → Application Core
const TodoCliAdapter = Effect.gen(function* () {
const args = process.argv.slice(2)
const [command, ...rest] = args
switch (command) {
case "create": {
const title = rest.join(" ")
const useCase = yield* CreateTodoUseCase
const todo = yield* useCase.execute({ title })
console.log(`✅ Создана задача: ${todo.title.value} (${todo.id.value})`)
break
}
case "complete": {
const id = yield* Schema.decode(TodoId)({ value: rest[0] })
const useCase = yield* CompleteTodoUseCase
yield* useCase.execute(id)
console.log(`✅ Задача завершена`)
break
}
case "list": {
const useCase = yield* ListTodosUseCase
const todos = yield* useCase.execute({})
for (const todo of todos) {
const status = todo.status === "completed" ? "✓" : "○"
console.log(` ${status} ${todo.title.value}`)
}
break
}
default:
console.log("Команды: create <title>, complete <id>, list")
}
})
Ключевое наблюдение: оба адаптера (HTTP и CLI) вызывают одни и те же Use Cases. Application Core одинаков — меняется только способ ввода/вывода.
Driven Adapters (Secondary Adapters)
Driven Adapter вызывается Application Core. Он реализует порт, преобразуя доменные операции в технологические:
Application Core Технология
┌─────────────────┐ ┌──────────────┐ ┌──────────┐
│ repo.save(todo) │────►│SQLite Adapter│────►│ SQLite DB│
│ │ │ (маппинг) │ │ │
└─────────────────┘ └──────────────┘ └──────────┘
Примеры Driven Adapters:
// Driven Adapter: InMemory (для тестов)
const InMemoryTodoRepository = Layer.sync(TodoRepository, () => {
const store = new Map<string, Todo>()
return {
findById: (id) => {
const todo = store.get(id.value)
return todo
? Effect.succeed(todo)
: Effect.fail(new TodoNotFound({ id }))
},
save: (todo) =>
Effect.sync(() => { store.set(todo.id.value, todo) }),
findAll: (_filter) =>
Effect.sync(() => [...store.values()] as ReadonlyArray<Todo>),
delete: (id) =>
store.has(id.value)
? Effect.sync(() => { store.delete(id.value) })
: Effect.fail(new TodoNotFound({ id })),
}
})
// Driven Adapter: Console Notification (для разработки)
const ConsoleNotificationService = Layer.succeed(NotificationService, {
send: (notification) =>
Effect.log(`📧 [${notification.type}] → ${notification.recipient}: ${notification.message}`),
})
// Driven Adapter: SMTP Notification (для production)
const SmtpNotificationService = Layer.scoped(
NotificationService,
Effect.gen(function* () {
const config = yield* SmtpConfig
const transport = yield* createSmtpTransport(config)
return {
send: (notification) =>
Effect.tryPromise({
try: () => transport.sendMail({
to: notification.recipient,
subject: notification.subject,
text: notification.message,
}),
catch: (error) => new NotificationError({ cause: error }),
}),
}
})
)
Обязанности адаптера
Адаптер выполняет три ключевые функции:
1. Маппинг данных (Data Mapping)
Преобразование между доменными типами и технологическими:
// Доменный тип → Инфраструктурный тип
const todoToRow = (todo: Todo): TodoRow => ({
id: todo.id.value,
title: todo.title.value,
status: todo.status,
priority: todo.priority,
created_at: todo.createdAt.toISOString(),
completed_at: Option.match(todo.completedAt, {
onNone: () => null,
onSome: (date) => date.toISOString(),
}),
})
// Инфраструктурный тип → Доменный тип
const rowToTodo = (row: TodoRow): Effect.Effect<Todo, DecodeError> =>
Schema.decode(Todo)({
id: { value: row.id },
title: { value: row.title },
status: row.status,
priority: row.priority,
createdAt: new Date(row.created_at),
completedAt: row.completed_at ? new Date(row.completed_at) : null,
})
2. Маппинг ошибок (Error Mapping)
Преобразование технических ошибок в доменные:
const SqliteTodoRepository = Layer.effect(
TodoRepository,
Effect.gen(function* () {
const db = yield* SqliteClient
return {
save: (todo) =>
Effect.gen(function* () {
const row = todoToRow(todo)
yield* Effect.try({
try: () => db.run(insertSql, rowToParams(row)),
catch: (error) => {
// Маппинг SQLite-ошибки → доменная ошибка
if (error instanceof Error && error.message.includes("UNIQUE constraint")) {
return new DuplicateTitle({ title: todo.title })
}
return new RepositoryError({
operation: "save",
cause: error,
})
},
})
}),
// ...
}
})
)
3. Управление ресурсами (Resource Management)
Адаптер отвечает за жизненный цикл инфраструктурных ресурсов — подключений к БД, файловых дескрипторов, сетевых соединений:
// Layer.scoped — адаптер с управляемым жизненным циклом
const SqliteTodoRepository = Layer.scoped(
TodoRepository,
Effect.gen(function* () {
// ACQUIRE: открываем соединение с БД
const db = yield* Effect.acquireRelease(
Effect.sync(() => new Database("todos.sqlite")),
// RELEASE: закрываем соединение при завершении
(db) => Effect.sync(() => db.close())
)
// Возвращаем реализацию, которая использует db
return {
findById: (id) => /* использует db */,
save: (todo) => /* использует db */,
findAll: (filter) => /* использует db */,
delete: (id) => /* использует db */,
}
})
)
Паттерн acquire → use → release в Effect обеспечивает гарантированное освобождение ресурсов, даже при ошибках.
Принципы проектирования адаптеров
1. Адаптер знает о технологии — порт не знает
Направление знания:
Адаптер ──знает──► Порт ──знает──► Application Core
Адаптер знает: Порт знает:
• SQL-синтаксис • Доменные типы
• HTTP-коды • Доменные ошибки
• Формат файлов • Операции
• Технические ошибки
2. Один порт — много адаптеров
Для каждого порта может существовать несколько адаптеров:
// ПОРТ: один
class TodoRepository extends Context.Tag("TodoRepository")<...>() {}
// АДАПТЕРЫ: много
const InMemoryTodoRepository: Layer.Layer<TodoRepository> // для тестов
const SqliteTodoRepository: Layer.Layer<TodoRepository, never, SqliteClient> // для production
const PostgresTodoRepository: Layer.Layer<TodoRepository, never, PgClient> // альтернатива
const MockTodoRepository: Layer.Layer<TodoRepository> // для моков
const CachedTodoRepository: Layer.Layer<TodoRepository, never, TodoRepository | Cache> // декоратор
3. Адаптер — тонкий слой
Адаптер не должен содержать бизнес-логику. Его задача — только перевод между технологией и доменом:
// ✅ ТОНКИЙ АДАПТЕР: только маппинг и технические вызовы
save: (todo) =>
Effect.gen(function* () {
const row = todoToRow(todo) // маппинг
yield* executeSql(insertSql, rowToParams(row)) // технический вызов
}),
// ❌ ТОЛСТЫЙ АДАПТЕР: бизнес-логика в адаптере
save: (todo) =>
Effect.gen(function* () {
// Бизнес-правило НЕ ДОЛЖНО быть здесь!
if (todo.title.value.length > 200) {
return yield* Effect.fail(new ValidationError({ message: "Title too long" }))
}
// Ещё одно бизнес-правило в неправильном месте
const existing = yield* findByTitle(todo.title)
if (existing) {
return yield* Effect.fail(new DuplicateTitle({ title: todo.title }))
}
const row = todoToRow(todo)
yield* executeSql(insertSql, rowToParams(row))
}),
4. Адаптер изолирует технические детали
Никакие технические типы не должны «просочиться» через порт в Application Core:
// ❌ УТЕЧКА: технический тип SQLite просочился в порт
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Statement> // Statement — тип SQLite!
}
>() {}
// ✅ ИЗОЛЯЦИЯ: порт оперирует только доменными типами
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
}
>() {}
Жизненный цикл адаптера
Создание и уничтожение
Адаптеры часто управляют ресурсами с жизненным циклом: подключения к БД, файловые хэндлы, сетевые соединения. Effect-ts предоставляет три способа создания адаптеров, каждый для своего сценария:
// Layer.succeed — без жизненного цикла (stateless адаптер)
// Подходит для: InMemory, статические конфигурации, моки
const StaticClock = Layer.succeed(Clock, {
now: () => Effect.succeed(new Date("2025-01-15T10:00:00Z")),
})
// Layer.effect — с инициализацией, но без cleanup
// Подходит для: адаптеры с начальной настройкой
const InitializedAdapter = Layer.effect(
TodoRepository,
Effect.gen(function* () {
const config = yield* AppConfig
const db = new Database(config.dbPath) // создаётся, но не закрывается автоматически
return { /* ... */ }
})
)
// Layer.scoped — с полным жизненным циклом (acquire + release)
// Подходит для: подключения к БД, пулы, файловые хэндлы
const ManagedAdapter = Layer.scoped(
TodoRepository,
Effect.gen(function* () {
const db = yield* Effect.acquireRelease(
Effect.sync(() => new Database("todos.sqlite")), // acquire
(db) => Effect.sync(() => db.close()) // release (гарантированно)
)
// Выполняем миграции при инициализации
yield* runMigrations(db)
return {
findById: (id) => /* использует db */,
save: (todo) => /* использует db */,
// ...
}
})
)
Мемоизация через MemoMap
Когда несколько частей приложения зависят от одного порта, Layer создаётся один раз и переиспользуется. Effect делает это автоматически через MemoMap:
// Оба Use Case зависят от TodoRepository
const createTodo = (...): Effect.Effect<..., ..., TodoRepository | Clock> => ...
const completeTodo = (...): Effect.Effect<..., ..., TodoRepository> => ...
// Но Layer создаётся ОДИН РАЗ, даже если используется в двух местах
const program = Effect.all([
createTodo(input),
completeTodo(todoId),
]).pipe(
Effect.provide(SqliteTodoRepository), // один экземпляр для обоих
Effect.provide(SystemClock),
)
Паттерн: адаптер-декоратор
Адаптер может оборачивать другой адаптер, добавляя поведение (логирование, кеширование, retry) без изменения основной реализации:
// Базовый адаптер
const SqliteTodoRepository: Layer.Layer<TodoRepository, never, SqliteClient> = ...
// Адаптер-декоратор: добавляет логирование
const LoggingTodoRepository = (
underlying: Layer.Layer<TodoRepository>
): Layer.Layer<TodoRepository> =>
Layer.effect(
TodoRepository,
Effect.gen(function* () {
const repo = yield* TodoRepository // получаем underlying реализацию
return {
findById: (id) =>
Effect.gen(function* () {
yield* Effect.log(`TodoRepository.findById(${id.value})`)
const start = performance.now()
const result = yield* repo.findById(id)
const duration = performance.now() - start
yield* Effect.log(`TodoRepository.findById → found (${duration.toFixed(1)}ms)`)
return result
}),
save: (todo) =>
Effect.gen(function* () {
yield* Effect.log(`TodoRepository.save(${todo.id.value})`)
yield* repo.save(todo)
yield* Effect.log(`TodoRepository.save → done`)
}),
findAll: (filter) =>
Effect.gen(function* () {
yield* Effect.log(`TodoRepository.findAll(${JSON.stringify(filter)})`)
const result = yield* repo.findAll(filter)
yield* Effect.log(`TodoRepository.findAll → ${result.length} results`)
return result
}),
delete: (id) =>
Effect.gen(function* () {
yield* Effect.log(`TodoRepository.delete(${id.value})`)
yield* repo.delete(id)
yield* Effect.log(`TodoRepository.delete → done`)
}),
}
})
)
Тестирование адаптеров
Стратегия: контрактные тесты + integration-тесты
Адаптеры тестируются на двух уровнях:
Контрактные тесты — проверяют, что адаптер соответствует контракту порта (описаны в предыдущем уроке).
Integration-тесты — проверяют, что адаптер корректно работает с реальной технологией:
describe("SqliteTodoRepository", () => {
// Контрактные тесты (общие для всех адаптеров)
todoRepositoryContract(() => SqliteTodoRepository.pipe(
Layer.provide(TestSqliteClient)
))
// Integration-тесты (специфичные для SQLite)
it("сохраняет данные между перезапусками", async () => {
const program = Effect.gen(function* () {
const repo = yield* TodoRepository
const todo = makeTodo({ title: "Persistent" })
yield* repo.save(todo)
// Симулируем перезапуск: закрываем и открываем БД
// ...
const found = yield* repo.findById(todo.id)
expect(found.title.value).toBe("Persistent")
})
await Effect.runPromise(
program.pipe(Effect.provide(
SqliteTodoRepository.pipe(Layer.provide(TestSqliteClient))
))
)
})
it("обрабатывает UNIQUE constraint как DuplicateTitle", async () => {
// Тест специфичный для SQLite: проверяем маппинг ошибок
const program = Effect.gen(function* () {
const repo = yield* TodoRepository
const todo1 = makeTodo({ title: "Same Title" })
const todo2 = makeTodo({ title: "Same Title" })
yield* repo.save(todo1)
const error = yield* repo.save(todo2).pipe(Effect.flip)
expect(error._tag).toBe("DuplicateTitle")
})
await Effect.runPromise(program.pipe(Effect.provide(testLayer)))
})
})
Выбор адаптера в runtime
Через конфигурацию окружения
import { Config, Layer } from "effect"
// Выбор адаптера на основе переменной окружения
const TodoRepositoryLive: Layer.Layer<TodoRepository> = Layer.unwrapEffect(
Effect.gen(function* () {
const storageType = yield* Config.string("STORAGE_TYPE").pipe(
Config.withDefault("sqlite")
)
switch (storageType) {
case "memory":
return InMemoryTodoRepository
case "sqlite":
return SqliteTodoRepository
default:
return yield* Effect.die(
new Error(`Unknown storage type: ${storageType}`)
)
}
})
)
Через композицию Layer
// Development: быстрая обратная связь
const DevConfig = Layer.mergeAll(
InMemoryTodoRepository,
ConsoleNotificationService,
FixedClock,
)
// Testing: детерминированность
const TestConfig = Layer.mergeAll(
InMemoryTodoRepository,
NoOpNotificationService,
FixedClock,
)
// Production: реальная инфраструктура
const ProductionConfig = Layer.mergeAll(
SqliteTodoRepository.pipe(Layer.provide(SqliteClientLive)),
SmtpNotificationService.pipe(Layer.provide(SmtpConfigLive)),
SystemClock,
)
Антипаттерны адаптеров
1. Бизнес-логика в адаптере
// ❌ Адаптер содержит бизнес-правила
save: (todo) =>
Effect.gen(function* () {
// Это бизнес-правило! Ему место в Domain Model
if (todo.priority === "high" && !todo.dueDate) {
return yield* Effect.fail(
new ValidationError({ message: "High priority tasks must have a due date" })
)
}
yield* executeSql(...)
}),
2. Доменные типы = инфраструктурные типы
// ❌ Один тип для домена и для SQL-строки
interface Todo {
id: number // autoincrement из SQLite — не доменный ID
is_completed: 0 | 1 // SQLite boolean — не доменный тип
created_at: string // ISO string — зависимость от формата хранения
}
3. Толстый адаптер с кешированием, retry, логированием
// ❌ Адаптер стал «помойкой» для всего подряд
save: (todo) =>
Effect.gen(function* () {
yield* Effect.log("Saving todo...") // логирование
yield* validateBusinessRules(todo) // бизнес-правила
const result = yield* Effect.retry( // retry
executeSql(...),
Schedule.exponential("100 millis")
)
yield* cache.set(todo.id.value, todo) // кеширование
yield* metrics.increment("todos.saved") // метрики
yield* eventBus.publish(new TodoSaved(todo)) // события
return result
}),
// ✅ Каждая ответственность — отдельный Layer/декоратор
// SqliteTodoRepository → только SQL
// LoggingTodoRepository → оборачивает, добавляет логи
// CachedTodoRepository → оборачивает, добавляет кеш
// RetryTodoRepository → оборачивает, добавляет retry
Резюме
Адаптер — это «переводчик» между портом (контрактом Application Core) и конкретной технологией:
- Driving Adapter преобразует внешний запрос в вызов Use Case (HTTP → createTodo)
- Driven Adapter преобразует доменную операцию в технический вызов (save → SQL INSERT)
- Три обязанности: маппинг данных, маппинг ошибок, управление ресурсами
- Layer в Effect-ts — идеальный механизм для реализации адаптеров с управляемым жизненным циклом
- Тонкий слой — адаптер не содержит бизнес-логики, только перевод
- Заменяемость — для одного порта может существовать множество адаптеров (SQLite, InMemory, PostgreSQL, Mock)