Типобезопасный домен: Гексагональная архитектура на базе Effect Контракт Repository: find, save, delete, findAll
Глава

Контракт Repository: find, save, delete, findAll

Детальный разбор четырёх фундаментальных операций Repository. Сигнатуры, семантика, обоснование решений для каждого метода. Почему save возвращает void, почему findById возвращает Option, почему delete идемпотентен. Типизация ошибок, инварианты контракта (roundtrip, идемпотентность), batch-операции, расширение контракта доменными методами.

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

Контракт Repository — это формальное соглашение между доменом и инфраструктурой. Он определяет:

  • Какие операции доступны (методы)
  • Какие типы принимаются и возвращаются (сигнатуры)
  • Какие ошибки могут возникнуть (E-канал)
  • Какие инварианты гарантируются (семантика)

Хорошо спроектированный контракт — минимальный, полный и выразительный. Он не содержит лишних методов, но предоставляет всё необходимое для работы домена. Он использует доменные типы и говорит на языке бизнеса.

В этой статье мы построим контракт Repository шаг за шагом, разберём каждый метод, его семантику и обоснование решений.


Базовый контракт Repository

Четыре фундаментальных операции

Любой Repository строится вокруг четырёх базовых операций, соответствующих операциям коллекции:

┌──────────────────────────────────────────────────┐
│            CRUD → Collection Semantics            │
│                                                   │
│  Create  →  save      (добавить в коллекцию)     │
│  Read    →  findById  (найти элемент)            │
│  Update  →  save      (обновить в коллекции)     │
│  Delete  →  delete    (удалить из коллекции)     │
│                                                   │
│  + findAll — перечислить все элементы коллекции   │
│                                                   │
│  Обратите внимание: Create и Update — это         │
│  одна операция save (upsert семантика)            │
└──────────────────────────────────────────────────┘

Полный базовый контракт

import { Context, Effect, Option, Schema } from "effect"

// ════════════════════════════════════════════════════
// Ошибки Repository
// ════════════════════════════════════════════════════

class RepositoryError extends Schema.TaggedError<RepositoryError>()(
  "RepositoryError",
  {
    operation: Schema.String,
    message: Schema.String,
    cause: Schema.optional(Schema.Unknown),
  }
) {}

// ════════════════════════════════════════════════════
// Контракт TodoRepository
// ════════════════════════════════════════════════════

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    /**
     * Сохраняет агрегат в хранилище.
     * 
     * Семантика: upsert
     * - Если агрегат с таким Id не существует → создаётся новый
     * - Если агрегат с таким Id существует → обновляется
     * 
     * Гарантии:
     * - Атомарность: агрегат сохраняется целиком или не сохраняется
     * - После успешного save, findById(todo.id) вернёт Some(todo)
     * 
     * @param todo - полный агрегат для сохранения
     * @returns void при успехе, RepositoryError при ошибке хранилища
     */
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
    
    /**
     * Ищет агрегат по идентификатору.
     * 
     * Семантика: lookup
     * - Если агрегат найден → Option.some(todo) с полностью восстановленным агрегатом
     * - Если агрегат не найден → Option.none()
     * 
     * Гарантии:
     * - Возвращённый агрегат валиден и все инварианты соблюдены
     * - Option.none() — это не ошибка, это отсутствие результата
     * 
     * @param id - доменный идентификатор агрегата
     * @returns Option<Todo> при успехе, RepositoryError при ошибке хранилища
     */
    readonly findById: (
      id: TodoId
    ) => Effect.Effect<Option.Option<Todo>, RepositoryError>
    
    /**
     * Удаляет агрегат из хранилища.
     * 
     * Семантика: идемпотентное удаление
     * - Если агрегат существует → удаляется
     * - Если агрегат не существует → операция успешна (идемпотентность)
     * 
     * Гарантии:
     * - После delete(id), findById(id) вернёт Option.none()
     * - Повторный вызов delete(id) не вызывает ошибку
     * 
     * @param id - доменный идентификатор агрегата для удаления
     * @returns void при успехе, RepositoryError при ошибке хранилища
     */
    readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
    
    /**
     * Возвращает все агрегаты из хранилища.
     * 
     * Семантика: полное перечисление
     * - Возвращает ReadonlyArray всех агрегатов
     * - Пустой массив, если хранилище пусто
     * 
     * Гарантии:
     * - Все возвращённые агрегаты валидны
     * - Порядок не гарантирован (если нужен порядок — используйте Specification)
     * 
     * Предупреждение:
     * - Для больших коллекций используйте findMany с пагинацией
     * 
     * @returns ReadonlyArray<Todo> при успехе, RepositoryError при ошибке
     */
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  }
>() {}

Детальный разбор каждого метода

save: сохранение агрегата

Сигнатура

readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>

Почему void, а не Todo?

Распространённый вопрос: почему save возвращает void, а не сохранённый агрегат? Есть несколько причин:

1. Агрегат уже есть у вызывающего кода:

const todo = Todo.make({ /* ... */ })
yield* repo.save(todo)
// У нас уже есть `todo` — зачем получать его обратно?

2. Repository не должен модифицировать агрегат:

Repository только хранит. Он не добавляет auto-increment ID, не проставляет timestamps — это ответственность домена:

// ❌ Антипаттерн: Repository генерирует Id
readonly save: (todo: Omit<Todo, "id">) => Effect.Effect<Todo, RepositoryError>
// Домен должен САМ генерировать Id до вызова save

// ✅ Правильно: домен контролирует создание
const todo = Todo.make({
  id: yield* generateId(),  // ← Домен генерирует Id
  title: validTitle,
  createdAt: yield* Clock.currentTimeMillis,  // ← Домен устанавливает время
})
yield* repo.save(todo)  // ← Repository только хранит

3. Исключение: генерируемые идентификаторы БД

Единственный случай, когда save может возвращать что-то — это если идентификатор генерируется базой данных (auto-increment). Но в DDD это антипаттерн. Идентификатор должен генерироваться в домене (UUID/ULID), а не делегироваться базе данных:

// ✅ Рекомендуемый подход: Id генерируется доменом
const TodoId = Schema.String.pipe(
  Schema.brand("TodoId")
)

const generateTodoId = (): TodoId =>
  TodoId.make(crypto.randomUUID()) // или ULID

// Домен полностью контролирует идентификацию

Семантика upsert

save — это upsert. Одна операция для insert и update:

// Первый save — insert
const todo = Todo.make({ id: todoId, title: "Draft", done: false })
yield* repo.save(todo)

// Второй save — update
const updated = new Todo({ ...todo, title: "Final version" })
yield* repo.save(updated)

// Оба вызова используют одну и ту же операцию save
// Адаптер внутри решает: INSERT ON CONFLICT UPDATE, MERGE, или другой механизм

Атомарность

save гарантирует, что агрегат сохраняется целиком или не сохраняется вообще. Если агрегат содержит вложенные Entity (Subtasks), все они сохраняются в одной атомарной операции:

// Агрегат с вложенными Entity
const todo = Todo.make({
  id: todoId,
  title: "Complex task",
  subtasks: [
    Subtask.make({ id: st1, title: "Part 1", done: true }),
    Subtask.make({ id: st2, title: "Part 2", done: false }),
  ]
})

// save гарантирует: Todo + оба Subtask сохранены атомарно
yield* repo.save(todo)
// Невозможна ситуация: Todo сохранён, а Subtasks — нет

findById: поиск по идентификатору

Сигнатура

readonly findById: (
  id: TodoId
) => Effect.Effect<Option.Option<Todo>, RepositoryError>

Почему Option, а не fail/throw при отсутствии?

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

// ✅ Option — явное выражение возможного отсутствия
const maybeTodo = yield* repo.findById(todoId)

// Вызывающий код РЕШАЕТ, что делать:
const result = pipe(
  maybeTodo,
  Option.match({
    onNone: () => yield* Effect.fail(new TodoNotFound({ id: todoId })),
    onSome: (todo) => todo,
  })
)

Сравните с альтернативами:

// ❌ Вариант 1: fail при отсутствии
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound | RepositoryError>
// Проблема: Repository РЕШАЕТ, что отсутствие — это ошибка
// Но это может быть нормальный flow (например, "создай если не существует")

// ❌ Вариант 2: null
readonly findById: (id: TodoId) => Effect.Effect<Todo | null, RepositoryError>
// Проблема: null — неявный, легко забыть проверку

// ✅ Вариант 3: Option (рекомендуемый)
readonly findById: (id: TodoId) => Effect.Effect<Option.Option<Todo>, RepositoryError>
// Явно, типобезопасно, вызывающий код решает стратегию

Когда всё же нужен fail?

Иногда удобно иметь метод getById, который гарантирует наличие агрегата:

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    // Базовый — возвращает Option
    readonly findById: (id: TodoId) => Effect.Effect<Option.Option<Todo>, RepositoryError>
    
    // Удобный — бросает ошибку если не найден
    readonly getById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound | RepositoryError>
  }
>() {}

Но getById можно реализовать поверх findById как утилиту, не загромождая контракт:

// Утилита вне контракта Repository
const getTodoOrFail = (id: TodoId) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const maybeTodo = yield* repo.findById(id)
    
    return yield* pipe(
      maybeTodo,
      Option.match({
        onNone: () => Effect.fail(new TodoNotFound({ id })),
        onSome: Effect.succeed,
      })
    )
  })

Восстановление агрегата

findById возвращает полностью валидный агрегат. Адаптер отвечает за:

  1. Чтение raw-данных из хранилища
  2. Маппинг в доменные типы (через Schema.decode)
  3. Восстановление вложенных Entity и Value Object
  4. Валидацию инвариантов
// Внутри адаптера (упрощённо)
const findById = (id: TodoId) =>
  Effect.gen(function* () {
    // 1. Raw data из хранилища
    const row = yield* queryDatabase("SELECT * FROM todos WHERE id = ?", [id])
    
    if (!row) return Option.none()
    
    // 2. Маппинг через Schema — валидация + трансформация
    const todo = yield* Schema.decode(Todo)({
      id: row.id,
      title: row.title,
      done: row.done === 1,
      // ...
    })
    
    // 3. Возвращаем валидный агрегат
    return Option.some(todo)
  })

delete: удаление агрегата

Сигнатура

readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>

Идемпотентность

deleteидемпотентная операция. Повторный вызов с тем же Id не вызывает ошибку:

yield* repo.delete(todoId)  // Удаляет Todo
yield* repo.delete(todoId)  // Не ошибка — просто noop

// Почему? Потому что после delete результат один и тот же:
// Todo с этим Id не существует в хранилище

Идемпотентность важна для надёжности:

  • Retry при сетевой ошибке: если первый delete прошёл, но ответ потерялся, повторный вызов не сломает систему
  • Distributed systems: в распределённых системах идемпотентность — основа надёжности
  • Event handlers: обработчик события может быть вызван повторно

Мягкое vs жёсткое удаление

Контракт Repository определяет семантику, а не механизм. delete означает: “этот агрегат больше не доступен через Repository”. Как адаптер это реализует — его дело:

// Адаптер может делать жёсткое удаление:
// DELETE FROM todos WHERE id = ?

// Или мягкое удаление (soft delete):
// UPDATE todos SET deleted_at = NOW() WHERE id = ?
// + SELECT ... WHERE deleted_at IS NULL (в findById/findAll)

// Для домена это прозрачно — результат один:
// findById(deletedId) → Option.none()

Почему delete(id), а не delete(todo)?

// ✅ По Id — минимальная информация для операции
readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>

// 🤔 По агрегату — избыточно
readonly delete: (todo: Todo) => Effect.Effect<void, RepositoryError>

Для удаления нужен только идентификатор. Передавать весь агрегат — избыточно и может вводить в заблуждение (будто Repository проверяет поля агрегата перед удалением).

Исключение: если при удалении нужна проверка версии (optimistic locking), может понадобиться агрегат или версия:

// С optimistic locking (продвинутый сценарий)
readonly delete: (
  id: TodoId, 
  expectedVersion: number
) => Effect.Effect<void, RepositoryError | ConcurrencyError>

findAll: получение всех агрегатов

Сигнатура

readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

ReadonlyArray, не Array

ReadonlyArray<Todo> — иммутабельный массив. Вызывающий код не может случайно модифицировать результат:

const todos = yield* repo.findAll()

// ✅ Чтение — пожалуйста
const firstTodo = todos[0]
const titles = todos.map(t => t.title)
const active = todos.filter(t => !t.done)

// ❌ Мутация — компилятор не позволит
todos.push(newTodo)     // Error: Property 'push' does not exist on type 'readonly Todo[]'
todos[0] = newTodo      // Error: Index signature in type 'readonly Todo[]' only permits reading

Пустой массив вместо ошибки

Если хранилище пустое, findAll возвращает пустой ReadonlyArray, а не ошибку:

const todos = yield* repo.findAll()

if (todos.length === 0) {
  // Нормальная ситуация — пустое хранилище
  console.log("Нет задач")
}

Осторожно: findAll для больших коллекций

findAll загружает все агрегаты в память. Для маленьких коллекций это нормально. Для больших — нужна пагинация:

// Для больших коллекций: расширяем контракт пагинацией
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
    
    // Пагинация для больших коллекций
    readonly findMany: (
      options: { readonly offset: number; readonly limit: number }
    ) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
    
    // Подсчёт без загрузки
    readonly count: () => Effect.Effect<number, RepositoryError>
  }
>() {}

Порядок элементов

findAll не гарантирует порядок. Если нужен определённый порядок — это отдельный метод или параметр:

// Вариант 1: Отдельный метод
readonly findAllSorted: (
  orderBy: "createdAt" | "priority" | "title",
  direction: "asc" | "desc"
) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

// Вариант 2: Specification Pattern (см. статью 05)
readonly findMany: (
  spec: Specification<Todo>
) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

Расширение базового контракта

Дополнительные методы поиска

В реальных приложениях базовых четырёх операций обычно недостаточно. Расширяем контракт доменно-значимыми методами:

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>
    
    // === Доменные запросы ===
    
    /** Найти активные задачи (не завершённые и не архивированные) */
    readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
    
    /** Найти задачи по приоритету */
    readonly findByPriority: (
      priority: Priority
    ) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
    
    /** Проверить существование задачи */
    readonly exists: (id: TodoId) => Effect.Effect<boolean, RepositoryError>
    
    /** Подсчитать количество задач */
    readonly count: () => Effect.Effect<number, RepositoryError>
  }
>() {}

Правило: методы поиска используют доменный язык

Каждый метод поиска должен быть бизнес-значимым:

// ✅ Доменный язык — понятно БЕЗ знания SQL
readonly findOverdue: (asOf: Date) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findCompletedBetween: (
  from: Date, to: Date
) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findByOwner: (ownerId: UserId) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

// ❌ Технический язык — утечка реализации
readonly findWhereDueDateLessThan: (date: string) => Effect.Effect<ReadonlyArray<Todo>>
readonly queryByField: (field: string, value: unknown) => Effect.Effect<ReadonlyArray<Todo>>

Типизация ошибок: что может пойти не так

Иерархия ошибок Repository

import { Schema, Data } from "effect"

// ── Базовая ошибка Repository ──
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
  "RepositoryError",
  {
    operation: Schema.String,
    message: Schema.String,
    cause: Schema.optional(Schema.Unknown),
  }
) {}

// ── Специализированные ошибки (при необходимости) ──

// Ошибка оптимистичной блокировки
class ConcurrencyError extends Schema.TaggedError<ConcurrencyError>()(
  "ConcurrencyError",
  {
    aggregateId: Schema.String,
    expectedVersion: Schema.Number,
    actualVersion: Schema.Number,
  }
) {}

// Ошибка уникальности (дубликат)
class DuplicateError extends Schema.TaggedError<DuplicateError>()(
  "DuplicateError",
  {
    field: Schema.String,
    value: Schema.String,
  }
) {}

Разделение ошибок: Repository vs Domain

Важно не путать ошибки Repository (инфраструктурные) с доменными ошибками:

// Доменные ошибки — бизнес-значимые
class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
  "TodoNotFound",
  { id: TodoId }
) {}

class InvalidTodoTransition extends Schema.TaggedError<InvalidTodoTransition>()(
  "InvalidTodoTransition",
  { from: Status, to: Status }
) {}

// Repository ошибки — инфраструктурные
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
  "RepositoryError",
  { operation: Schema.String, message: Schema.String }
) {}

Маппинг между ними происходит на уровне адаптера:

// Внутри SQLite-адаптера
const save = (todo: Todo) =>
  pipe(
    Effect.try(() => db.run("INSERT OR REPLACE ...", [todo.id, todo.title])),
    Effect.mapError((sqliteError) =>
      new RepositoryError({
        operation: "save",
        message: `Failed to save Todo ${todo.id}`,
        cause: sqliteError,
      })
    )
  )

Инварианты контракта

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

Инвариант 1: Roundtrip (save → findById = identity)

// Для любого валидного агрегата todo:
yield* repo.save(todo)
const found = yield* repo.findById(todo.id)

// found === Option.some(todo')
// где todo' структурно эквивалентен todo
// (Equal.equals(todo, Option.getOrThrow(found)) === true)

Это самый важный инвариант. Если я сохранил агрегат и сразу прочитал его — должен получить эквивалентный объект.

Инвариант 2: Save идемпотентен по Id

yield* repo.save(todo)
yield* repo.save(todo)  // Повторный save с тем же Id — не ошибка

const all = yield* repo.findAll()
// all содержит ровно один элемент с todo.id

Инвариант 3: Delete + findById = None

yield* repo.save(todo)
yield* repo.delete(todo.id)

const found = yield* repo.findById(todo.id)
// found === Option.none()

Инвариант 4: Delete идемпотентен

yield* repo.delete(todoId)
yield* repo.delete(todoId)  // Повторный delete — не ошибка

Инвариант 5: findAll содержит все сохранённые агрегаты

yield* repo.save(todo1)
yield* repo.save(todo2)

const all = yield* repo.findAll()
// all содержит и todo1, и todo2

Выражение инвариантов как тестов

Эти инварианты становятся контрактными тестами, которые должна проходить любая реализация Repository:

// Contract test — запускается для КАЖДОГО адаптера
const testRepositoryContract = (
  makeLayer: Layer.Layer<TodoRepository>
) =>
  describe("TodoRepository Contract", () => {
    it("roundtrip: save then findById returns same aggregate", () =>
      Effect.gen(function* () {
        const repo = yield* TodoRepository
        const todo = makeTodo({ title: "Test" })
        
        yield* repo.save(todo)
        const found = yield* repo.findById(todo.id)
        
        expect(Option.isSome(found)).toBe(true)
        expect(Option.getOrThrow(found)).toEqual(todo)
      }).pipe(
        Effect.provide(makeLayer),
        Effect.runPromise
      )
    )
    
    it("delete makes findById return None", () =>
      Effect.gen(function* () {
        const repo = yield* TodoRepository
        const todo = makeTodo({ title: "ToDelete" })
        
        yield* repo.save(todo)
        yield* repo.delete(todo.id)
        const found = yield* repo.findById(todo.id)
        
        expect(Option.isNone(found)).toBe(true)
      }).pipe(
        Effect.provide(makeLayer),
        Effect.runPromise
      )
    )
    
    it("delete is idempotent", () =>
      Effect.gen(function* () {
        const repo = yield* TodoRepository
        const todoId = TodoId.make("nonexistent")
        
        // Не бросает ошибку для несуществующего Id
        yield* repo.delete(todoId)
        yield* repo.delete(todoId)
      }).pipe(
        Effect.provide(makeLayer),
        Effect.runPromise
      )
    )
    
    // ... остальные инварианты
  })

// Запускаем для каждого адаптера
testRepositoryContract(TodoRepositoryInMemory)
testRepositoryContract(TodoRepositorySqlite)

Batch-операции: saveAll, deleteAll

Для массовых операций контракт может включать batch-методы:

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    // ... базовые методы ...
    
    /**
     * Сохраняет несколько агрегатов атомарно.
     * Все сохраняются или ни один.
     */
    readonly saveAll: (
      todos: ReadonlyArray<Todo>
    ) => Effect.Effect<void, RepositoryError>
    
    /**
     * Удаляет несколько агрегатов атомарно.
     */
    readonly deleteAll: (
      ids: ReadonlyArray<TodoId>
    ) => Effect.Effect<void, RepositoryError>
  }
>() {}

Batch-операции важны для производительности (один SQL-запрос вместо N) и атомарности (транзакция на всю пачку):

// Без batch: N отдельных операций
for (const todo of todos) {
  yield* repo.save(todo)  // N запросов к БД, без общей транзакции
}

// С batch: одна атомарная операция
yield* repo.saveAll(todos)  // 1 транзакция, оптимальное количество запросов

Проектирование типа Shape

Подход 1: Inline в Context.Tag (простой)

Для небольших контрактов определяем Shape прямо в Tag:

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>
  }
>() {}

Подход 2: Отдельный interface (рекомендуемый)

Для больших контрактов выделяем Shape в отдельный интерфейс:

// Интерфейс контракта
interface TodoRepositoryShape {
  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>
  readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly exists: (id: TodoId) => Effect.Effect<boolean, RepositoryError>
  readonly count: () => Effect.Effect<number, RepositoryError>
}

// Tag использует интерфейс
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  TodoRepositoryShape
>() {}

Преимущества отдельного интерфейса:

  • Переиспользование в тестах: const mock: TodoRepositoryShape = { ... }
  • Документация: JSDoc на интерфейсе, а не внутри Tag
  • Экспорт: можно экспортировать Shape отдельно от Tag

Подход 3: Извлечение типа из Tag

Effect позволяет извлечь Shape из Tag:

// Определяем Tag
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>
  }
>() {}

// Извлекаем тип Shape
type TodoRepositoryShape = Context.Tag.Service<TodoRepository>
//   ^? { readonly save: ...; readonly findById: ... }

Полный пример контракта для Todo

Собираем всё вместе — финальный контракт TodoRepository:

import { Context, Effect, Option, Schema } from "effect"

// ── Ошибки ──
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
  "RepositoryError",
  {
    operation: Schema.String,
    message: Schema.String,
    cause: Schema.optional(Schema.Unknown),
  }
) {}

// ── Shape ──
interface TodoRepositoryShape {
  /** Сохранить агрегат (upsert семантика) */
  readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
  
  /** Найти по Id (Option.none если не найден) */
  readonly findById: (id: TodoId) => Effect.Effect<Option.Option<Todo>, RepositoryError>
  
  /** Удалить по Id (идемпотентно) */
  readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
  
  /** Получить все агрегаты */
  readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  
  /** Проверить существование */
  readonly exists: (id: TodoId) => Effect.Effect<boolean, RepositoryError>
  
  /** Подсчитать количество */
  readonly count: () => Effect.Effect<number, RepositoryError>
}

// ── Port (Context.Tag) ──
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  TodoRepositoryShape
>() {}

export { TodoRepository, type TodoRepositoryShape, RepositoryError }

Итоги

  1. Четыре базовых метода: save (upsert), findById (Option), delete (идемпотентный), findAll (ReadonlyArray)
  2. save возвращает void: агрегат уже есть у клиента, Repository не модифицирует его
  3. findById возвращает Option: отсутствие — не ошибка, а информация
  4. delete идемпотентен: повторный вызов — не ошибка
  5. Ошибки типизированы: RepositoryError отделён от доменных ошибок
  6. Инварианты как контракт: roundtrip, идемпотентность — проверяются тестами для каждого адаптера
  7. Расширение по необходимости: добавляем доменные методы по мере появления реальных потребностей
  8. ReadonlyArray: иммутабельные результаты, защита от случайных мутаций