Практика: один контракт на два хранилища (Repository contract tests)
Репозиторий в памяти и репозиторий в JSON-файле проходят один и тот же набор тестов: копии вместо общих объектов, поиск по критерию и конфликт версий.
Базовый пример: Репозиторий. Там одна реализация в памяти, и никто не проверяет, ведёт ли она себя как настоящее хранилище. Здесь две реализации одного интерфейса и один набор контрактных тестов, который ловит расхождения между ними.
Задача
Сервис заказов в проде пишет в базу, а его тесты идут на репозитории в памяти: так быстро и не нужна схема. Через полгода в проде всплыли две ошибки, которых тесты не видели. Первая: обработчик нашёл заказ, поменял сумму для проверки скидки и не сохранял, а в тестах изменение «сохранилось» само, потому что репозиторий в памяти отдавал тот же объект, что лежал в словаре. Вторая: два оператора одновременно отметили один заказ оплаченным, и клиенту дважды ушло письмо с чеком. В памяти такого не воспроизвести, если реализация не знает о версиях.
Подделка была написана «по смыслу», и никто не проверял, что она ведёт себя как база. Нужно, чтобы поведение репозитория было записано один раз — тестами — и чтобы каждая реализация доказывала, что ему следует.
Что урезано
- Настоящая база заменена JSON-файлом: на каждый вызов файл читается целиком и переписывается целиком. Достаточно, чтобы показать вторую реализацию со своими особенностями (сериализация, отсутствие файла, копии «бесплатно»).
- Нет блокировок между процессами и атомарной записи через временный файл и переименование: два процесса могут перезаписать файл друг друга. В базе проверка версии делается одним
UPDATE ... WHERE version = ?. - Нет транзакций на несколько заказов: это задача Единицы работы (Unit of Work).
- Методы синхронные; в проекте на TypeScript они вернули бы
Promise.
Решение
Контракт OrderRepository тот же, что в базовом примере, но теперь у него записаны обязательства, которые раньше подразумевались: find возвращает копию, unpaidOf сортирует по номеру, а save проверяет версию и при расхождении бросает VersionConflict. Проверка версии вынесена рядом с интерфейсом, чтобы обе реализации не писали её по-своему.
Сущность Order получила поле version. Новый заказ приходит с версией 0, каждое успешное save увеличивает её на единицу и записывает новую версию в переданный объект — так делают ORM. Сохранить можно только объект, чья версия совпадает с хранимой: кто прочитал заказ раньше, чем его изменил другой, получит конфликт, а не тихую перезапись.
Две реализации: в памяти InMemoryOrderRepository и в файле JsonFileOrderRepository. Сервиса в практике нет: проверяется сам контракт, который сервис из базового примера использует.
Структура
Контракт теперь не только сигнатуры. Комментарий у find про копию и сортировка в unpaidOf — часть обещания: вызывающий код может на них опираться, и тесты их проверяют. Общая проверка версии (checkVersion, в PHP — статический VersionConflict::check) лежит рядом с интерфейсом, в Go, Python и TypeScript там же и отбор неоплаченных: реализации различаются только тем, где хранят данные.
Копии и версии
Реализация в памяти — место, где подделка обычно расходится с базой. Из базы каждый find возвращает новый объект, собранный из строки. Словарь отдаёт тот же объект, что в нём лежит, и всё, что с ним сделали, «сохраняется» без save. Поэтому реализация в памяти копирует дважды: при записи (иначе изменения объекта, переданного в save, протекут в хранилище) и при чтении. В PHP это clone, в Python dataclasses.replace, в TypeScript хранятся простые данные, а наружу отдаётся новый Order. В Go карта значений копирует сама.
Файловая реализация копирует бесплатно: каждый find разбирает JSON заново. Зато у неё свои обязанности: отсутствующий файл — пустое хранилище, а не ошибка, и запись должна сохранять все поля, включая версию.
Версия — оптимистическая блокировка: никто ничего не блокирует при чтении, конфликт обнаруживается при записи. Сравнение одно и то же в обеих реализациях: версия объекта против хранимой (0, если заказа нет). Поэтому повторное создание заказа с тем же номером — тоже конфликт: новый объект с версией 0 против хранимой 1.
Как это собирается
Хранилище выбирает конфиг, а не сервис и не обработчик. В примере openOrders по значению storage отдаёт InMemoryOrderRepository или JsonFileOrderRepository, а дальше код работает только с интерфейсом OrderRepository: в приложении эта функция живёт в DI-контейнере или в main, а значения приходят из окружения.
Два оператора одновременно открыли заказ A-1 и оба получили копию с версией 1. Обработчик payHandler первого оплачивает и сохраняет: версия 2, ответ 200. Второй оплачивает свою устаревшую копию, и save бросает VersionConflict. Обработчик ловит конфликт, перечитывает заказ и видит, что он уже оплачен: ответ 409, второго чека не будет. Если бы свежий заказ был не оплачен, обработчик повторил бы оплату на нём. Путь вызова: обработчик -> репозиторий -> хранилище, решение о конфликте принимает обработчик.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Файловая реализация читает файл в словарь «номер -> заказ», меняет словарь и записывает его обратно стандартным кодировщиком JSON. Декодирование строк обратно в сущность — единственное место, где реализация знает формат хранения.
| Язык | Особенность |
|---|---|
| PHP | json_encode сериализует публичные свойства сущности, обратно — new Order(...$row) с именованными аргументами; копия — clone; набор прогоняется через #[DataProvider] с фабриками-замыканиями |
| Go | Save(o *Order) принимает указатель, чтобы вернуть новую версию; теги json:"..." и encoding/json; набор — функция runContract(t, newRepo), временная папка — t.TempDir() |
| Python | asdict и Order(**row); копия — dataclasses.replace; набор — фикстура с params=[in_memory, json_file], папка — TemporaryDirectory |
| TypeScript | Хранятся простые данные OrderData, наружу — новый Order.from(data); node:fs; набор — describe.each по списку реализаций |
Запуск: make test T=patterns/enterprise/repository/practice
Что проверяют тесты
Тесты написаны один раз и прогоняются на обеих реализациях: шесть случаев, двенадцать прогонов. Сохранённый заказ A-1 на 1500 находится по номеру с версией 1. Найденный, оплаченный и сохранённый заказ читается оплаченным с версией 2. Отсутствующий номер даёт пустой ответ (в Go — ErrOrderNotFound). Из заказов A-3, A-2 (оплачен), B-1 (другой клиент) и A-1, сохранённых вразнобой, unpaidOf("ann") возвращает ровно A-1, A-3 в этом порядке.
Два случая — те, что раньше проходили только на одной реализации. Изоляция: заказ оплатили после save и оплатили найденную копию, но не сохранили — в хранилище он не оплачен. Конфликт: два оператора прочитали A-1 с версией 1, первый оплатил и сохранил (версия 2), второй тоже оплатил, и его save падает с VersionConflict «version 1, stored 2», а в хранилище остаётся версия 2. Если убрать clone или проверку версии из реализации в памяти, упадёт именно её прогон, а файловый останется зелёным.
Где граница
- Репозиторий превращается в DAO. Каждый экран просит свой метод:
findByCustomerAndStatus,findPaidBetween,findByCityOrderByTotal... Через полгода в интерфейсе двадцать методов, и каждый надо реализовать и покрыть контрактом в обеих реализациях. Это сигнал выделить критерий в объект — Спецификацию (and/or/not, одна спецификация и для памяти, и для SQL) — и оставить в репозитории одинmatching(spec). А выборки для экранов отдать отдельным запросом для чтения, мимо сущностей. - Конфликт — только половина дела. Тест показывает, что вторую запись отвергли. Что делать дальше — перечитать и повторить, показать пользователю «заказ изменён» или слить изменения — решает сервис, и это отдельное решение.
- Несколько заказов в одной операции. Версия защищает одну сущность. Если оплата меняет заказ и баланс клиента, нужна транзакция на оба — Единица работы, а не два независимых
save. - Избыточность. Если хранилище одно и тестируется настоящим (база в контейнере), реализация в памяти и контракт на неё не нужны. Контрактные тесты окупаются, когда подделку используют в тестах сервисов и от её честности зависит, чему эти тесты верят.
Отличия между языками
- PHP — PHPUnit 12 находит в файле только класс с его именем, поэтому один набор на две реализации — это
#[DataProvider], который отдаёт фабрики. Альтернатива — абстрактныйOrderRepositoryContractTestи по наследнику на реализацию в своём файле. Временный файл получает уникальное имя вsys_get_temp_dir()и удаляется вtearDown. - Go — изоляция бесплатна:
Order— значение, и карта хранит копию. Зато сигнатураSave(o *Order)отличается отFind, иначе новую версию не вернуть вызывающему.t.TempDir()сам удаляет папку после теста, аt.Runдаёт каждому случаю имя видаTestJSONFileOrderRepository/stale_version_conflicts. - Python — параметризованная фикстура размножает каждый тест на обе реализации, и в выводе pytest видно, какая упала:
[memory]или[json file].dataclasses.replaceбез изменений — короткий способ скопировать сущность; для вложенных объектов понадобился быcopy.deepcopy. - TypeScript —
structuredCloneпотерял бы прототип класса и методpay, поэтому хранятся простые данные, а сущность собирается заново черезOrder.from.describe.eachподставляет имя реализации в заголовок группы через$name.
Связанные темы
- Репозиторий (базовый пример)
- Практика Заместителя: кэш репозитория товаров — ещё одна реализация того же интерфейса, которая обязана вести себя как исходная
- Прототип (Prototype) — копирование объектов, на котором держится изоляция реализации в памяти