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

Порты: контракты между ядром и внешним миром

Порт как типизированный контракт через Context.Tag. Пять свойств хорошего порта, Driving vs Driven порты, гранулярность и ISP, «скрытые» порты (Clock, IdGenerator), контрактное тестирование.

Введение: что такое порт

Порт — это формальный контракт, определяющий, как Application Core взаимодействует с внешним миром. Порт описывает что нужно сделать, но не описывает как это делается.

Аналогия: USB-порт на ноутбуке. Он определяет форму разъёма, напряжение, протокол передачи данных. Но ему безразлично, подключаете ли вы мышь, клавиатуру, флешку или принтер. Любое устройство, соответствующее спецификации USB, будет работать.

В программной архитектуре порт играет ту же роль: он определяет спецификацию взаимодействия, а конкретные реализации (адаптеры) подключаются и отключаются свободно.


Порт как типизированный контракт

Определение через Effect Context.Tag

В Effect-ts порт определяется через Context.Tag — механизм, который позволяет описать форму (shape) сервиса на уровне типов:

import { Context, Effect } from "effect"

// Порт: TodoRepository
// Определяет КОНТРАКТ работы с хранилищем задач
// НЕ определяет, где и как задачи хранятся
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (
      id: TodoId
    ) => Effect.Effect<Todo, TodoNotFound>
    
    readonly findAll: (
      filter: TodoFilter
    ) => Effect.Effect<ReadonlyArray<Todo>>
    
    readonly save: (
      todo: Todo
    ) => Effect.Effect<void, RepositoryError>
    
    readonly delete: (
      id: TodoId
    ) => Effect.Effect<void, TodoNotFound>
    
    readonly existsByTitle: (
      title: TodoTitle
    ) => Effect.Effect<boolean>
  }
>() {}

Этот код устанавливает контракт: любой адаптер, реализующий TodoRepository, обязан предоставить все пять методов с точно такими типами входов, выходов и ошибок.

Анатомия порта

Каждый порт состоит из нескольких элементов:

class PortName extends Context.Tag("UniqueIdentifier")<
  PortName,        // ← Сам тип (для type-level ссылок)
  PortShape        // ← Форма: набор операций с типами
>() {}

UniqueIdentifier — строковый идентификатор, уникальный в рамках приложения. Используется Effect для разрешения зависимостей в runtime. Рекомендуется использовать имя модуля или полный путь:

// Простое имя (для небольших приложений)
Context.Tag("TodoRepository")

// Полный путь (для крупных приложений, избегает коллизий)
Context.Tag("@app/ports/TodoRepository")

PortShape — объектный тип, описывающий доступные операции. Каждая операция — это функция, возвращающая Effect:

type TodoRepositoryShape = {
  // Каждая операция типизирована: входы, выходы, ошибки
  readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
  readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
  // ...
}

Свойства хорошего порта

1. Полнота (Completeness)

Порт должен содержать все операции, необходимые для данного аспекта взаимодействия. Если Application Core нуждается в операции поиска, сохранения и удаления — все три должны быть в порте.

// ✅ ПОЛНЫЙ ПОРТ: все операции для работы с задачами
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly findAll: (filter: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>>
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
    readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
    readonly count: (filter: TodoFilter) => Effect.Effect<number>
  }
>() {}

// ❌ НЕПОЛНЫЙ ПОРТ: Use Case для списка задач не сможет работать
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void>
    // Где findAll? Где delete? Где count?
  }
>() {}

2. Минимализм (Minimalism)

Порт должен содержать только те операции, которые реально используются Application Core. Лишние операции «на будущее» усложняют реализацию адаптеров.

// ❌ ИЗБЫТОЧНЫЙ ПОРТ: операции, которые никто не использует
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void>
    readonly findByTitle: (title: string) => Effect.Effect<Option<Todo>>
    readonly findByDateRange: (from: Date, to: Date) => Effect.Effect<ReadonlyArray<Todo>>
    readonly findByPriorityAndStatus: (...) => ...  // Может, когда-нибудь пригодится?
    readonly bulkUpdate: (...) => ...                // YAGNI
    readonly exportToCsv: (...) => ...               // Это вообще не бизнес-операция!
  }
>() {}

Принцип: добавляйте операции в порт только тогда, когда они нужны Use Case-у. Это следствие принципа YAGNI (You Aren’t Gonna Need It).

3. Доменный язык (Ubiquitous Language)

Порт оперирует терминами предметной области, а не терминами технологий:

// ✅ ДОМЕННЫЙ ЯЗЫК: термины бизнеса
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
    readonly findOverdue: () => Effect.Effect<ReadonlyArray<Todo>>
    readonly findByAssignee: (userId: UserId) => Effect.Effect<ReadonlyArray<Todo>>
  }
>() {}

// ❌ ТЕХНИЧЕСКИЙ ЯЗЫК: термины инфраструктуры
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly selectById: (id: string) => Effect.Effect<Record<string, unknown>>
    readonly insertOrUpdate: (row: Record<string, unknown>) => Effect.Effect<void>
    readonly executeQuery: (sql: string) => Effect.Effect<ReadonlyArray<Record<string, unknown>>>
    // ☝️ SQL-термины вместо доменных. Порт знает о SQL!
  }
>() {}

4. Независимость от технологий (Technology Agnosticism)

Порт не должен содержать типы, специфичные для конкретной технологии:

// ❌ АНТИПАТТЕРН: технологические типы в порте
import { Statement } from "bun:sqlite"
import { Request, Response } from "@effect/platform/HttpServer"

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Statement  // тип SQLite!
    readonly toJson: (todo: Todo) => Response      // тип HTTP!
  }
>() {}

// ✅ ПРАВИЛЬНО: только доменные типы
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
  }
>() {}

5. Типизированные ошибки (Typed Errors)

Каждая операция порта явно объявляет возможные ошибки в E-канале:

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    // findById МОЖЕТ завершиться ошибкой TodoNotFound
    readonly findById: (
      id: TodoId
    ) => Effect.Effect<Todo, TodoNotFound>
    
    // save МОЖЕТ завершиться ошибкой RepositoryError
    readonly save: (
      todo: Todo
    ) => Effect.Effect<void, RepositoryError>
    
    // findAll НЕ МОЖЕТ завершиться доменной ошибкой
    // (пустой список — не ошибка)
    readonly findAll: (
      filter: TodoFilter
    ) => Effect.Effect<ReadonlyArray<Todo>>
    
    // delete МОЖЕТ завершиться ошибкой TodoNotFound
    readonly delete: (
      id: TodoId
    ) => Effect.Effect<void, TodoNotFound>
  }
>() {}

Типизированные ошибки — часть контракта. Адаптер обязан возвращать именно эти ошибки, а не технические (например, SqliteError). Маппинг технических ошибок в доменные — ответственность адаптера.


Два типа портов

Driving Ports (Primary / Input)

Driving port определяет, что внешний мир может попросить у приложения. Это «вход» в систему:

   Внешний мир                 Application Core
   ┌──────────┐               ┌─────────────────┐
   │  HTTP    │──────────────►│                 │
   │  Server  │  Driving Port │  Use Case:      │
   │          │  (входной)    │  createTodo()   │
   └──────────┘               └─────────────────┘

Driving ports обычно представлены Use Case интерфейсами — описанием бизнес-операций, доступных извне:

// Driving Port: операция создания задачи
// Внешний мир (HTTP, CLI, тест) использует этот контракт
class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
  CreateTodoUseCase,
  {
    readonly execute: (
      input: CreateTodoInput
    ) => Effect.Effect<Todo, ValidationError | DuplicateTitle>
  }
>() {}

// Driving Port: операция получения списка задач
class ListTodosUseCase extends Context.Tag("ListTodosUseCase")<
  ListTodosUseCase,
  {
    readonly execute: (
      filter: TodoFilter
    ) => Effect.Effect<ReadonlyArray<TodoView>>
  }
>() {}

Кто реализует Driving Port? Application Core сам реализует Driving Ports. Use Case — это реализация Driving Port:

// Реализация Driving Port внутри Application Core
const CreateTodoUseCaseLive = Layer.effect(
  CreateTodoUseCase,
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const clock = yield* Clock
    const idGen = yield* IdGenerator
    
    return {
      execute: (input) =>
        Effect.gen(function* () {
          const title = yield* Schema.decode(TodoTitle)({ value: input.title })
          yield* checkTitleUniqueness(title)
          const todo = new Todo({
            id: yield* idGen.generate(),
            title,
            status: "active",
            createdAt: yield* clock.now(),
          })
          yield* repo.save(todo)
          return todo
        }),
    }
  })
)

Driven Ports (Secondary / Output)

Driven port определяет, что приложению нужно от внешнего мира. Это «выход» из системы:

   Application Core               Внешний мир
   ┌─────────────────┐           ┌──────────┐
   │                 │           │  SQLite  │
   │  Use Case:      │──────────►│  Database │
   │  createTodo()   │ Driven    │          │
   │                 │ Port      └──────────┘
   └─────────────────┘ (выходной)

Driven ports — это потребности Application Core в инфраструктуре:

// Driven Port: хранилище задач
// 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>
  }
>() {}

// Driven Port: отправка уведомлений
class NotificationService extends Context.Tag("NotificationService")<
  NotificationService,
  {
    readonly send: (
      notification: Notification
    ) => Effect.Effect<void, NotificationError>
  }
>() {}

// Driven Port: текущее время
class Clock extends Context.Tag("Clock")<
  Clock,
  {
    readonly now: () => Effect.Effect<Date>
    readonly today: () => Effect.Effect<Date>
  }
>() {}

// Driven Port: генерация уникальных идентификаторов
class IdGenerator extends Context.Tag("IdGenerator")<
  IdGenerator,
  {
    readonly generate: () => Effect.Effect<TodoId>
  }
>() {}

Кто реализует Driven Port? Адаптеры — внешний код, который подключается извне Application Core:

// Адаптер: SQLite реализация Driven Port TodoRepository
const SqliteTodoRepository = Layer.scoped(
  TodoRepository,
  Effect.gen(function* () {
    const db = yield* SqliteClient
    return {
      findById: (id) => /* SQL query */,
      save: (todo) => /* SQL insert/update */,
      findAll: (filter) => /* SQL select */,
      delete: (id) => /* SQL delete */,
    }
  })
)

// Адаптер: In-Memory реализация (для тестов)
const InMemoryTodoRepository = Layer.succeed(
  TodoRepository,
  (() => {
    const store = new Map<string, Todo>()
    return {
      findById: (id) =>
        Effect.gen(function* () {
          const todo = store.get(id.value)
          if (!todo) return yield* Effect.fail(new TodoNotFound({ id }))
          return todo
        }),
      save: (todo) =>
        Effect.sync(() => { store.set(todo.id.value, todo) }),
      findAll: (_filter) =>
        Effect.sync(() => [...store.values()]),
      delete: (id) =>
        Effect.gen(function* () {
          if (!store.has(id.value)) {
            return yield* Effect.fail(new TodoNotFound({ id }))
          }
          store.delete(id.value)
        }),
    }
  })()
)

Симметрия портов: ключевой инсайт Кокберна

Одна из самых глубоких идей Кокберна — симметрия между Driving и Driven портами. Оба типа портов следуют одному и тому же паттерну:

Driving:  Внешний Actor → [Driving Port] → Application Core
Driven:   Application Core → [Driven Port] → Внешний Actor

В обоих случаях порт — это контракт, а адаптер — реализация. Разница только в направлении инициации:

СвойствоDriving PortDriven Port
Кто инициируетВнешний акторApplication Core
Кто определяет контрактApplication CoreApplication Core
Кто реализуетApplication Core (Use Cases)Адаптеры (инфраструктура)
ПримерCreateTodoUseCaseTodoRepository
Направление потокаСнаружи → ВнутрьИзнутри → Наружу

Обратите внимание: контракт всегда определяет Application Core. Это следствие Dependency Rule (правила зависимостей), которое мы подробно рассмотрим в уроке 06.


Порт как граница маппинга

Типы на границе

Порт определяет не только операции, но и типы данных, которые пересекают границу. Это критически важный момент: данные, входящие и выходящие из Application Core, должны быть выражены в доменных типах.

// Driving Port: входные данные выражены в доменных терминах
class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
  CreateTodoUseCase,
  {
    readonly execute: (
      input: CreateTodoInput    // ← доменный тип, не JSON, не FormData
    ) => Effect.Effect<
      Todo,                     // ← доменный тип, не SQL-строка
      ValidationError | DuplicateTitle  // ← доменные ошибки
    >
  }
>() {}

// Типы данных, пересекающие границу
class CreateTodoInput extends Schema.TaggedClass<CreateTodoInput>()(
  "CreateTodoInput",
  {
    title: Schema.String,       // Ещё не TodoTitle — валидация внутри Use Case
    priority: Schema.optional(PrioritySchema),
    dueDate: Schema.optional(Schema.Date),
  }
) {}

Маппинг на каждой границе

При пересечении порта данные преобразуются. Каждый адаптер отвечает за маппинг между своими технологическими типами и доменными типами порта:

HTTP Request (JSON) ──► [HTTP Adapter: маппинг] ──► CreateTodoInput

                                                    Driving Port

                                                    ▼ Use Case ▼

                                                    Driven Port

Todo (Domain) ──► [SQLite Adapter: маппинг] ──► TodoRow (SQL)
// HTTP Adapter: JSON → доменный тип
const parseCreateTodoRequest = (
  request: HttpServerRequest.HttpServerRequest
): Effect.Effect<CreateTodoInput, HttpBodyError> =>
  Effect.gen(function* () {
    const body = yield* request.json
    return yield* Schema.decode(CreateTodoInput)(body)
  })

// SQLite Adapter: доменный тип → SQL-строка
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: (d) => d.toISOString(),
  }),
})

// SQLite Adapter: SQL-строка → доменный тип
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,
  })

Порт как точка подмены

Одна из главных ценностей портов

Порт позволяет менять реализацию без изменения Application Core. Это не абстрактное преимущество — оно проявляется в конкретных сценариях:

Сценарий 1: Тестирование В тестах InMemoryTodoRepository вместо SqliteTodoRepository. Тесты мгновенные, детерминированные, не требуют базы данных.

Сценарий 2: Миграция технологий Переход с SQLite на PostgreSQL: создаём новый адаптер PostgresTodoRepository, подключаем его вместо SqliteTodoRepository. Application Core не затронут.

Сценарий 3: A/B тестирование Два разных алгоритма уведомлений: EmailNotification и PushNotification. Подключаются через один порт NotificationService в зависимости от конфигурации.

Сценарий 4: Feature toggles Новая функциональность кеширования: CachedTodoRepository оборачивает SqliteTodoRepository, подключается через тот же порт.

// Сценарий 4: декоратор-адаптер (кеширующий слой)
const CachedTodoRepository = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    const underlying = yield* TodoRepositoryUnderlying // оригинальный адаптер
    const cache = new Map<string, Todo>()
    
    return {
      findById: (id) =>
        Effect.gen(function* () {
          const cached = cache.get(id.value)
          if (cached) return cached
          
          const todo = yield* underlying.findById(id)
          cache.set(id.value, todo)
          return todo
        }),
      
      save: (todo) =>
        Effect.gen(function* () {
          yield* underlying.save(todo)
          cache.set(todo.id.value, todo)
        }),
      
      findAll: (filter) => underlying.findAll(filter),
      
      delete: (id) =>
        Effect.gen(function* () {
          yield* underlying.delete(id)
          cache.delete(id.value)
        }),
    }
  })
)

Гранулярность портов

Один порт на «аспект» взаимодействия

Порт группирует операции по аспекту (concern), а не по технологии или сущности:

// ✅ Правильная гранулярность: один аспект = один порт
class TodoRepository extends Context.Tag("TodoRepository")<...>() {}
class NotificationService extends Context.Tag("NotificationService")<...>() {}
class Clock extends Context.Tag("Clock")<...>() {}
class IdGenerator extends Context.Tag("IdGenerator")<...>() {}

// ❌ Слишком крупный порт: "всё в одном"
class Infrastructure extends Context.Tag("Infrastructure")<
  Infrastructure,
  {
    readonly findTodoById: ...
    readonly saveTodo: ...
    readonly sendEmail: ...
    readonly now: ...
    readonly generateId: ...
  }
>() {}
// ☝️ Нарушение ISP (Interface Segregation Principle)
// Use Case, которому нужно только время, вынужден зависеть от всей инфраструктуры

// ❌ Слишком мелкий порт: каждая операция — отдельный порт
class FindTodoById extends Context.Tag("FindTodoById")<...>() {}
class SaveTodo extends Context.Tag("SaveTodo")<...>() {}
class DeleteTodo extends Context.Tag("DeleteTodo")<...>() {}
// ☝️ Чрезмерная фрагментация. Все три операции — один аспект (хранение)

Принцип Interface Segregation в контексте портов

Interface Segregation Principle (ISP) из SOLID гласит: «Клиенты не должны зависеть от интерфейсов, которые они не используют». В контексте портов это означает:

// Use Case: GetTodo — нуждается ТОЛЬКО в findById
const getTodo = (id: TodoId): Effect.Effect<Todo, TodoNotFound, TodoRepository> =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    return yield* repo.findById(id) // использует только findById
  })

// Но R-канал требует ВЕСЬ TodoRepository, включая save, delete, findAll
// Это приемлемо, потому что TodoRepository — связный набор операций
// одного аспекта (хранение задач)

Если бы TodoRepository включал sendEmail — это было бы нарушением ISP, потому что sendEmail — другой аспект, не связанный с хранением.


Эволюция портов

Добавление новых операций

Когда бизнес-требования меняются, порт может расти:

// Версия 1: базовые операции
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void>
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>>
  }
>() {}

// Версия 2: добавлена фильтрация и подсчёт
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void>
    readonly findAll: (filter: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>>  // изменено
    readonly count: (filter: TodoFilter) => Effect.Effect<number>                  // добавлено
    readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>            // добавлено
  }
>() {}

При добавлении новых операций все существующие адаптеры перестают компилироваться — TypeScript покажет ошибку, что новые методы не реализованы. Это преимущество, а не недостаток: компилятор гарантирует, что ни один адаптер не «забыт».

Разделение порта

Если порт становится слишком большим, его можно разделить:

// Было: один большой порт
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: ...
    readonly save: ...
    readonly findAll: ...
    readonly delete: ...
    readonly findOverdue: ...
    readonly findByAssignee: ...
    readonly countByStatus: ...
    readonly generateReport: ...
  }
>() {}

// Стало: два порта по аспектам
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: ...
    readonly save: ...
    readonly findAll: ...
    readonly delete: ...
  }
>() {}

class TodoQueryService extends Context.Tag("TodoQueryService")<
  TodoQueryService,
  {
    readonly findOverdue: ...
    readonly findByAssignee: ...
    readonly countByStatus: ...
    readonly generateReport: ...
  }
>() {}

Порты, которые легко забыть

Clock (Время)

Системное время — скрытая зависимость, которую легко пропустить:

// ❌ Скрытая зависимость от системного времени
const createTodo = (title: TodoTitle) =>
  Effect.succeed(new Todo({
    ...props,
    createdAt: new Date(), // ← недетерминированно, невозможно тестировать
  }))

// ✅ Явная зависимость через порт
class Clock extends Context.Tag("Clock")<
  Clock,
  { readonly now: () => Effect.Effect<Date> }
>() {}

const createTodo = (title: TodoTitle): Effect.Effect<Todo, never, Clock> =>
  Effect.gen(function* () {
    const clock = yield* Clock
    const now = yield* clock.now()
    return new Todo({ ...props, createdAt: now })
  })

IdGenerator (Генерация идентификаторов)

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

Random (Случайные числа)

class RandomService extends Context.Tag("RandomService")<
  RandomService,
  {
    readonly nextInt: (min: number, max: number) => Effect.Effect<number>
    readonly nextUuid: () => Effect.Effect<string>
  }
>() {}

Logger (Логирование)

В Effect логирование уже является портом через Effect.log, Effect.logDebug и т.д. Но для доменного логирования может быть полезен собственный порт:

class AuditLog extends Context.Tag("AuditLog")<
  AuditLog,
  {
    readonly record: (event: AuditEvent) => Effect.Effect<void>
  }
>() {}

Контрактное тестирование портов

Порт определяет контракт. Каждый адаптер, реализующий порт, должен соответствовать этому контракту. Это проверяется контрактными тестами — набором тестов, которые запускаются для каждой реализации:

// Контрактный тест: любая реализация TodoRepository
// ДОЛЖНА проходить эти тесты
const todoRepositoryContract = (
  makeLayer: () => Layer.Layer<TodoRepository>
) => {
  describe("TodoRepository contract", () => {
    it("сохраняет и находит задачу по ID", async () => {
      const program = Effect.gen(function* () {
        const repo = yield* TodoRepository
        const todo = makeTodo({ title: "Test" })
        
        yield* repo.save(todo)
        const found = yield* repo.findById(todo.id)
        
        expect(found.id).toEqual(todo.id)
        expect(found.title).toEqual(todo.title)
      })
      
      await Effect.runPromise(program.pipe(Effect.provide(makeLayer())))
    })

    it("возвращает TodoNotFound для несуществующего ID", async () => {
      const program = Effect.gen(function* () {
        const repo = yield* TodoRepository
        const result = yield* repo.findById(nonExistentId).pipe(Effect.flip)
        expect(result._tag).toBe("TodoNotFound")
      })
      
      await Effect.runPromise(program.pipe(Effect.provide(makeLayer())))
    })

    it("возвращает пустой массив для пустого хранилища", async () => {
      const program = Effect.gen(function* () {
        const repo = yield* TodoRepository
        const all = yield* repo.findAll(emptyFilter)
        expect(all).toHaveLength(0)
      })
      
      await Effect.runPromise(program.pipe(Effect.provide(makeLayer())))
    })
  })
}

// Запускаем контрактные тесты для каждого адаптера
todoRepositoryContract(() => InMemoryTodoRepository)
todoRepositoryContract(() => SqliteTodoRepository)
// Если завтра появится PostgresTodoRepository — добавим одну строку
todoRepositoryContract(() => PostgresTodoRepository)

Резюме

Порт — это фундаментальный элемент гексагональной архитектуры, выполняющий несколько функций одновременно:

  1. Контракт — формальное описание взаимодействия между Application Core и внешним миром
  2. Граница — чёткая демаркационная линия, за которой начинается «чужая территория»
  3. Точка подмены — возможность заменить реализацию без изменения ядра
  4. Документация — тип порта описывает, что нужно Application Core, на языке домена
  5. Защита — компилятор гарантирует, что все зависимости удовлетворены

В Effect-ts порт реализуется через Context.Tag, а его типизация обеспечивает проверку контракта на этапе компиляции — задолго до запуска кода.