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

TodoRepository: полный контракт для Todo-агрегата

Полный production-ready контракт TodoRepository. Доменные типы, ошибки (RepositoryError, ConcurrencyError, DuplicateTodoError), TodoFilter, TodoQueryOptions, TodoSortField. InMemory-адаптер с фильтрацией, сортировкой и пагинацией. Использование в Use Cases (CreateTodo, CompleteTodo, ListTodos). Контрактные тесты для каждого инварианта. Утилиты getTodoOrFail, updateTodo.

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

В предыдущих статьях мы изучили Repository как абстракцию, как Driven Port, как контракт, как Generic Repository и Specification Pattern. Теперь соберём всё вместе и построим полный, production-ready контракт TodoRepository для нашего сквозного проекта.

Этот модуль — мост между теорией и реализацией. Мы определим контракт, который:

  • Покрывает все потребности домена Todo
  • Использует доменные типы из модулей 10–15
  • Готов к реализации через InMemory и SQLite адаптеры (модули 25–26)
  • Типобезопасен и проверяем через контрактные тесты

Доменные типы Todo (напоминание)

Прежде чем определить Repository, вспомним доменную модель:

import { Schema, Option, Data } from "effect"

// ═══════════════════════════════════════
// Value Objects
// ═══════════════════════════════════════

const TodoId = Schema.String.pipe(
  Schema.pattern(/^todo_[a-zA-Z0-9]{12,}$/),
  Schema.brand("TodoId"),
)
type TodoId = typeof TodoId.Type

const TodoTitle = Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(200),
  Schema.trim,
  Schema.brand("TodoTitle"),
)
type TodoTitle = typeof TodoTitle.Type

const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type

const Status = Schema.Literal("active", "completed", "archived")
type Status = typeof Status.Type

const Tag = Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(50),
  Schema.brand("Tag"),
)
type Tag = typeof Tag.Type

// ═══════════════════════════════════════
// Entity (Aggregate Root)
// ═══════════════════════════════════════

class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  description: Schema.OptionFromSelf(Schema.String),
  priority: Priority,
  status: Status,
  tags: Schema.Array(Tag),
  dueDate: Schema.OptionFromSelf(Schema.DateFromSelf),
  createdAt: Schema.DateFromSelf,
  updatedAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromSelf(Schema.DateFromSelf),
  ownerId: Schema.OptionFromSelf(Schema.String.pipe(Schema.brand("UserId"))),
}) {
  complete(): Todo {
    return new Todo({
      ...this,
      status: "completed" as Status,
      completedAt: Option.some(new Date()),
      updatedAt: new Date(),
    })
  }
  
  archive(): Todo {
    return new Todo({
      ...this,
      status: "archived" as Status,
      updatedAt: new Date(),
    })
  }
  
  changeTitle(newTitle: TodoTitle): Todo {
    return new Todo({
      ...this,
      title: newTitle,
      updatedAt: new Date(),
    })
  }
  
  changePriority(newPriority: Priority): Todo {
    return new Todo({
      ...this,
      priority: newPriority,
      updatedAt: new Date(),
    })
  }
}

Ошибки TodoRepository

Определяем типизированные ошибки для Repository:

import { Schema } 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,
    message: Schema.String,
  }
) {}

/** Ошибка уникальности */
class DuplicateTodoError extends Schema.TaggedError<DuplicateTodoError>()(
  "DuplicateTodoError",
  {
    title: TodoTitle,
    message: Schema.String,
  }
) {}

/** Union всех ошибок Repository */
type TodoRepositoryError = RepositoryError | ConcurrencyError | DuplicateTodoError

Полный контракт TodoRepository

Определение Shape

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

// ═══════════════════════════════════════
// Filter для поиска
// ═══════════════════════════════════════

interface TodoFilter {
  readonly status?: Status
  readonly priority?: Priority
  readonly ownerId?: string
  readonly tag?: Tag
  readonly search?: string
  readonly dueBefore?: Date
  readonly dueAfter?: Date
}

interface TodoSortField {
  readonly field: "createdAt" | "updatedAt" | "priority" | "dueDate" | "title"
  readonly direction: "asc" | "desc"
}

interface TodoQueryOptions {
  readonly filter?: TodoFilter
  readonly sort?: ReadonlyArray<TodoSortField>
  readonly offset?: number
  readonly limit?: number
}

// ═══════════════════════════════════════
// Shape контракта
// ═══════════════════════════════════════

interface TodoRepositoryShape {
  // ─── Базовые CRUD ─────────────────────

  /**
   * Сохранить Todo (upsert).
   * Если Todo с таким id не существует — создаёт.
   * Если существует — обновляет все поля.
   * Атомарно: Todo сохраняется целиком или не сохраняется.
   */
  readonly save: (todo: Todo) => Effect.Effect<void, TodoRepositoryError>

  /**
   * Найти Todo по идентификатору.
   * Возвращает Option.none() если не найден.
   * Возвращённый Todo полностью валиден (все инварианты соблюдены).
   */
  readonly findById: (
    id: TodoId
  ) => Effect.Effect<Option.Option<Todo>, RepositoryError>

  /**
   * Удалить Todo по идентификатору.
   * Идемпотентно: повторное удаление не вызывает ошибку.
   */
  readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>

  // ─── Поиск ────────────────────────────

  /**
   * Найти Todo по критериям с сортировкой и пагинацией.
   * Пустой filter — вернуть все записи.
   */
  readonly findMany: (
    options: TodoQueryOptions
  ) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

  /**
   * Подсчитать количество Todo по фильтру.
   * Пустой filter — общее количество.
   */
  readonly count: (
    filter?: TodoFilter
  ) => Effect.Effect<number, RepositoryError>

  /**
   * Проверить существование Todo по id.
   * Быстрее findById — не загружает весь агрегат.
   */
  readonly exists: (id: TodoId) => Effect.Effect<boolean, RepositoryError>

  // ─── Доменные запросы ─────────────────

  /**
   * Найти все активные Todo (status = "active").
   * Удобный метод — эквивалент findMany({ filter: { status: "active" } }).
   */
  readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

  /**
   * Найти просроченные Todo.
   * Активные задачи с dueDate раньше asOf.
   */
  readonly findOverdue: (
    asOf: Date
  ) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

  /**
   * Найти Todo по приоритету.
   */
  readonly findByPriority: (
    priority: Priority
  ) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>

  // ─── Batch-операции ───────────────────

  /**
   * Сохранить несколько Todo атомарно.
   * Все сохраняются или ни один.
   */
  readonly saveAll: (
    todos: ReadonlyArray<Todo>
  ) => Effect.Effect<void, TodoRepositoryError>

  /**
   * Удалить несколько Todo атомарно.
   */
  readonly deleteAll: (
    ids: ReadonlyArray<TodoId>
  ) => Effect.Effect<void, RepositoryError>
}

Определение Tag (Port)

// ═══════════════════════════════════════
// Port
// ═══════════════════════════════════════

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

export {
  TodoRepository,
  type TodoRepositoryShape,
  type TodoFilter,
  type TodoSortField,
  type TodoQueryOptions,
  RepositoryError,
  ConcurrencyError,
  DuplicateTodoError,
  type TodoRepositoryError,
}

InMemory-адаптер: полная реализация

InMemory-адаптер — первая реализация порта. Используется для тестов и прототипирования:

import { Layer, Effect, Option, pipe, Array as Arr } from "effect"

// ═══════════════════════════════════════
// InMemory Adapter
// ═══════════════════════════════════════

const TodoRepositoryInMemory: Layer.Layer<TodoRepository> = Layer.sync(
  TodoRepository,
  () => {
    // Mutable store — приватное состояние адаптера
    const store = new Map<string, Todo>()

    // ── Вспомогательные функции ──

    const matchesFilter = (todo: Todo, filter: TodoFilter): boolean => {
      if (filter.status !== undefined && todo.status !== filter.status) return false
      if (filter.priority !== undefined && todo.priority !== filter.priority) return false
      if (filter.ownerId !== undefined && !pipe(todo.ownerId, Option.contains(filter.ownerId))) return false
      if (filter.tag !== undefined && !todo.tags.includes(filter.tag)) return false
      if (filter.search !== undefined) {
        const q = filter.search.toLowerCase()
        const titleMatch = todo.title.toLowerCase().includes(q)
        const descMatch = pipe(
          todo.description,
          Option.map((d) => d.toLowerCase().includes(q)),
          Option.getOrElse(() => false),
        )
        if (!titleMatch && !descMatch) return false
      }
      if (filter.dueBefore !== undefined) {
        const due = Option.getOrUndefined(todo.dueDate)
        if (!due || due >= filter.dueBefore) return false
      }
      if (filter.dueAfter !== undefined) {
        const due = Option.getOrUndefined(todo.dueDate)
        if (!due || due <= filter.dueAfter) return false
      }
      return true
    }

    const compareTodos = (
      a: Todo,
      b: Todo,
      sort: ReadonlyArray<TodoSortField>,
    ): number => {
      for (const { field, direction } of sort) {
        let cmp = 0
        switch (field) {
          case "title":
            cmp = a.title.localeCompare(b.title)
            break
          case "priority": {
            const order = { low: 0, medium: 1, high: 2, critical: 3 } as const
            cmp = order[a.priority] - order[b.priority]
            break
          }
          case "createdAt":
            cmp = a.createdAt.getTime() - b.createdAt.getTime()
            break
          case "updatedAt":
            cmp = a.updatedAt.getTime() - b.updatedAt.getTime()
            break
          case "dueDate": {
            const aDate = Option.getOrUndefined(a.dueDate)
            const bDate = Option.getOrUndefined(b.dueDate)
            if (!aDate && !bDate) cmp = 0
            else if (!aDate) cmp = 1
            else if (!bDate) cmp = -1
            else cmp = aDate.getTime() - bDate.getTime()
            break
          }
        }
        if (cmp !== 0) return direction === "desc" ? -cmp : cmp
      }
      return 0
    }

    // ── Реализация контракта ──

    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)
        }),

      findMany: (options) =>
        Effect.sync(() => {
          let result = Array.from(store.values())

          // Filter
          if (options.filter) {
            result = result.filter((t) => matchesFilter(t, options.filter!))
          }

          // Sort
          if (options.sort && options.sort.length > 0) {
            result = result.slice().sort((a, b) =>
              compareTodos(a, b, options.sort!)
            )
          }

          // Pagination
          const offset = options.offset ?? 0
          const limit = options.limit ?? result.length
          result = result.slice(offset, offset + limit)

          return result as ReadonlyArray<Todo>
        }),

      count: (filter) =>
        Effect.sync(() => {
          if (!filter) return store.size
          return Array.from(store.values()).filter((t) =>
            matchesFilter(t, filter)
          ).length
        }),

      exists: (id) => Effect.sync(() => store.has(id)),

      findActive: () =>
        Effect.sync(() =>
          Array.from(store.values()).filter(
            (t) => t.status === "active",
          ) as ReadonlyArray<Todo>,
        ),

      findOverdue: (asOf) =>
        Effect.sync(() =>
          Array.from(store.values()).filter((t) => {
            if (t.status !== "active") return false
            const due = Option.getOrUndefined(t.dueDate)
            return due !== undefined && due < asOf
          }) as ReadonlyArray<Todo>,
        ),

      findByPriority: (priority) =>
        Effect.sync(() =>
          Array.from(store.values()).filter(
            (t) => t.priority === priority,
          ) as ReadonlyArray<Todo>,
        ),

      saveAll: (todos) =>
        Effect.sync(() => {
          for (const todo of todos) {
            store.set(todo.id, todo)
          }
        }),

      deleteAll: (ids) =>
        Effect.sync(() => {
          for (const id of ids) {
            store.delete(id)
          }
        }),
    } satisfies TodoRepositoryShape
  },
)

export { TodoRepositoryInMemory }

Использование в Application Layer

Use Case: CreateTodo

import { Effect, pipe, Option } from "effect"

const createTodo = (input: {
  readonly title: string
  readonly priority: Priority
  readonly description?: string
  readonly dueDate?: Date
  readonly tags?: ReadonlyArray<string>
}) =>
  Effect.gen(function* () {
    // 1. Валидация
    const title = yield* Schema.decode(TodoTitle)(input.title)
    const tags = yield* Effect.all(
      (input.tags ?? []).map((t) => Schema.decode(Tag)(t))
    )
    
    // 2. Создание агрегата
    const now = new Date()
    const todo = new Todo({
      id: TodoId.make(`todo_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`),
      title,
      description: Option.fromNullable(input.description),
      priority: input.priority,
      status: "active" as Status,
      tags,
      dueDate: Option.fromNullable(input.dueDate),
      createdAt: now,
      updatedAt: now,
      completedAt: Option.none(),
      ownerId: Option.none(),
    })
    
    // 3. Сохранение через порт
    const repo = yield* TodoRepository
    yield* repo.save(todo)
    
    return todo
  })

Use Case: CompleteTodo

const completeTodo = (id: TodoId) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    
    // 1. Найти Todo
    const maybeTodo = yield* repo.findById(id)
    const todo = yield* pipe(
      maybeTodo,
      Option.match({
        onNone: () => Effect.fail(new TodoNotFound({ id })),
        onSome: Effect.succeed,
      })
    )
    
    // 2. Бизнес-логика (в домене)
    if (todo.status !== "active") {
      return yield* Effect.fail(
        new InvalidTodoTransition({ from: todo.status, to: "completed" })
      )
    }
    const completed = todo.complete()
    
    // 3. Сохранить
    yield* repo.save(completed)
    
    return completed
  })

Use Case: ListTodos с фильтрацией

interface ListTodosInput {
  readonly status?: Status
  readonly priority?: Priority
  readonly search?: string
  readonly page?: number
  readonly pageSize?: number
  readonly sortBy?: "createdAt" | "priority" | "dueDate"
  readonly sortDir?: "asc" | "desc"
}

interface ListTodosOutput {
  readonly items: ReadonlyArray<Todo>
  readonly total: number
  readonly page: number
  readonly pageSize: number
  readonly totalPages: number
}

const listTodos = (input: ListTodosInput) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    
    const page = input.page ?? 1
    const pageSize = input.pageSize ?? 20
    const offset = (page - 1) * pageSize
    
    const filter: TodoFilter = {
      ...(input.status && { status: input.status }),
      ...(input.priority && { priority: input.priority }),
      ...(input.search && { search: input.search }),
    }
    
    const sort: ReadonlyArray<TodoSortField> = input.sortBy
      ? [{ field: input.sortBy, direction: input.sortDir ?? "desc" }]
      : [{ field: "createdAt", direction: "desc" }]
    
    const [items, total] = yield* Effect.all([
      repo.findMany({ filter, sort, offset, limit: pageSize }),
      repo.count(filter),
    ])
    
    return {
      items,
      total,
      page,
      pageSize,
      totalPages: Math.ceil(total / pageSize),
    } satisfies ListTodosOutput
  })

Контрактные тесты

Каждый адаптер должен пройти набор контрактных тестов:

import { describe, it, expect } from "bun:test"
import { Effect, Option, pipe } from "effect"

/**
 * Контрактные тесты — запускаются для КАЖДОГО адаптера.
 * Гарантируют, что адаптер корректно реализует контракт.
 */
const todoRepositoryContractTests = (
  name: string,
  makeLayer: () => Layer.Layer<TodoRepository>,
) => {
  const run = <A, E>(
    effect: Effect.Effect<A, E, TodoRepository>,
  ) =>
    Effect.runPromise(
      pipe(effect, Effect.provide(makeLayer()))
    )

  const makeTodo = (overrides?: Partial<{
    id: string
    title: string
    priority: Priority
    status: Status
  }>): Todo =>
    new Todo({
      id: TodoId.make(overrides?.id ?? `todo_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`),
      title: TodoTitle.make(overrides?.title ?? "Test Todo"),
      description: Option.none(),
      priority: (overrides?.priority ?? "medium") as Priority,
      status: (overrides?.status ?? "active") as Status,
      tags: [],
      dueDate: Option.none(),
      createdAt: new Date(),
      updatedAt: new Date(),
      completedAt: Option.none(),
      ownerId: Option.none(),
    })

  describe(`TodoRepository Contract: ${name}`, () => {
    // ── save + findById roundtrip ──
    it("save then findById returns the saved Todo", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          const todo = makeTodo({ title: "Roundtrip test" })
          
          yield* repo.save(todo)
          const found = yield* repo.findById(todo.id)
          
          expect(Option.isSome(found)).toBe(true)
          expect(Option.getOrThrow(found).id).toBe(todo.id)
          expect(Option.getOrThrow(found).title).toBe(todo.title)
        })
      ))

    // ── findById for nonexistent ──
    it("findById returns None for nonexistent id", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          const found = yield* repo.findById(TodoId.make("todo_nonexistent1"))
          expect(Option.isNone(found)).toBe(true)
        })
      ))

    // ── save upsert semantics ──
    it("save updates existing Todo (upsert)", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          const todo = makeTodo({ title: "Original" })
          
          yield* repo.save(todo)
          
          const updated = todo.changeTitle(TodoTitle.make("Updated"))
          yield* repo.save(updated)
          
          const found = yield* repo.findById(todo.id)
          expect(Option.getOrThrow(found).title).toBe("Updated")
          
          // Убедимся, что дубликата нет
          const total = yield* repo.count()
          expect(total).toBe(1)
        })
      ))

    // ── delete ──
    it("delete removes Todo, findById returns None", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          const todo = makeTodo()
          
          yield* repo.save(todo)
          yield* repo.delete(todo.id)
          
          const found = yield* repo.findById(todo.id)
          expect(Option.isNone(found)).toBe(true)
        })
      ))

    // ── delete idempotent ──
    it("delete is idempotent for nonexistent id", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          // Не должно бросать ошибку
          yield* repo.delete(TodoId.make("todo_nonexistent2"))
        })
      ))

    // ── findMany with filter ──
    it("findMany filters by status", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          
          yield* repo.save(makeTodo({ id: "todo_aaaaaaaaaaaa", status: "active" }))
          yield* repo.save(makeTodo({ id: "todo_bbbbbbbbbbbb", status: "completed" }))
          yield* repo.save(makeTodo({ id: "todo_cccccccccccc", status: "active" }))
          
          const active = yield* repo.findMany({ filter: { status: "active" } })
          expect(active.length).toBe(2)
          
          const completed = yield* repo.findMany({ filter: { status: "completed" } })
          expect(completed.length).toBe(1)
        })
      ))

    // ── findMany with pagination ──
    it("findMany supports offset and limit", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          
          for (let i = 0; i < 10; i++) {
            yield* repo.save(
              makeTodo({ id: `todo_page${String(i).padStart(8, "0")}` })
            )
          }
          
          const page1 = yield* repo.findMany({ offset: 0, limit: 3 })
          expect(page1.length).toBe(3)
          
          const page2 = yield* repo.findMany({ offset: 3, limit: 3 })
          expect(page2.length).toBe(3)
        })
      ))

    // ── count ──
    it("count returns correct number", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository

          expect(yield* repo.count()).toBe(0)

          yield* repo.save(makeTodo({ id: "todo_cnt000000001" }))
          yield* repo.save(makeTodo({ id: "todo_cnt000000002" }))

          expect(yield* repo.count()).toBe(2)
        })
      ))

    // ── count with filter ──
    it("count with filter", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository

          yield* repo.save(makeTodo({ id: "todo_cf0000000001", priority: "high" }))
          yield* repo.save(makeTodo({ id: "todo_cf0000000002", priority: "low" }))
          yield* repo.save(makeTodo({ id: "todo_cf0000000003", priority: "high" }))

          expect(yield* repo.count({ priority: "high" })).toBe(2)
          expect(yield* repo.count({ priority: "low" })).toBe(1)
        })
      ))

    // ── exists ──
    it("exists returns true for saved Todo, false for missing", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          const todo = makeTodo()
          
          expect(yield* repo.exists(todo.id)).toBe(false)
          yield* repo.save(todo)
          expect(yield* repo.exists(todo.id)).toBe(true)
        })
      ))

    // ── saveAll ──
    it("saveAll saves multiple todos atomically", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          const todos = [
            makeTodo({ id: "todo_batch0000001" }),
            makeTodo({ id: "todo_batch0000002" }),
            makeTodo({ id: "todo_batch0000003" }),
          ]
          
          yield* repo.saveAll(todos)
          expect(yield* repo.count()).toBe(3)
        })
      ))

    // ── deleteAll ──
    it("deleteAll removes multiple todos", () =>
      run(
        Effect.gen(function* () {
          const repo = yield* TodoRepository
          const ids = [
            TodoId.make("todo_del000000001"),
            TodoId.make("todo_del000000002"),
          ]
          
          yield* repo.saveAll([
            makeTodo({ id: "todo_del000000001" }),
            makeTodo({ id: "todo_del000000002" }),
            makeTodo({ id: "todo_del000000003" }),
          ])
          
          yield* repo.deleteAll(ids)
          expect(yield* repo.count()).toBe(1)
        })
      ))
  })
}

// ═══════════════════════════════════════
// Запуск для каждого адаптера
// ═══════════════════════════════════════

todoRepositoryContractTests("InMemory", () => TodoRepositoryInMemory)
// todoRepositoryContractTests("SQLite", () => TodoRepositorySqlite)

Структура файлов

src/
├── domain/
│   ├── model/
│   │   ├── Todo.ts              ← Aggregate Root
│   │   ├── TodoId.ts            ← Value Object
│   │   ├── TodoTitle.ts         ← Value Object
│   │   ├── Priority.ts          ← Value Object
│   │   └── Status.ts            ← Value Object
│   │
│   ├── ports/
│   │   └── TodoRepository.ts    ← PORT (Tag + Shape + Filter + Errors)
│   │
│   └── errors/
│       ├── TodoNotFound.ts
│       ├── InvalidTodoTransition.ts
│       └── RepositoryError.ts

├── infrastructure/
│   └── adapters/
│       ├── TodoRepositoryInMemory.ts   ← InMemory Adapter
│       └── TodoRepositorySqlite.ts     ← SQLite Adapter (модуль 25)

├── application/
│   └── use-cases/
│       ├── CreateTodo.ts
│       ├── CompleteTodo.ts
│       └── ListTodos.ts

└── tests/
    └── contracts/
        └── TodoRepositoryContract.test.ts  ← Контрактные тесты

Утилиты поверх Repository

Полезные утилиты, построенные поверх контракта:

// ═══════════════════════════════════════
// Утилиты (не часть контракта, но удобны)
// ═══════════════════════════════════════

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

/** Обновить Todo по Id с функцией трансформации */
const updateTodo = (
  id: TodoId,
  updater: (todo: Todo) => Effect.Effect<Todo, TodoRepositoryError>,
) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const todo = yield* getTodoOrFail(id)
    const updated = yield* updater(todo)
    yield* repo.save(updated)
    return updated
  })

// Использование
const renameTodo = (id: TodoId, newTitle: TodoTitle) =>
  updateTodo(id, (todo) =>
    Effect.succeed(todo.changeTitle(newTitle))
  )

Итоги

  1. TodoRepository — полный Driven Port для Todo-агрегата с базовыми CRUD + доменные запросы + batch-операции
  2. TodoFilter — типизированный объект фильтрации без утечки SQL
  3. TodoQueryOptions — пагинация и сортировка как часть контракта
  4. ОшибкиRepositoryError, ConcurrencyError, DuplicateTodoError как typed unions
  5. InMemory-адаптер — полная реализация для тестов и прототипирования
  6. Контрактные тесты — набор тестов, которые должен пройти ЛЮБОЙ адаптер
  7. Утилиты (getTodoOrFail, updateTodo) — удобные функции поверх контракта
  8. Структура файлов — порт в domain/ports/, адаптер в infrastructure/adapters/