handbookНаписать

patterns/enterprise/repository/practice/README.md · практика · 4 языка · 29 тестов

Практика: один контракт на два хранилища (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. Сервиса в практике нет: проверяется сам контракт, который сервис из базового примера использует.

Структура ​

save throws

Order

+id: string

+customerId: string

+totalCents: int

+paid: bool

+version: int

+pay()

«interface»

OrderRepository

+find(id) : Order?

+save(order)

+unpaidOf(customerId) : Order[]

VersionConflict

InMemoryOrderRepository

-orders: map

JsonFileOrderRepository

-path: string

-load() : map

Контракт теперь не только сигнатуры. Комментарий у 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. Декодирование строк обратно в сущность — единственное место, где реализация знает формат хранения.

ЯзыкОсобенность
PHPjson_encode сериализует публичные свойства сущности, обратно — new Order(...$row) с именованными аргументами; копия — clone; набор прогоняется через #[DataProvider] с фабриками-замыканиями
GoSave(o *Order) принимает указатель, чтобы вернуть новую версию; теги json:"..." и encoding/json; набор — функция runContract(t, newRepo), временная папка — t.TempDir()
Pythonasdict и 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.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS