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

Адаптеры: реализации портов для конкретных технологий

Адаптер как 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) и конкретной технологией:

  1. Driving Adapter преобразует внешний запрос в вызов Use Case (HTTP → createTodo)
  2. Driven Adapter преобразует доменную операцию в технический вызов (save → SQL INSERT)
  3. Три обязанности: маппинг данных, маппинг ошибок, управление ресурсами
  4. Layer в Effect-ts — идеальный механизм для реализации адаптеров с управляемым жизненным циклом
  5. Тонкий слой — адаптер не содержит бизнес-логики, только перевод
  6. Заменяемость — для одного порта может существовать множество адаптеров (SQLite, InMemory, PostgreSQL, Mock)