Порты: контракты между ядром и внешним миром
Порт как типизированный контракт через 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 Port | Driven Port |
|---|---|---|
| Кто инициирует | Внешний актор | Application Core |
| Кто определяет контракт | Application Core | Application Core |
| Кто реализует | Application Core (Use Cases) | Адаптеры (инфраструктура) |
| Пример | CreateTodoUseCase | TodoRepository |
| Направление потока | Снаружи → Внутрь | Изнутри → Наружу |
Обратите внимание: контракт всегда определяет 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)
Резюме
Порт — это фундаментальный элемент гексагональной архитектуры, выполняющий несколько функций одновременно:
- Контракт — формальное описание взаимодействия между Application Core и внешним миром
- Граница — чёткая демаркационная линия, за которой начинается «чужая территория»
- Точка подмены — возможность заменить реализацию без изменения ядра
- Документация — тип порта описывает, что нужно Application Core, на языке домена
- Защита — компилятор гарантирует, что все зависимости удовлетворены
В Effect-ts порт реализуется через Context.Tag, а его типизация обеспечивает проверку контракта на этапе компиляции — задолго до запуска кода.