Repository как Driven Port: интерфейс через Effect.Service
Repository как Secondary (Driven) Port в Hexagonal Architecture. Пошаговое построение порта: доменные типы → ошибки → Context.Tag (Port) → использование в Application Service → Layer (Adapter) → Wiring. R-канал как compile-time Dependency Rule. Несколько адаптеров для одного порта. Антипаттерны: инфраструктурные типы, SQL, mutable state в контракте.
Введение: Port = интерфейс на языке домена
В модуле 5 мы установили фундаментальное соответствие: Context.Tag = Port. Теперь мы применим это к Repository — самому важному Driven Port в любом приложении с доменной моделью.
Repository как Driven Port — это контракт, который:
- Определяется доменом — на языке бизнеса, а не базы данных
- Реализуется инфраструктурой — конкретным адаптером (SQLite, InMemory, PostgreSQL)
- Обеспечивает Dependency Rule — домен не зависит от деталей хранения
В Effect-ts этот контракт выражается через Context.Tag, а его реализация — через Layer. Давайте разберём, как именно это работает, шаг за шагом.
Driven Port в Hexagonal Architecture: теория
Классификация портов
В гексагональной архитектуре есть два типа портов:
Driving Adapters Driven Adapters
(HTTP, CLI, GUI) (SQLite, Email, FS)
│ ▲
▼ │
┌─────────────┐ ┌───────────────┐
│ Driving │ │ Driven │
│ Port │ │ Port │
│ (Primary) │ │ (Secondary) │
└──────┬───────┘ └───────┬───────┘
│ │
▼ │
┌──────────────────────────────────────────────────────┐
│ │
│ APPLICATION CORE │
│ │
│ Domain Model ←── Application Services │
│ │
└──────────────────────────────────────────────────────┘
Driving Port (Primary): определяет API, через который внешний мир вызывает наше приложение. Пример: CreateTodoUseCase.
Driven Port (Secondary): определяет, что приложению нужно от внешнего мира. Пример: TodoRepository — приложению нужно хранить и извлекать Todo.
Направление зависимости
Ключевое правило — Dependency Rule: зависимости всегда указывают внутрь, к домену.
SQLiteAdapter ──depends on──► TodoRepository (Port) ◄──uses── Application Service
▲ │
│ │
Инфраструктура Определён в домене
ЗАВИСИТ от порта НЕ зависит от инфраструктуры
Порт TodoRepository определён внутри домена (или на границе домена). Адаптер TodoRepositorySqlite живёт в инфраструктуре и зависит от порта. Никогда не наоборот.
Repository как Driven Port на Effect-ts: пошаговое построение
Шаг 1: Определяем доменные типы
Прежде чем определить Repository, нам нужны доменные типы, которыми он оперирует. Они определены в предыдущих модулях:
import { Schema, Data } from "effect"
// === Value Objects ===
// TodoId — идентификатор агрегата (branded type)
const TodoId = Schema.String.pipe(
Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type
// TodoTitle — заголовок задачи с валидацией
const TodoTitle = Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
// Priority — приоритет как перечисление
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type
// Status — статус задачи
const Status = Schema.Literal("active", "completed", "archived")
type Status = typeof Status.Type
// === Entity (Aggregate Root) ===
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
priority: Priority,
status: Status,
createdAt: Schema.DateFromSelf,
completedAt: Schema.OptionFromSelf(Schema.DateFromSelf),
}) {
// Бизнес-методы — чистые функции
complete(): Todo {
return new Todo({
...this,
status: "completed" as Status,
completedAt: Option.some(new Date()),
})
}
}
// === Domain Errors ===
class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
"TodoNotFound",
{ id: TodoId }
) {}
Шаг 2: Определяем ошибки Repository
Repository имеет собственные ошибки, отделённые от доменных:
import { Schema } from "effect"
// Ошибка инфраструктуры хранения — может случиться при любой операции
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
"RepositoryError",
{
operation: Schema.Literal("save", "findById", "delete", "findAll"),
message: Schema.String,
cause: Schema.optional(Schema.Unknown),
}
) {}
Обратите внимание: RepositoryError — это ошибка инфраструктуры хранения, а не доменная ошибка. TodoNotFound — доменная ошибка (бизнес-значение: “задача не существует”). RepositoryError — техническая ошибка (что-то пошло не так при обращении к хранилищу).
Шаг 3: Определяем Repository как Context.Tag (Port)
Вот ключевой момент — определение порта:
import { Context, Effect, Option } from "effect"
// ────────────────────────────────────────────────────────
// TodoRepository — Driven Port
// Определяется в доменном слое (или на границе домена)
// Файл: src/domain/ports/TodoRepository.ts
// ────────────────────────────────────────────────────────
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly findById: (
id: TodoId
) => Effect.Effect<Option.Option<Todo>, RepositoryError>
readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
>() {}
Разберём каждый элемент:
Context.Tag("TodoRepository")
Это уникальный идентификатор сервиса в системе типов Effect. Строка "TodoRepository" используется для отладки и является ключом в Context.
Тип параметров <TodoRepository, Shape>
Первый параметр (TodoRepository) — сам тип тега (рекурсивная ссылка для self-typing). Второй параметр (Shape) — контракт порта: набор методов, которые должен реализовать любой адаптер.
readonly на каждом методе
Иммутабельность — фундаментальный принцип. Контракт не может быть изменён после определения.
Сигнатуры методов
Каждый метод возвращает Effect<A, E> (без R, потому что адаптер сам реализует метод — ему не нужны дополнительные зависимости на уровне контракта порта). Это важное наблюдение: контракт порта описывает, что метод делает, а не от чего он зависит.
Шаг 4: Используем порт в Application Service
Теперь Application Service использует TodoRepository как зависимость:
import { Effect, pipe } from "effect"
// ────────────────────────────────────────────────────────
// CreateTodoUseCase — Application Service
// Файл: src/application/use-cases/CreateTodoUseCase.ts
// ────────────────────────────────────────────────────────
interface CreateTodoInput {
readonly title: string
readonly priority: Priority
}
const createTodo = (input: CreateTodoInput) =>
Effect.gen(function* () {
// 1. Валидация и создание доменного объекта
const title = yield* Schema.decode(TodoTitle)(input.title)
const id = yield* generateTodoId()
const todo = new Todo({
id,
title,
priority: input.priority,
status: "active" as Status,
createdAt: new Date(),
completedAt: Option.none(),
})
// 2. Получаем Repository из контекста (порт!)
const repo = yield* TodoRepository
// 3. Сохраняем через порт
yield* repo.save(todo)
return todo
})
// ^? Effect<Todo, RepositoryError | ParseError, TodoRepository>
// ^^^^^^^^^^^^^^
// Зависимость в R-канале!
Посмотрите на выведенный тип createTodo:
Effect<Todo, RepositoryError | ParseError, TodoRepository>
│ │ │
│ │ └── R: нужен TodoRepository
│ └── E: может упасть с RepositoryError или ParseError
└── A: возвращает Todo
R-канал (TodoRepository) — это типизированная зависимость. Компилятор гарантирует, что перед запуском этого Effect будет предоставлена реализация TodoRepository. Если забыть — код не скомпилируется.
Шаг 5: Создаём адаптер (Layer)
Адаптер — это Layer, который реализует контракт порта:
import { Layer, Effect, Option } from "effect"
// ────────────────────────────────────────────────────────
// InMemory адаптер — для тестов и разработки
// Файл: src/infrastructure/adapters/TodoRepositoryInMemory.ts
// ────────────────────────────────────────────────────────
const TodoRepositoryInMemory = Layer.sync(TodoRepository, () => {
// Внутреннее состояние адаптера
const store = new Map<string, Todo>()
return {
save: (todo: Todo) =>
Effect.sync(() => {
store.set(todo.id, todo)
}),
findById: (id: TodoId) =>
Effect.sync(() => {
const found = store.get(id)
return found ? Option.some(found) : Option.none()
}),
delete: (id: TodoId) =>
Effect.sync(() => {
store.delete(id)
}),
findAll: () =>
Effect.sync(() =>
Array.from(store.values()) as ReadonlyArray<Todo>
),
}
})
// ^? Layer<TodoRepository, never, never>
// ^^^^^^^^^^^^^^
// Этот Layer ПРЕДОСТАВЛЯЕТ TodoRepository
Шаг 6: Подключаем адаптер (Wiring)
import { Effect, pipe } from "effect"
// ────────────────────────────────────────────────────────
// Точка входа — сборка приложения
// Файл: src/main.ts
// ────────────────────────────────────────────────────────
const program = createTodo({
title: "Написать статью о Repository",
priority: "high" as Priority,
})
// ^? Effect<Todo, RepositoryError | ParseError, TodoRepository>
// Подключаем адаптер — TodoRepository "исчезает" из R-канала
const runnable = pipe(
program,
Effect.provide(TodoRepositoryInMemory)
)
// ^? Effect<Todo, RepositoryError | ParseError, never>
// ^^^^^
// Все зависимости удовлетворены!
// Запускаем
Effect.runPromise(runnable).then(console.log)
Анатомия Driven Port: три компонента
Каждый Driven Port в Effect-ts состоит из трёх компонентов:
┌─────────────────────────────────────────────────────────────────┐
│ DRIVEN PORT (полная структура) │
│ │
│ 1. КОНТРАКТ (Context.Tag + Shape) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ class TodoRepository extends Context.Tag(...) │ │
│ │ save: (todo: Todo) => Effect<void, RepoError> │ │
│ │ findById: (id: TodoId) => Effect<Option<Todo>>│ │
│ └─────────────────────────────────────────────────┘ │
│ Определён в: src/domain/ports/ │
│ │
│ 2. ОШИБКИ (TaggedError) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ class RepositoryError extends TaggedError(...) │ │
│ └─────────────────────────────────────────────────┘ │
│ Определены в: src/domain/errors/ │
│ │
│ 3. ТИПЫ (доменные типы для сигнатур) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Todo, TodoId, Option<Todo>, ReadonlyArray<Todo> │ │
│ └─────────────────────────────────────────────────┘ │
│ Определены в: src/domain/model/ │
│ │
└─────────────────────────────────────────────────────────────────┘
Все три компонента живут в ДОМЕННОМ слое.
Адаптер (Layer) живёт в ИНФРАСТРУКТУРНОМ слое и ЗАВИСИТ от них.
R-канал как Dependency Rule
Как R-канал обеспечивает Dependency Rule
R-канал в Effect<A, E, R> — это compile-time реализация Dependency Rule. Рассмотрим, как это работает на практике:
// Domain Layer — определяет контракт
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{ readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError> }
>() {}
// Application Layer — использует контракт
const createTodo = (input: CreateTodoInput) =>
Effect.gen(function* () {
const repo = yield* TodoRepository // ← Запрашивает порт
yield* repo.save(todo) // ← Использует порт
})
// ^? Effect<Todo, ..., TodoRepository> // ← R содержит TodoRepository
// Infrastructure Layer — реализует контракт
const TodoRepositorySqlite = Layer.effect(TodoRepository, /* ... */)
// ^? Layer<TodoRepository, ...> // ← Предоставляет TodoRepository
Цепочка зависимостей:
- Domain определяет
TodoRepository(Tag) - Application использует
TodoRepository→ появляется в R-канале - Infrastructure реализует
TodoRepositoryчерез Layer → убирает из R-канала
Если Infrastructure не предоставит реализацию — код не скомпилируется. Компилятор TypeScript гарантирует Dependency Rule:
// ❌ Не скомпилируется: R = TodoRepository, а provide не дан
Effect.runPromise(createTodo({ title: "test", priority: "high" }))
// Type error: Effect<Todo, ..., TodoRepository> is not assignable to Effect<Todo, ..., never>
// ✅ Скомпилируется: provide убирает TodoRepository из R
Effect.runPromise(
pipe(
createTodo({ title: "test", priority: "high" }),
Effect.provide(TodoRepositorySqlite)
)
)
Сравнение с традиционным DI
В традиционных DI-контейнерах (inversify, tsyringe) ошибки — runtime:
// Традиционный DI — ошибка в RUNTIME
container.bind(TodoRepository).to(TodoRepositorySqlite)
const useCase = container.get(CreateTodoUseCase)
// Если забыли bind — узнаем только при запуске: "No binding for TodoRepository"
// Effect DI — ошибка в COMPILE TIME
const program = createTodo(input)
const runnable = Effect.provide(program, TodoRepositorySqlite)
// Если забыли provide — TypeScript ошибка при компиляции
Правила размещения файлов
Структура файлов для Driven Port
src/
├── domain/
│ ├── model/
│ │ ├── Todo.ts ← Aggregate Root
│ │ ├── TodoId.ts ← Value Object
│ │ └── TodoTitle.ts ← Value Object
│ │
│ ├── ports/
│ │ └── TodoRepository.ts ← PORT (Context.Tag + Shape)
│ │
│ └── errors/
│ ├── TodoNotFound.ts ← Domain Error
│ └── RepositoryError.ts ← Repository Error
│
├── application/
│ └── use-cases/
│ └── CreateTodo.ts ← Uses TodoRepository (R-канал)
│
└── infrastructure/
└── adapters/
├── TodoRepositoryInMemory.ts ← Adapter (Layer)
└── TodoRepositorySqlite.ts ← Adapter (Layer)
Ключевые правила:
TodoRepository.ts(порт) — вdomain/ports/. Это контракт, определённый доменом.TodoRepositoryInMemory.tsиTodoRepositorySqlite.ts(адаптеры) — вinfrastructure/adapters/. Они реализуют контракт.RepositoryError.ts— вdomain/errors/. Ошибки порта определяются доменом.- Импорты идут ТОЛЬКО внутрь:
infrastructure/→ импортирует изdomain/application/→ импортирует изdomain/domain/→ НЕ импортирует изinfrastructure/илиapplication/
Проверка направления зависимостей
Визуально проверяем импорты:
// ✅ infrastructure/adapters/TodoRepositorySqlite.ts
import { TodoRepository } from "../../domain/ports/TodoRepository" // ← внутрь
import { Todo } from "../../domain/model/Todo" // ← внутрь
import { RepositoryError } from "../../domain/errors/RepositoryError" // ← внутрь
// ✅ application/use-cases/CreateTodo.ts
import { TodoRepository } from "../../domain/ports/TodoRepository" // ← внутрь
import { Todo } from "../../domain/model/Todo" // ← внутрь
// ❌ domain/model/Todo.ts
import { Database } from "bun:sqlite" // ← НАРУЖУ! Нарушение Dependency Rule!
Несколько адаптеров для одного порта
Одна из главных ценностей Port — возможность иметь несколько реализаций:
// ── Порт (один) ──────────────────────────────────
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
TodoRepositoryShape
>() {}
// ── Адаптер 1: InMemory (для тестов) ─────────────
const TodoRepositoryInMemory: Layer.Layer<TodoRepository> =
Layer.sync(TodoRepository, () => ({
save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
findById: (id) => Effect.sync(() => Option.fromNullable(store.get(id))),
delete: (id) => Effect.sync(() => { store.delete(id) }),
findAll: () => Effect.sync(() => Array.from(store.values())),
}))
// ── Адаптер 2: SQLite (для production) ───────────
const TodoRepositorySqlite: Layer.Layer<TodoRepository, never, SqliteClient> =
Layer.effect(TodoRepository,
Effect.gen(function* () {
const db = yield* SqliteClient
return {
save: (todo) => Effect.try(() => db.run("INSERT OR REPLACE ...", [todo.id, ...])),
findById: (id) => Effect.try(() => {
const row = db.get("SELECT * FROM todos WHERE id = ?", [id])
return row ? Option.some(rowToTodo(row)) : Option.none()
}),
// ...
}
})
)
// ── Адаптер 3: HTTP (для микросервисов) ──────────
const TodoRepositoryHttp: Layer.Layer<TodoRepository, never, HttpClient> =
Layer.effect(TodoRepository,
Effect.gen(function* () {
const http = yield* HttpClient
return {
save: (todo) => pipe(
http.post("/api/todos", { body: todo }),
Effect.mapError(toRepositoryError)
),
// ...
}
})
)
Выбор адаптера происходит на этапе сборки, не в бизнес-логике:
// Тесты
const testProgram = pipe(program, Effect.provide(TodoRepositoryInMemory))
// Development
const devProgram = pipe(program, Effect.provide(TodoRepositorySqlite))
// Микросервисная конфигурация
const prodProgram = pipe(program, Effect.provide(TodoRepositoryHttp))
Полный пример: от порта до запуска
Соберём всё в один связный пример:
import { Context, Effect, Layer, Option, Schema, pipe } from "effect"
// ╔══════════════════════════════════════════════════════╗
// ║ DOMAIN LAYER ║
// ╚══════════════════════════════════════════════════════╝
// --- Value Objects ---
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type
const TodoTitle = Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
// --- Errors ---
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
"RepositoryError",
{ message: Schema.String }
) {}
// --- Entity ---
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
done: Schema.Boolean,
}) {}
// --- Port (Driven) ---
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly findById: (id: TodoId) => Effect.Effect<Option.Option<Todo>, RepositoryError>
readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
>() {}
// ╔══════════════════════════════════════════════════════╗
// ║ APPLICATION LAYER ║
// ╚══════════════════════════════════════════════════════╝
const createTodo = (title: string) =>
Effect.gen(function* () {
const validTitle = yield* Schema.decode(TodoTitle)(title)
const todo = new Todo({
id: TodoId.make(`todo-${Date.now()}`),
title: validTitle,
done: false,
})
const repo = yield* TodoRepository
yield* repo.save(todo)
return todo
})
const getAllTodos = Effect.gen(function* () {
const repo = yield* TodoRepository
return yield* repo.findAll()
})
// ╔══════════════════════════════════════════════════════╗
// ║ INFRASTRUCTURE LAYER ║
// ╚══════════════════════════════════════════════════════╝
const TodoRepositoryInMemory = Layer.sync(TodoRepository, () => {
const store = new Map<string, Todo>()
return {
save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
findById: (id) => Effect.sync(() => Option.fromNullable(store.get(id))),
delete: (id) => Effect.sync(() => { store.delete(id) }),
findAll: () => Effect.sync(() => Array.from(store.values()) as ReadonlyArray<Todo>),
}
})
// ╔══════════════════════════════════════════════════════╗
// ║ COMPOSITION ROOT ║
// ╚══════════════════════════════════════════════════════╝
const program = Effect.gen(function* () {
yield* createTodo("Изучить Repository Pattern")
yield* createTodo("Написать адаптер для SQLite")
const todos = yield* getAllTodos
return todos
})
const main = pipe(
program,
Effect.provide(TodoRepositoryInMemory)
)
Effect.runPromise(main).then((todos) => {
console.log(`Создано ${todos.length} задач`)
todos.forEach((t) => console.log(` - ${t.title}`))
})
Driven Port vs Driving Port: ключевые различия
| Характеристика | Driving Port (Primary) | Driven Port (Secondary) |
|---|---|---|
| Кто вызывает | Внешний мир → приложение | Приложение → внешний мир |
| Пример | CreateTodoUseCase | TodoRepository |
| Определяет | API приложения | Потребности приложения |
| Адаптер | HTTP Controller, CLI | SQLite, InMemory, API Client |
| Направление данных | Входящие запросы | Исходящие операции |
| Кто реализует | Application Layer | Infrastructure Layer |
Repository — всегда Driven Port, потому что это приложение обращается к хранилищу, а не наоборот. Приложение говорит: “мне нужно сохранить этот агрегат” — и Repository обеспечивает эту потребность.
Антипаттерны: что ломает Port
Антипаттерн 1: Инфраструктурные типы в контракте
// ❌ Утечка инфраструктуры
import { Database } from "bun:sqlite"
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo, db: Database) => Effect.Effect<void>
// ^^^^^^^^^^^
// Инфраструктурный тип в контракте порта!
}
>() {}
Антипаттерн 2: SQL в контракте
// ❌ SQL — деталь реализации
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly query: (sql: string, params: ReadonlyArray<unknown>) => Effect.Effect<unknown>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Это не Repository, это Database Client
}
>() {}
Антипаттерн 3: Возврат инфраструктурных ошибок
// ❌ SQLite ошибки в контракте порта
import { SqliteError } from "bun:sqlite"
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void, SqliteError>
// ^^^^^^^^^^^
// Инфраструктурная ошибка в доменном контракте!
}
>() {}
// ✅ Доменная ошибка
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
// ^^^^^^^^^^^^^^^
// Абстрактная ошибка, определённая доменом
}
>() {}
Антипаттерн 4: Mutable state в контракте
// ❌ Mutable state
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
cache: Map<string, Todo> // ← Mutable, не readonly
save: (todo: Todo) => void // ← Синхронный, без Effect
}
>() {}
// ✅ Immutable, Effect-based
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
}
>() {}
Итоги
- Repository — это Driven Port: контракт определён доменом, реализован инфраструктурой
- Context.Tag — это Port:
Context.Tagв Effect-ts — прямой аналог порта в Hexagonal Architecture - Layer — это Adapter: каждый адаптер реализуется как Layer, который удовлетворяет Tag
- R-канал — это Dependency Rule: компилятор гарантирует, что все зависимости предоставлены
- Множество адаптеров: один порт может иметь сколько угодно реализаций (InMemory, SQLite, HTTP)
- Направление зависимостей:
infrastructure/ → domain/, никогда наоборот - Чистота контракта: никаких инфраструктурных типов, SQL, database-специфичных ошибок
В следующей статье мы детально разберём контракт Repository — какие именно методы должен содержать порт, какие типы возвращать, и как обеспечить полноту контракта.