Типобезопасный домен: Гексагональная архитектура на базе Effect Repository: коллекция агрегатов в памяти (абстракция)
Глава

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?

ХарактеристикаRepositoryDAO (Data Access Object)
Мыслит в терминахДоменных объектовЗаписей в таблице
ВозвращаетАгрегаты с инвариантамиDTO / raw data
ЯзыкUbiquitous LanguageSQL / технический
ПринадлежитДоменному слою (контракт)Инфраструктурному слою
Гранулярность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, потому что:

  1. Определяется доменом — контракт описан на языке домена
  2. Реализуется инфраструктурой — SQLite, PostgreSQL, InMemory
  3. Направление зависимости внутрь — домен определяет интерфейс, инфраструктура реализует
                    ┌─────────────────────────────┐
   HTTP Request     │        APPLICATION           │
  ──────────────►   │                              │     ┌───────────┐
   Driving Port     │   UseCase → Domain Logic     │     │           │
   (Primary)        │        │                     │     │  SQLite   │
                    │        ▼                     │     │           │
                    │   TodoRepository  ───────────┼────►│  Adapter  │
                    │   (Driven Port)              │     │           │
                    │                              │     └───────────┘
                    └─────────────────────────────┘
                    
   Стрелка зависимости: SQLite Adapter зависит от TodoRepository (порта),
   а не наоборот. Домен НЕ зависит от SQLite.

Соответствие Effect-ts и Hexagonal Architecture

HexagonalEffect-tsПример
Driven PortContext.Tag + interfaceTodoRepository сервис
AdapterLayerTodoRepositorySqlite
Dependency RuleR-канал Effect<A, E, R>R = TodoRepository
Adapter WiringEffect.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 не знает и не решает, какой переход произошёл — это ответственность домена.


Итоги

  1. Repository — это абстракция коллекции, а не обёртка над SQL. Мыслите как Map<Id, Aggregate>
  2. Один Repository = один Aggregate Root. Вложенные Entity и Value Object сохраняются вместе с агрегатом
  3. Семантика save (upsert): клиент не решает, insert это или update
  4. Возвращает полные агрегаты, а не DTO. Агрегат после восстановления готов к работе
  5. Driven Port в Hexagonal: контракт в домене, реализация в инфраструктуре
  6. Минимальный контракт: только методы, реально нужные домену
  7. Ошибки типизированы и являются частью контракта через E-канал Effect
  8. Не содержит бизнес-логику, не знает о транспорте, не управляет транзакциями на уровне контракта

В следующей статье мы покажем, как Repository реализуется как Driven Port через Effect.Service / Context.Tag — и как это обеспечивает Dependency Rule на уровне типов.