Почему Ports & Adapters — лучший выбор для Effect
Кульминация модуля: почему именно Hexagonal Architecture естественно ложится на Effect-ts. Структурное соответствие Context.Tag = Port, Layer = Adapter, R-канал = Dependency Rule — это не метафора, а прямое отображение. Мы покажем, что Effect-проекты уже являются гексагональными, даже если авторы об этом не знают.
Центральный тезис
Когда мы говорим «Effect-ts хорошо подходит для Hexagonal Architecture», мы не имеем в виду, что Effect — удобный инструмент, который можно использовать для реализации гексагона. Мы имеем в виду нечто более глубокое:
Effect-ts содержит встроенную реализацию Ports & Adapters паттерна на уровне системы типов. Context.Tag — это Port. Layer — это Adapter. R-канал в Effect<A, E, R> — это Dependency Rule, проверяемый компилятором.
Это прямое структурное соответствие, а не метафора. Каждый раз, когда вы определяете Context.Tag — вы определяете порт. Каждый раз, когда вы создаёте Layer — вы реализуете адаптер. Каждый раз, когда компилятор требует Effect.provide — он проверяет Dependency Rule.
Соответствие 1: Context.Tag = Port
Что такое Port в Hexagonal Architecture?
Порт — это типизированный контракт между ядром приложения и внешним миром. Он определяет:
- Какие операции доступны
- Какие данные принимаются и возвращаются
- Какие ошибки возможны
Порт не содержит реализации. Он говорит что нужно, но не как это сделать.
Что такое Context.Tag в Effect?
Context.Tag — это типизированный идентификатор сервиса, который определяет:
- Какие методы доступны (через Shape)
- Какие типы принимаются и возвращаются
- Какие ошибки могут произойти (через E в Effect)
Context.Tag не содержит реализации. Он определяет что сервис предоставляет, но не как.
Прямое соответствие
import { Effect, Context } from "effect"
// ═══════════════════════════════════════════════════
// Hexagonal: "Порт TodoRepository определяет контракт
// для персистентности задач"
//
// Effect: "Tag TodoRepository определяет сервис
// для персистентности задач"
//
// ЭТО ОДНО И ТО ЖЕ.
// ═══════════════════════════════════════════════════
/**
* Это ПОРТ в терминах Hexagonal Architecture.
* Это TAG в терминах Effect-ts.
* Один объект — две роли, потому что роли совпадают.
*/
class TodoRepository extends Context.Tag("@app/TodoRepository")<
TodoRepository,
{
readonly findById: (id: string) => Effect.Effect<Todo | null, RepositoryError>
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly delete: (id: string) => Effect.Effect<void, RepositoryError>
}
>() {}
/**
* Ещё один порт / Tag.
* Определяет контракт уведомлений — без привязки к email, push, SMS.
*/
class NotificationService extends Context.Tag("@app/NotificationService")<
NotificationService,
{
readonly send: (userId: string, message: string) => Effect.Effect<void, NotificationError>
}
>() {}
/**
* Порт для детерминированного времени.
* В продакшене — системные часы. В тестах — фиксированная дата.
*/
class Clock extends Context.Tag("@app/Clock")<
Clock,
{
readonly now: () => Effect.Effect<Date>
}
>() {}
Обратите внимание: ни один из этих тегов не содержит ни строчки реализации. Они описывают контракт — именно то, что делает Port в Hexagonal Architecture.
Соответствие 2: Layer = Adapter
Что такое Adapter в Hexagonal Architecture?
Адаптер — это конкретная реализация порта для определённой технологии. Для одного порта может быть несколько адаптеров:
TodoRepositoryPort→SqliteTodoAdapter,InMemoryTodoAdapter,PostgresTodoAdapterNotificationPort→EmailAdapter,SlackAdapter,ConsoleAdapter
Что такое Layer в Effect?
Layer — это конкретная реализация Tag (сервиса). Для одного Tag может быть несколько Layer:
TodoRepositoryTag →SqliteTodoLayer,InMemoryTodoLayer,PostgresTodoLayerNotificationServiceTag →EmailLayer,SlackLayer,ConsoleLayer
Прямое соответствие
import { Effect, Layer } from "effect"
// ═══════════════════════════════════════════════════
// Hexagonal: "Адаптер InMemoryTodoRepository реализует
// порт TodoRepository для хранения в памяти"
//
// Effect: "Layer InMemoryTodoRepo реализует
// Tag TodoRepository для хранения в памяти"
//
// ЭТО ОДНО И ТО ЖЕ.
// ═══════════════════════════════════════════════════
// Адаптер 1: InMemory (для тестов и прототипирования)
const InMemoryTodoRepo = Layer.sync(TodoRepository, () => {
const store = new Map<string, Todo>()
return {
findById: (id) => Effect.sync(() => store.get(id) ?? null),
findAll: () => Effect.sync(() => [...store.values()]),
save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
delete: (id) => Effect.sync(() => { store.delete(id) }),
}
})
// Адаптер 2: SQLite (для продакшена)
const SqliteTodoRepo = Layer.scoped(
TodoRepository,
Effect.gen(function* () {
const db = yield* SqliteClient // зависимость адаптера от другого сервиса
return {
findById: (id) =>
Effect.try({
try: () => {
const row = db.query("SELECT * FROM todos WHERE id = ?").get(id)
return row ? mapRowToTodo(row as TodoRow) : null
},
catch: (error) => new RepositoryError({ cause: error }),
}),
findAll: () =>
Effect.try({
try: () => {
const rows = db.query("SELECT * FROM todos ORDER BY created_at DESC").all()
return (rows as ReadonlyArray<TodoRow>).map(mapRowToTodo)
},
catch: (error) => new RepositoryError({ cause: error }),
}),
save: (todo) =>
Effect.try({
try: () => {
db.query(
`INSERT OR REPLACE INTO todos (id, title, status, priority, created_at, completed_at)
VALUES ($id, $title, $status, $priority, $created, $completed)`
).run({
$id: todo.id,
$title: todo.title,
$status: todo.status,
$priority: todo.priority,
$created: todo.createdAt.toISOString(),
$completed: todo.completedAt?.toISOString() ?? null,
})
},
catch: (error) => new RepositoryError({ cause: error }),
}),
delete: (id) =>
Effect.try({
try: () => { db.query("DELETE FROM todos WHERE id = ?").run(id) },
catch: (error) => new RepositoryError({ cause: error }),
}),
}
})
)
// Адаптер 3: Console Logger (для отладки — логирует все операции)
const LoggingTodoRepo = Layer.effect(
TodoRepository,
Effect.gen(function* () {
const inner = yield* TodoRepository // декорирует другой адаптер
return {
findById: (id) =>
Effect.tap(
inner.findById(id),
(result) => Effect.log(`findById(${id}) => ${result ? "found" : "null"}`)
),
findAll: () =>
Effect.tap(
inner.findAll(),
(todos) => Effect.log(`findAll() => ${todos.length} todos`)
),
save: (todo) =>
Effect.tap(
inner.save(todo),
() => Effect.log(`save(${todo.id}: "${todo.title}")`)
),
delete: (id) =>
Effect.tap(
inner.delete(id),
() => Effect.log(`delete(${id})`)
),
}
})
)
Три адаптера (Layer) для одного порта (Tag). Замена адаптера — одна строка в точке сборки:
// Для тестов:
const testProgram = createTodo("Test").pipe(Effect.provide(InMemoryTodoRepo))
// Для продакшена:
const prodProgram = createTodo("Prod").pipe(Effect.provide(SqliteTodoRepo))
// Бизнес-логика (createTodo) НЕ ИЗМЕНИЛАСЬ.
Соответствие 3: R-канал = Dependency Rule
Что такое Dependency Rule?
Dependency Rule в Hexagonal Architecture гласит: бизнес-логика определяет свои зависимости через порты (интерфейсы), а конкретные реализации предоставляются снаружи. Бизнес-логика не знает, какой адаптер будет использоваться.
Что такое R-канал в Effect?
Effect<A, E, R> — тип с тремя параметрами:
- A — тип результата (что возвращает)
- E — тип ошибки (что может пойти не так)
- R — тип требований (от чего зависит)
R-канал — это множество сервисов (портов), от которых зависит вычисление. Компилятор отслеживает эти зависимости и не позволяет запустить программу, пока все зависимости не будут предоставлены.
Прямое соответствие
// Use Case: создание задачи
// R-канал ЯВНО показывает зависимости (порты)
const createTodo = (
title: string,
priority: Priority
): Effect.Effect<
Todo, // A: результат
ValidationError | RepositoryError, // E: возможные ошибки
TodoRepository | Clock // R: ЗАВИСИМОСТИ = ПОРТЫ
> =>
Effect.gen(function* () {
// Получение сервисов через порты
const repo = yield* TodoRepository
const clock = yield* Clock
// Доменная валидация
if (title.trim().length === 0) {
return yield* Effect.fail(new ValidationError({ message: "Empty title" }))
}
// Создание доменного объекта
const now = yield* clock.now()
const todo: Todo = {
id: crypto.randomUUID(),
title: title.trim(),
status: "pending",
priority,
createdAt: now,
completedAt: null,
}
// Сохранение через порт
yield* repo.save(todo)
return todo
})
// Тип createTodo: Effect<Todo, ValidationError | RepositoryError, TodoRepository | Clock>
// ^^^^^^^^^^^^^^^^^^^^^^^^^
// R-канал = ПОРТЫ
// = Dependency Rule
Компилятор как enforcement-механизм Dependency Rule:
// ❌ НЕ СКОМПИЛИРУЕТСЯ: R-канал не пуст
// Type 'TodoRepository | Clock' is not assignable to type 'never'
Effect.runPromise(createTodo("Buy milk", "medium"))
// ❌ НЕ СКОМПИЛИРУЕТСЯ: предоставлен только один из двух портов
// Type 'Clock' is not assignable to type 'never'
const partial = createTodo("Buy milk", "medium").pipe(
Effect.provide(InMemoryTodoRepo)
)
Effect.runPromise(partial)
// ✅ СКОМПИЛИРУЕТСЯ: все порты предоставлены, R = never
const complete = createTodo("Buy milk", "medium").pipe(
Effect.provide(Layer.merge(InMemoryTodoRepo, TestClock))
)
Effect.runPromise(complete) // R = never → можно запускать
Это уникальное свойство Effect-ts: Dependency Rule проверяется компилятором. Ни один из традиционных подходов (Clean, Onion, Hexagonal без Effect) не даёт такой гарантии. Там зависимости проверяются в runtime (DI-контейнер бросит исключение, если что-то не зарегистрировано).
Соответствие 4: Effect.provide = Adapter Wiring
Что такое Wiring в Hexagonal Architecture?
Wiring (сборка) — это процесс подключения адаптеров к портам при запуске приложения. Обычно это происходит в main.ts или Composition Root:
Port: TodoRepository → Adapter: SqliteTodoRepository
Port: NotificationService → Adapter: EmailNotificationAdapter
Port: Clock → Adapter: SystemClock
Что такое Effect.provide в Effect?
Effect.provide — это операция, которая подключает Layer (адаптер) к Effect-программе, удовлетворяя зависимости из R-канала:
import { Effect, Layer } from "effect"
// ═══════════════════════════════════════════════════
// Composition Root = Layer сборка
// ═══════════════════════════════════════════════════
// Отдельные адаптеры
const SqliteClientLive = Layer.scoped(SqliteClient, /* ... */)
const SqliteTodoRepoLive = Layer.effect(TodoRepository, /* ... */)
const SystemClockLive = Layer.succeed(Clock, { now: () => Effect.sync(() => new Date()) })
const EmailNotificationLive = Layer.effect(NotificationService, /* ... */)
// Граф зависимостей — автоматическое разрешение
const AppLayer = Layer.mergeAll(
SqliteTodoRepoLive,
SystemClockLive,
EmailNotificationLive
).pipe(
Layer.provide(SqliteClientLive) // SqliteTodoRepo зависит от SqliteClient
)
// Запуск: все порты → все адаптеры → R = never
const main = createTodo("Buy milk", "medium").pipe(
Effect.provide(AppLayer)
)
Effect.runPromise(main) // ✅ R = never, все зависимости удовлетворены
// ═══════════════════════════════════════════════════
// Для тестов — ДРУГАЯ сборка, ТОТ ЖЕ Use Case
// ═══════════════════════════════════════════════════
const TestLayer = Layer.mergeAll(
InMemoryTodoRepo,
Layer.succeed(Clock, { now: () => Effect.succeed(new Date("2024-01-01")) }),
Layer.succeed(NotificationService, { send: () => Effect.void })
)
const testMain = createTodo("Buy milk", "medium").pipe(
Effect.provide(TestLayer)
)
Effect.runPromise(testMain) // ✅ Тот же Use Case, другие адаптеры
Соответствие 5: E-канал = Error Contract
Ошибки как часть контракта порта
В Hexagonal Architecture порт определяет не только успешные операции, но и возможные ошибки. Если порт TodoRepository может выдать RepositoryError, это часть контракта.
В Effect ошибки — часть типа Effect<A, E, R>, где E — типизированный канал ошибок:
// Доменные ошибки
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly message: string
}> {}
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly id: string
}> {}
class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
readonly from: TodoStatus
readonly to: TodoStatus
}> {}
// Инфраструктурные ошибки
class RepositoryError extends Data.TaggedError("RepositoryError")<{
readonly cause: unknown
}> {}
// Use Case с полным контрактом ошибок
const completeTodo = (
id: string
): Effect.Effect<
Todo, // A
TodoNotFound | InvalidTransition | RepositoryError, // E — контракт ошибок
TodoRepository // R — контракт зависимостей
> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const existing = yield* repo.findById(id)
if (existing === null) {
return yield* Effect.fail(new TodoNotFound({ id }))
}
const updated = transitionStatus(existing, "completed")
if (updated === null) {
return yield* Effect.fail(
new InvalidTransition({ from: existing.status, to: "completed" })
)
}
yield* repo.save(updated)
return updated
})
Адаптер (HTTP контроллер) может маппить доменные ошибки в HTTP-коды:
// Driving Adapter: HTTP → доменные ошибки → HTTP статус-коды
const handleCompleteTodo = (id: string) =>
completeTodo(id).pipe(
Effect.catchTags({
TodoNotFound: (e) =>
Effect.succeed(new Response(JSON.stringify({ error: `Todo ${e.id} not found` }), { status: 404 })),
InvalidTransition: (e) =>
Effect.succeed(new Response(JSON.stringify({ error: `Cannot transition from ${e.from} to ${e.to}` }), { status: 409 })),
RepositoryError: (_e) =>
Effect.succeed(new Response(JSON.stringify({ error: "Internal error" }), { status: 500 })),
}),
Effect.map((todo) =>
new Response(JSON.stringify(todo), { status: 200, headers: { "Content-Type": "application/json" } })
)
)
Почему НЕ Clean Architecture для Effect?
Clean Architecture вводит концепции, которые не нужны при использовании Effect:
Input/Output Boundary → не нужен
Effect-программа уже является контрактом с чётко определёнными входами (параметры), выходами (A-канал) и ошибками (E-канал):
// Clean Architecture: нужны два интерфейса
interface CreateTodoInputBoundary {
execute(input: CreateTodoInput): Promise<void>
}
interface CreateTodoOutputBoundary {
presentSuccess(output: CreateTodoOutput): void
}
// Effect: один тип содержит ВСЕ
type CreateTodo = (
input: CreateTodoInput
) => Effect.Effect<CreateTodoOutput, CreateTodoError, TodoRepository>
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
// Output (A) Errors (E) Dependencies (R)
Presenter → не нужен
Effect позволяет трансформировать результат с помощью Effect.map на любом уровне:
// Clean: Presenter — отдельный объект
class HttpPresenter implements CreateTodoOutputBoundary {
presentSuccess(output: CreateTodoOutput) { /* форматирование */ }
}
// Effect: map/tap на уровне адаптера
const httpHandler = createTodo(input).pipe(
Effect.map(todoToHttpResponse), // «presenter» — одна строка
Effect.catchAll(errorToHttpResponse) // обработка ошибок — тоже
)
Interactor class → не нужен
В Clean Architecture Use Case — это класс Interactor. В Effect Use Case — это функция, возвращающая Effect:
// Clean: класс с конструктором для DI
class CreateTodoInteractor implements CreateTodoInputBoundary {
constructor(
private readonly repo: TodoRepository,
private readonly presenter: CreateTodoOutputBoundary
) {}
async execute(input: CreateTodoInput): Promise<void> { /* ... */ }
}
// Effect: функция, зависимости в R-канале
const createTodo = (input: CreateTodoInput) =>
Effect.gen(function* () {
const repo = yield* TodoRepository // DI через R-канал
// ... логика
})
// Нет классов, нет конструкторов, нет ручного DI
Почему НЕ Onion Architecture для Effect?
Onion Architecture ближе к Effect, но имеет одно ключевое отличие:
Explicit Domain Services layer → не нужен как отдельный слой
В Onion Architecture Domain Services — отдельный слой между Domain Model и Application Services. В Effect Domain Services — это обычные чистые функции, которые не требуют отдельного архитектурного слоя:
// Onion: Domain Services — отдельный слой с DI
class TodoPrioritizer {
constructor(private readonly config: PrioritizerConfig) {}
prioritize(todos: Todo[]): Todo[] { /* ... */ }
}
// Effect: Domain Services — просто функции
// Если нужна конфигурация — она в R-канале
const prioritize = (
todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
[...todos].sort(/* чистая сортировка */)
// Если нужна зависимость — она явна в типе
const prioritizeWithConfig = (
todos: ReadonlyArray<Todo>
): Effect.Effect<ReadonlyArray<Todo>, never, PrioritizerConfig> =>
Effect.gen(function* () {
const config = yield* PrioritizerConfig
return [...todos].sort(/* сортировка с учётом config */)
})
Сводная таблица: Effect ↔ Hexagonal
| Hexagonal Architecture | Effect-ts | Роль |
|---|---|---|
| Port | Context.Tag | Типизированный контракт |
| Adapter | Layer | Конкретная реализация контракта |
| Driving Port | Tag для входных операций (Use Cases) | API приложения |
| Driven Port | Tag для инфраструктуры (Repository, etc.) | Зависимости приложения |
| Driving Adapter | HTTP Router, CLI handler | Внешний клиент |
| Driven Adapter | Layer для SQLite, FS, API | Инфраструктура |
| Application Core | Effect-программы (Effect.gen) | Бизнес-логика |
| Dependency Rule | R-канал + Effect.provide | Проверка зависимостей |
| Adapter Wiring | Layer.mergeAll + Effect.provide | Сборка приложения |
| Error Contract | E-канал в Effect<A, E, R> | Контракт ошибок порта |
| Adapter Swap | Замена Layer в provide | Подмена технологии |
| Compilation check | R extends never | Dependency Rule enforced |
Гарантии, которые не даёт ни одна другая архитектура
1. Compile-time Dependency Rule
Если вы забыли подключить адаптер — код не скомпилируется:
const program = createTodo("Buy milk", "medium")
// Тип: Effect<Todo, Error, TodoRepository | Clock>
Effect.runPromise(program)
// ❌ Compilation Error:
// Argument of type 'Effect<Todo, Error, TodoRepository | Clock>'
// is not assignable to parameter of type 'Effect<Todo, Error, never>'
// Type 'TodoRepository | Clock' is not assignable to type 'never'
В Spring, NestJS и других DI-фреймворках вы узнаете о забытой зависимости в runtime. В Effect — в IDE, до запуска.
2. Type-safe Error Propagation
Ошибки не теряются и не становятся unknown:
// Тип ПОЛНОСТЬЮ описывает, что может пойти не так
const result: Effect.Effect<
Todo,
TodoNotFound | InvalidTransition | RepositoryError,
TodoRepository
> = completeTodo("123")
// Компилятор заставит обработать ВСЕ возможные ошибки
result.pipe(
Effect.catchTags({
TodoNotFound: (e) => /* обязательно */,
InvalidTransition: (e) => /* обязательно */,
RepositoryError: (e) => /* обязательно */,
})
)
3. Automatic Dependency Graph Resolution
Effect автоматически разрешает граф зависимостей между Layer:
// SqliteTodoRepo зависит от SqliteClient
// SqliteClient зависит от Config
// Effect разрешает это автоматически
const AppLayer = SqliteTodoRepo.pipe(
Layer.provide(SqliteClientLive),
Layer.provide(ConfigLive)
)
// Порядок не важен — Effect разберётся
4. Resource Safety
Адаптеры, которые управляют ресурсами (соединения с БД, файловые дескрипторы), гарантированно закрываются:
const SqliteClientLive = Layer.scoped(
SqliteClient,
Effect.acquireRelease(
// Acquire: открыть соединение
Effect.sync(() => new Database("todos.sqlite")),
// Release: ГАРАНТИРОВАННО закрыть
(db) => Effect.sync(() => db.close())
)
)
// close() вызовется ВСЕГДА, даже при ошибке или прерывании
Финальная картина: Hexagonal Architecture на Effect
DRIVING SIDE DRIVEN SIDE
(Context.Tag = Driving Port) (Context.Tag = Driven Port)
┌─────────────────────────┐ ┌─────────────────────────┐
│ HTTP Adapter │ │ SQLite Adapter │
│ (Bun.serve router) │ │ (Layer.scoped) │
│ │ │ │
│ Layer: HttpServerLive │ │ Layer: SqliteTodoRepo │
└──────────┬──────────────┘ └──────────▲──────────────┘
│ │
│ calls │ implements
│ │
┌──────────▼──────────────────────────────────────┤
│ │
│ APPLICATION CORE │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Use Cases (Effect.gen functions) │ │
│ │ │ │
│ │ createTodo: Effect<Todo, E, R> │ │
│ │ completeTodo: Effect<Todo, E, R> │ │
│ │ listTodos: Effect<Todo[], E, R> │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Domain Model (pure types & functions) │ │
│ │ │ │
│ │ Todo, Priority, TodoStatus │ │
│ │ completeTodo, transitionStatus │ │
│ │ ValidationError, TodoNotFound │ │
│ └──────────────────────────────────────────┘ │
│ │
│ R-канал = [TodoRepository, Clock, ...] │
│ E-канал = [TodoNotFound, InvalidTransition, ...]│
│ │
└──────────────────────────────────────────────────┘
Effect.provide(AppLayer) — WIRING
Compiler checks R = never — DEPENDENCY RULE
Ключевые выводы
-
Context.Tag = Port — не метафора, а прямое структурное соответствие. Оба определяют типизированный контракт без реализации.
-
Layer = Adapter — конкретная реализация контракта для конкретной технологии. Замена Layer = замена адаптера.
-
R-канал = Dependency Rule — компилятор гарантирует, что все зависимости (порты) удовлетворены. Это сильнее, чем runtime DI.
-
E-канал = Error Contract — ошибки как часть контракта порта, типизированные и исчерпывающие.
-
Clean Architecture избыточна для Effect: Presenter, Output Boundary, Interactor class — всё это заменяется
Effect.map,Effect.catchTagи обычными функциями. -
Onion Architecture близка, но её явное разделение Domain Services не нужно как отдельный слой в Effect.
-
Hexagonal Architecture — естественный выбор для Effect, потому что Effect уже реализует Ports & Adapters на уровне своей типовой системы.
Далее: Упражнения — применяем полученные знания для классификации архитектурных решений и анализа реального кода.