Репозиторий (Repository)
Даёт предметной области коллекцию сущностей с методами на её языке и прячет за ней, где и как они хранятся.
Проблема
Сервис заказов принимает оплату: находит заказ, отмечает его оплаченным, записывает обратно. Ещё он считает долг клиента — сумму его неоплаченных заказов. Если писать это прямо на SQL, в сервисе появляются строки запросов, названия колонок и разбор результата в объект Order. Правило «долг — это неоплаченные заказы» размазано между WHERE paid = 0 и циклом суммирования, а проверить сервис без базы нельзя: каждый тест поднимает схему и заполняет таблицы.
Хуже всего то, что сервис начинает думать таблицами. Добавили кэш или перенесли заказы в другой сервис — правится код оплаты, хотя правила оплаты не менялись.
Решение
Описать хранилище интерфейсом OrderRepository в терминах предметной области: найти заказ по номеру (find), сохранить (save), выдать неоплаченные заказы клиента (unpaidOf). Интерфейс принадлежит домену и ничего не говорит о таблицах, файлах и запросах. Для вызывающего репозиторий выглядит как коллекция заказов в памяти.
Сервис OrderService получает репозиторий в конструкторе и знает только интерфейс. Реализаций может быть сколько угодно: в памяти для тестов и прототипа, на SQL в проекте, поверх HTTP-API соседнего сервиса. Сервис от замены не меняется.
Структура
Контракт — три метода, и каждый назван так, как о нём говорят в предметной области. Нет findBy(array $criteria) и query(string $sql): «неоплаченные заказы клиента» — это unpaidOf, и как именно хранилище отбирает их, решает реализация. Отсутствие заказа — не ошибка хранилища, а обычный ответ: null, undefined, None или в Go сентинел ErrOrderNotFound. Что делать с отсутствием, решает сервис.
Как это собирается
Сервис и хранилище собирает тот, кто обрабатывает запрос, а не сам сервис. В примере сборка идёт сверху вниз: создаётся InMemoryOrderRepository (в приложении DI-контейнер отдаёт здесь реализацию на SQL), в него кладутся заказы клиентов ann и bob, затем OrderService получает репозиторий в конструкторе. Обработчик вебхука оплаты вызывает pay и переводит ответы домена в статусы HTTP: успех - 200, OrderNotFound - 404, повторная оплата - 409. До оплаты долг ann равен 2300, после оплаты A-1 - 800.
Путь вызова один для всех запросов: обработчик -> OrderService -> OrderRepository -> хранилище. Обработчик не знает, где лежат заказы, а сервис не знает про HTTP.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Реализация в памяти — словарь «номер -> заказ». save кладёт заказ под его номером (новый и изменённый сохраняются одинаково), unpaidOf перебирает словарь. Для учебного примера этого достаточно, но у неё есть свойство, которого нет у базы: в PHP, Python и TypeScript она хранит сам объект, а не копию, так что изменение найденного заказа видно в хранилище и без save. Почему это опасно и как это ловить, показывает практика.
| Язык | Особенность |
|---|---|
| PHP | interface + final readonly сервис; paid с асимметричной видимостью public private(set) из PHP 8.4: читать можно всем, менять только через pay() |
| Go | Карта значений map[string]Order: Find возвращает копию, и без Save изменения не видны; отсутствие — обёрнутый ErrOrderNotFound, все методы возвращают error |
| Python | Protocol вместо базового класса; заказ — изменяемый dataclass с методом pay |
| TypeScript | implements OrderRepository; заглушка в тестах — объектный литерал, структурной типизации этого достаточно |
Запуск: make test T=patterns/enterprise/repository
Плюсы и минусы
Тесты проверяют сервис, а не словарь. У клиента ann заказы на 1500, 800 и уже оплаченный на 700, у bob — на 999: долг ann равен 2300. Оплата через сервис видна при следующем find. Оплата несуществующего заказа даёт OrderNotFound. Последний тест подставляет вместо хранилища заглушку из трёх строк, и сервис считает долг по ней, ничего не заметив: он знает только интерфейс.
- ✅ Сервис говорит на языке предметной области и не знает о SQL, файлах и кэшах.
- ✅ Сервис тестируется на реализации в памяти или заглушке, без базы.
- ✅ Хранилище меняется заменой одной реализации; правила запросов собраны в одном классе.
- ❌ Ещё один слой и ещё один интерфейс на каждый вид сущностей.
- ❌ Реализация в памяти легко начинает вести себя иначе, чем настоящая (копии, сортировка, конфликты), и тесты зеленеют там, где прод падает.
- ❌ Методы поиска множатся: каждый новый экран просит свой
findBy....
Когда не применять
- Простое CRUD-приложение, где сущности — это строки таблиц без поведения: Active Record или прямые запросы честнее лишнего слоя.
- Отчёты и выборки для экранов (join десяти таблиц, агрегаты): их удобнее читать отдельным запросом для чтения, а не собирать из сущностей репозитория.
- ORM уже даёт репозиторий (Doctrine, Spring Data, Entity Framework): поверх него достаточно своего интерфейса с доменными методами, второй слой обёрток не нужен.
Отличия между языками
- PHP — объекты передаются по ссылке на экземпляр, и реализация в памяти, которая кладёт объект в массив, отдаёт тот же экземпляр всем. Настоящий репозиторий (Doctrine) тоже держит один экземпляр на сущность (Identity Map), но изменения записывает только при
flush. - Go — карта значений копирует структуру и при записи, и при чтении: изоляция от хранилища получается сама. Цена — явный
Saveпосле каждого изменения иerrorв каждом методе, потому что настоящее хранилище может не ответить. - Python —
Protocolпроверяется структурно: заглушке в тестах не нужно наследоваться от интерфейса. Изменяемыйdataclassхранится в словаре по ссылке, как в PHP. - TypeScript — интерфейсу удовлетворяет любой объект с теми же методами, поэтому заглушка — литерал. В проекте методы были бы асинхронными (
Promise<Order | undefined>): хранилище за сетью; здесь синхронность упрощает пример.
Практика
- Учебный пример держит одну реализацию в памяти и не спрашивает, ведёт ли она себя как настоящее хранилище. В проекте на этом и ломаются: тесты идут на подделке, а она отдаёт общий объект, не сортирует и не знает о конкурентных записях. Практика: один контракт на два хранилища прогоняет один набор контрактных тестов на реализации в памяти и в JSON-файле: копии вместо общих объектов, поиск по критерию и оптимистическая блокировка по версии.
- Учебный пример обещает, что реализация может жить поверх HTTP-API соседнего сервиса, но не показывает, что ей при этом приходится прятать. Практика: контрагенты из учётной системы держит контрагентов во внешней учётной системе за API: постраничная выдача превращается в обход, коды
404,409и503- в пустой ответ и доменные ошибки, поля API - в модель, а один контракт проверяет и реализацию в памяти, и реализацию поверх API.
Связанные темы
- Практика: один контракт на два хранилища — контрактные тесты, изоляция объектов и конфликт версий
- Практика: контрагенты из учётной системы - репозиторий поверх HTTP API: страницы, коды ответа и маппинг полей
- Заместитель (Proxy) — его практика ставит кэширующий заместитель перед репозиторием товаров
- Адаптер (Adapter) — реализация репозитория часто адаптирует чужой API или драйвер к интерфейсу домена