handbookНаписать

patterns/enterprise/repository/README.md · тема · 4 языка · 19 тестов

Репозиторий (Repository) ​

Даёт предметной области коллекцию сущностей с методами на её языке и прячет за ней, где и как они хранятся.

Проблема ​

Сервис заказов принимает оплату: находит заказ, отмечает его оплаченным, записывает обратно. Ещё он считает долг клиента — сумму его неоплаченных заказов. Если писать это прямо на SQL, в сервисе появляются строки запросов, названия колонок и разбор результата в объект Order. Правило «долг — это неоплаченные заказы» размазано между WHERE paid = 0 и циклом суммирования, а проверить сервис без базы нельзя: каждый тест поднимает схему и заполняет таблицы.

Хуже всего то, что сервис начинает думать таблицами. Добавили кэш или перенесли заказы в другой сервис — правится код оплаты, хотя правила оплаты не менялись.

Решение ​

Описать хранилище интерфейсом OrderRepository в терминах предметной области: найти заказ по номеру (find), сохранить (save), выдать неоплаченные заказы клиента (unpaidOf). Интерфейс принадлежит домену и ничего не говорит о таблицах, файлах и запросах. Для вызывающего репозиторий выглядит как коллекция заказов в памяти.

Сервис OrderService получает репозиторий в конструкторе и знает только интерфейс. Реализаций может быть сколько угодно: в памяти для тестов и прототипа, на SQL в проекте, поверх HTTP-API соседнего сервиса. Сервис от замены не меняется.

Структура ​

knows only

stores

Order

+id: string

+customerId: string

+totalCents: int

+paid: bool

+pay()

«interface»

OrderRepository

+find(id) : Order?

+save(order)

+unpaidOf(customerId) : Order[]

InMemoryOrderRepository

-orders: map

+find(id) : Order?

+save(order)

+unpaidOf(customerId) : Order[]

OrderService

-orders: OrderRepository

+pay(orderId)

+debtOf(customerId) : int

Контракт — три метода, и каждый назван так, как о нём говорят в предметной области. Нет 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. Почему это опасно и как это ловить, показывает практика.

ЯзыкОсобенность
PHPinterface + final readonly сервис; paid с асимметричной видимостью public private(set) из PHP 8.4: читать можно всем, менять только через pay()
GoКарта значений map[string]Order: Find возвращает копию, и без Save изменения не видны; отсутствие — обёрнутый ErrOrderNotFound, все методы возвращают error
PythonProtocol вместо базового класса; заказ — изменяемый dataclass с методом pay
TypeScriptimplements 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.

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

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