Repository: коллекция агрегатов в памяти (абстракция)
Объяснение репозитория как абстракции коллекции агрегатов. Ментальная модель Map<Id, Aggregate>. Отличие от DAO, Active Record. Ключевые характеристики: один Repository = один Aggregate Root, upsert-семантика save, Option для findById, идемпотентный delete. Правила проектирования и место Repository в жизненном цикле агрегата.
Введение: зачем нужен Repository
В предыдущих модулях мы построили доменную модель — Entity, Value Object, Aggregate, Domain Event. Эта модель живёт в чистом мире: нулевые зависимости от инфраструктуры, типобезопасность через Effect Schema, бизнес-правила через инварианты. Но рано или поздно агрегат нужно сохранить — и потом восстановить. Именно здесь появляется Repository.
Repository — один из самых важных паттернов в Domain-Driven Design. Это не просто “обёртка над базой данных”. Это абстракция коллекции, которая позволяет домену работать с агрегатами так, как будто они все находятся в памяти. Домен говорит: “дай мне Todo с таким-то идентификатором” или “сохрани этот Todo” — и не знает ни про SQL, ни про файловую систему, ни про HTTP-запросы к удалённому сервису.
┌─────────────────────────────────────────────────┐
│ ДОМЕН │
│ │
│ TodoAggregate TodoAggregate TodoAggregate │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ TodoRepository │ ← Порт │
│ │ (абстракция) │ │
│ └─────────┬──────────┘ │
│ │ │
└────────────────────────┼─────────────────────────┘
│
│ Граница домена
─────────────────────────┼─────────────────────────
│
┌──────────▼─────────┐
│ SQLite / InMemory │ ← Адаптер
│ (реализация) │
└────────────────────┘
Историческая перспектива: откуда пришёл Repository
Мартин Фаулер — Patterns of Enterprise Application Architecture (2002)
Фаулер описал Repository как объект, который медиирует между доменным слоем и слоем данных, используя интерфейс, похожий на коллекцию, для доступа к доменным объектам. Ключевое слово — коллекция. Не “DAO”, не “data access layer”, а именно коллекция.
Эрик Эванс — Domain-Driven Design (2003)
Эванс развил идею и чётко определил место Repository в тактическом дизайне DDD:
“Для каждого типа объекта, который нуждается в глобальном доступе, создайте объект, который может обеспечить иллюзию коллекции всех объектов этого типа в памяти.”
Два ключевых слова здесь — иллюзия и в памяти. Repository не является настоящей коллекцией в памяти, но он ведёт себя как она.
Вон Вернон — Implementing Domain-Driven Design (2013)
Вернон конкретизировал правила: один Repository на один Aggregate Root. Не на Entity, не на Value Object — именно на Aggregate Root. Это гарантирует, что агрегат сохраняется и восстанавливается как единое целое, с соблюдением всех инвариантов.
Ментальная модель: “In-Memory Collection”
Самая частая ошибка при работе с Repository — думать о нём как об обёртке над SQL. Правильная ментальная модель:
Repository = Map<Id, Aggregate>
Представьте, что у вас есть Map<TodoId, Todo> в памяти приложения:
// Ментальная модель — НЕ реальная реализация
const todos: Map<TodoId, Todo> = new Map()
// Добавить
todos.set(todo.id, todo)
// Найти
const found = todos.get(todoId)
// Удалить
todos.delete(todoId)
// Все элементы
const all = Array.from(todos.values())
Repository предоставляет точно такой же интерфейс, но за кулисами может обращаться к любому хранилищу данных. Домен не знает и не должен знать, что находится “за кулисами”.
Почему именно коллекция, а не DAO?
| Характеристика | Repository | DAO (Data Access Object) |
|---|---|---|
| Мыслит в терминах | Доменных объектов | Записей в таблице |
| Возвращает | Агрегаты с инвариантами | DTO / raw data |
| Язык | Ubiquitous Language | SQL / технический |
| Принадлежит | Доменному слою (контракт) | Инфраструктурному слою |
| Гранулярность | Aggregate Root | Таблица / запрос |
| Семантика | .save() = добавить или обновить | .insert() / .update() |
DAO мыслит строками таблицы — INSERT, UPDATE, SELECT. Repository мыслит агрегатами — сохранить, найти, удалить. Это принципиальная разница в уровне абстракции.
// ❌ DAO-мышление — утечка инфраструктуры
interface TodoDAO {
insertRow(row: TodoRow): Promise<void>
updateRow(id: string, fields: Partial<TodoRow>): Promise<void>
selectById(id: string): Promise<TodoRow | null>
selectWhere(clause: string, params: unknown[]): Promise<ReadonlyArray<TodoRow>>
}
// ✅ Repository-мышление — коллекция агрегатов
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, TodoRepositoryError>
findById(id: TodoId): Effect.Effect<Option<Todo>, TodoRepositoryError>
delete(id: TodoId): Effect.Effect<void, TodoRepositoryError>
findAll(): Effect.Effect<ReadonlyArray<Todo>, TodoRepositoryError>
}
Ключевые характеристики Repository
1. Одна единица — Aggregate Root
Repository оперирует только Aggregate Root. Не отдельными Entity, не Value Object — только агрегатами целиком. Это фундаментальное правило:
// ✅ Правильно: Repository для Aggregate Root
// Todo — это Aggregate Root
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
}
// ❌ Неправильно: Repository для вложенной Entity
// Subtask — часть агрегата Todo, не самостоятельный Aggregate Root
interface SubtaskRepository {
save(subtask: Subtask): Effect.Effect<void, RepositoryError>
}
// ❌ Неправильно: Repository для Value Object
// Priority — значение без идентичности
interface PriorityRepository {
save(priority: Priority): Effect.Effect<void, RepositoryError>
}
Если вам нужно сохранить Subtask — вы сохраняете весь агрегат Todo, в котором живёт этот Subtask.
2. Агрегат сохраняется и восстанавливается целиком
При вызове save(todo) — сохраняется весь агрегат со всеми вложенными Entity и Value Object. При findById(id) — возвращается полностью восстановленный агрегат, готовый к работе:
// Агрегат с вложенными объектами
const todo = Todo.make({
id: TodoId.make("todo-1"),
title: TodoTitle.make("Написать статью"),
priority: Priority.High,
subtasks: [
Subtask.make({ id: SubtaskId.make("st-1"), title: "Outline", done: true }),
Subtask.make({ id: SubtaskId.make("st-2"), title: "Draft", done: false }),
],
tags: HashSet.make(Tag.make("writing"), Tag.make("urgent")),
})
// save сохраняет ВСЁ: Todo + Subtasks + Tags
yield* _(todoRepository.save(todo))
// findById восстанавливает ВСЁ как единое целое
const restored = yield* _(todoRepository.findById(todo.id))
// restored содержит Todo + Subtasks + Tags с валидными инвариантами
3. Семантика save: upsert, не insert/update
Repository использует семантику save (или persist) — это upsert. Если агрегата с таким Id не существует — создаётся новый. Если существует — обновляется. Клиент не должен решать, insert это или update:
// ✅ Правильно: одна операция save
const todo = Todo.create({ title: "New task" })
yield* _(repo.save(todo)) // insert
const updated = Todo.complete(todo)
yield* _(repo.save(updated)) // update
// ❌ Неправильно: клиент решает insert/update
yield* _(repo.insert(todo)) // Утечка хранилища
yield* _(repo.update(todo)) // Клиент вынужден отслеживать состояние
Почему это важно? Потому что Repository — это коллекция. Когда вы работаете с Map<K, V>, вы вызываете map.set(key, value) и не думаете, новый это элемент или обновление существующего. Repository работает так же.
4. Идентичность через Id, не через ссылку
Repository идентифицирует агрегаты по доменному идентификатору, а не по ссылке в памяти:
// Два разных объекта в памяти...
const todo1 = yield* _(repo.findById(todoId))
const todo2 = yield* _(repo.findById(todoId))
// ...представляют один и тот же агрегат
// todo1 !== todo2 (разные ссылки)
// но TodoId.equals(todo1.id, todo2.id) === true (один агрегат)
5. Возврат через Option, не через null
В функциональном стиле Repository возвращает Option<Aggregate> при поиске по Id — это явно показывает, что агрегат может не существовать:
// ✅ Правильно: Option для операции, которая может не найти результат
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
// ❌ Неправильно: null / undefined
findById(id: TodoId): Effect.Effect<Todo | null, RepositoryError>
// Использование:
const maybeTodo = yield* _(repo.findById(todoId))
const todo = pipe(
maybeTodo,
Option.getOrElse(() => {
// Решение на уровне вызывающего кода:
// бросить ошибку? вернуть значение по умолчанию? создать новый?
})
)
Repository в Hexagonal Architecture: Driven Port
В контексте гексагональной архитектуры Repository — это Driven Port (Secondary Port). Вспомним классификацию:
- Driving (Primary) Ports — как внешний мир обращается к приложению (HTTP API, CLI, GUI)
- Driven (Secondary) Ports — что приложение нужно от внешнего мира (БД, файлы, email)
Repository — классический Driven Port, потому что:
- Определяется доменом — контракт описан на языке домена
- Реализуется инфраструктурой — SQLite, PostgreSQL, InMemory
- Направление зависимости внутрь — домен определяет интерфейс, инфраструктура реализует
┌─────────────────────────────┐
HTTP Request │ APPLICATION │
──────────────► │ │ ┌───────────┐
Driving Port │ UseCase → Domain Logic │ │ │
(Primary) │ │ │ │ SQLite │
│ ▼ │ │ │
│ TodoRepository ───────────┼────►│ Adapter │
│ (Driven Port) │ │ │
│ │ └───────────┘
└─────────────────────────────┘
Стрелка зависимости: SQLite Adapter зависит от TodoRepository (порта),
а не наоборот. Домен НЕ зависит от SQLite.
Соответствие Effect-ts и Hexagonal Architecture
| Hexagonal | Effect-ts | Пример |
|---|---|---|
| Driven Port | Context.Tag + interface | TodoRepository сервис |
| Adapter | Layer | TodoRepositorySqlite |
| Dependency Rule | R-канал Effect<A, E, R> | R = TodoRepository |
| Adapter Wiring | Effect.provide(layer) | pipe(program, Effect.provide(sqliteLayer)) |
Чего Repository НЕ делает
Чтобы правильно использовать паттерн, важно понимать его границы:
Repository НЕ содержит бизнес-логику
// ❌ Бизнес-логика внутри Repository
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
completeTodoAndNotify(id: TodoId): Effect.Effect<void, RepositoryError> // НЕТ!
getOverdueTodosAndSendReminders(): Effect.Effect<void, RepositoryError> // НЕТ!
}
// ✅ Repository только хранит/извлекает
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
findOverdue(now: Date): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
Repository НЕ знает о транспорте
// ❌ HTTP-специфичные типы
interface TodoRepository {
save(todo: Todo): Effect.Effect<HttpResponse, RepositoryError>
findById(id: TodoId, headers: HttpHeaders): Effect.Effect<Todo, RepositoryError>
}
// ✅ Только доменные типы
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
}
Repository НЕ управляет транзакциями на уровне контракта
Транзакции — это инфраструктурный концепт. Контракт Repository говорит о семантике (save сохраняет атомарно), а не о механизме (BEGIN TRANSACTION … COMMIT):
// ❌ Транзакции в контракте порта
interface TodoRepository {
beginTransaction(): Effect.Effect<Transaction, RepositoryError>
saveInTransaction(tx: Transaction, todo: Todo): Effect.Effect<void, RepositoryError>
commitTransaction(tx: Transaction): Effect.Effect<void, RepositoryError>
}
// ✅ Атомарность как семантика
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
// Адаптер внутри решает, как обеспечить атомарность
}
Repository НЕ возвращает DTO
Repository возвращает полностью валидные агрегаты — не DTO, не “сырые данные”:
// ❌ Возврат DTO
interface TodoRepository {
findById(id: string): Effect.Effect<{
id: string
title: string
status: string
created_at: string
}, RepositoryError>
}
// ✅ Возврат агрегата
interface TodoRepository {
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
// Todo — полностью валидный агрегат с инвариантами
}
Repository vs другие паттерны доступа к данным
Repository vs DAO (Data Access Object)
DAO оперирует на уровне таблиц базы данных. Repository оперирует на уровне агрегатов домена. Агрегат может маппиться на несколько таблиц:
Todo Aggregate → todos table
├── TodoId (VO) id column
├── TodoTitle (VO) title column
├── Priority (VO) priority column
├── Status (VO) status column
├── Subtasks (Entity[]) → subtasks table (1:N)
└── Tags (VO[]) → todo_tags table (N:M)
DAO потребовал бы три отдельных объекта (TodoDAO, SubtaskDAO, TodoTagDAO). Repository — один TodoRepository, который атомарно сохраняет/восстанавливает всю структуру.
Repository vs Active Record
Active Record смешивает доменный объект с механизмом персистентности:
// Active Record — доменный объект ЗНАЕТ про БД
class Todo extends ActiveRecord {
title: string
async save() { await db.query("INSERT INTO todos...") }
static async find(id: string) { return db.query("SELECT...") }
}
// Repository — доменный объект ЧИСТ
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: TodoTitle,
// ...
}) {
complete() { /* чистая бизнес-логика */ }
}
// Персистентность отделена
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
}
Repository vs Query Object / CQRS Read Side
Repository предназначен для write-операций и простого поиска по Id. Для сложных запросов, аналитики, фильтрации с пагинацией правильнее использовать отдельные Query-объекты (об этом подробно в модулях по CQRS):
// Repository — для записи и простого чтения
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
}
// Query — для сложного чтения (CQRS Read Side, будет в модулях 34–37)
interface TodoQueryService {
list(filter: TodoFilter, page: Pagination): Effect.Effect<PagedResult<TodoView>>
statistics(): Effect.Effect<TodoStats>
search(query: string): Effect.Effect<ReadonlyArray<TodoSummary>>
}
Правила проектирования Repository
Правило 1: Один Repository = один Aggregate Root
Каждый Aggregate Root имеет ровно один Repository. Если у вас два агрегата — Todo и User — у вас два Repository:
// Один Repository для каждого Aggregate Root
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>
}
>() {}
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly save: (user: User) => Effect.Effect<void, RepositoryError>
readonly findById: (id: UserId) => Effect.Effect<Option.Option<User>, RepositoryError>
}
>() {}
Правило 2: Контракт описывается доменным языком
Методы Repository используют доменные типы и термины Ubiquitous Language:
// ✅ Доменный язык
interface TodoRepository {
findOverdue(asOf: Date): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
findByPriority(priority: Priority): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
countActive(): Effect.Effect<number, RepositoryError>
}
// ❌ Технический язык
interface TodoRepository {
selectWhereDeadlineLessThan(date: string): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
queryByField(field: string, value: unknown): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
Правило 3: Минимальный контракт
Определяйте только те методы, которые реально нужны домену. Не создавайте “на всякий случай”:
// ❌ Раздутый контракт — методы "на всякий случай"
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
findAll(): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
findByTitle(title: string): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
findByStatus(status: Status): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
findByPriority(priority: Priority): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
findByDateRange(from: Date, to: Date): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
findByTags(tags: ReadonlyArray<Tag>): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
count(): Effect.Effect<number, RepositoryError>
countByStatus(status: Status): Effect.Effect<number, RepositoryError>
existsById(id: TodoId): Effect.Effect<boolean, RepositoryError>
deleteAll(): Effect.Effect<void, RepositoryError>
// ... 20 методов, половина не используется
}
// ✅ Минимальный контракт — только то, что нужно сейчас
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
findById(id: TodoId): Effect.Effect<Option.Option<Todo>, RepositoryError>
delete(id: TodoId): Effect.Effect<void, RepositoryError>
findAll(): Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
// Расширяем по мере появления реальных потребностей
Правило 4: Ошибки — часть контракта
Repository определяет свои типизированные ошибки:
import { Schema } from "effect"
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
"RepositoryError",
{
message: Schema.String,
cause: Schema.optional(Schema.Unknown),
}
) {}
// Ошибки возвращаются в E-канале Effect
interface TodoRepository {
save(todo: Todo): Effect.Effect<void, RepositoryError>
// ^^^^^^^^^^^^^^^^
// Типизированная ошибка — часть контракта
}
Repository в контексте жизненного цикла агрегата
Repository участвует на каждом этапе жизненного цикла агрегата:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ CREATE │────►│ UPDATE │────►│ COMPLETE │────►│ ARCHIVE │
│ │ │ │ │ │ │ │
│ Todo.make │ │ Todo. │ │ Todo. │ │ Todo. │
│ │ │ rename │ │ complete │ │ archive │
└─────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
repo.save() repo.save() repo.save() repo.save()
(insert) (update) (update) (update)
┌──────────┐
│ DELETE │
│ │
└─────┬─────┘
│
▼
repo.delete()
На каждом этапе Repository принимает уже изменённый агрегат и сохраняет его. Repository не знает и не решает, какой переход произошёл — это ответственность домена.
Итоги
- Repository — это абстракция коллекции, а не обёртка над SQL. Мыслите как
Map<Id, Aggregate> - Один Repository = один Aggregate Root. Вложенные Entity и Value Object сохраняются вместе с агрегатом
- Семантика save (upsert): клиент не решает, insert это или update
- Возвращает полные агрегаты, а не DTO. Агрегат после восстановления готов к работе
- Driven Port в Hexagonal: контракт в домене, реализация в инфраструктуре
- Минимальный контракт: только методы, реально нужные домену
- Ошибки типизированы и являются частью контракта через E-канал Effect
- Не содержит бизнес-логику, не знает о транспорте, не управляет транзакциями на уровне контракта
В следующей статье мы покажем, как Repository реализуется как Driven Port через Effect.Service / Context.Tag — и как это обеспечивает Dependency Rule на уровне типов.