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