Практика: контрагенты из учётной системы (Repository over HTTP API)
Контрагенты живут не в нашей базе, а в учётной системе вроде 1С за HTTP API, и домен всё равно видит обычный репозиторий: страницы превращаются в обход, коды ответа в доменные ошибки, поля API в модель.
Базовый пример: Репозиторий. Там хранилище - словарь в памяти, и обещание "реализация может быть поверх HTTP-API соседнего сервиса" остаётся словами. Здесь вторая реализация того же интерфейса действительно ходит в API, и видно, что она прячет от домена.
Задача
Админка интернет-магазина показывает контрагентов: юрлица, с которыми работают менеджеры. Хозяин этих данных - бухгалтерия, они ведутся в учётной системе, а админка читает и правит их через её HTTP API. API устроен так, как удобно учётной системе: поля называются Ref_Key, Description, INN, DeletionMark, DataVersion, список отдаётся страницами по сто записей, при одновременной правке отвечает 409, а ночью во время закрытия периода - 503.
Сначала контроллеры админки звали API напрямую. Через полгода в пяти местах был свой цикл по страницам (в двух он терял последнюю), свой разбор DeletionMark, а 503 где-то показывался пользователю как "контрагент не найден". Тесты админки требовали либо живую учётную систему, либо записанные ответы на каждый экран.
Что урезано
- Нет настоящего HTTP: репозиторий получает клиента с одним методом, а в тестах и примере вместо сети отвечает заглушка
FakeAccountingApi. В проекте это PSR-18-клиент,*http.Client,httpxилиfetchс базовым адресом и авторизацией. - Пути и поля упрощены по мотивам OData учётной системы:
GET /counterparties/{id},GET /counterparties?deleted=false&page=Nс ответом{"value": [...], "next": N+1 | null},PUT /counterparties/{id}с проверкойDataVersion. Версия - число, а не непрозрачная строка. - Нет повторов, таймаутов и кэша. Кэширующая обёртка над репозиторием разобрана в практике Заместителя.
- Нет создания с выдачей номера на стороне учётной системы: номер
C-1приходит от вызывающего.
Решение
Интерфейс CounterpartyRepository написан на языке админки: find по номеру, save с проверкой версии, active - все действующие, отсортированные по номеру. В нём нет страниц, статусов и имён полей API.
Реализаций две. InMemoryCounterpartyRepository - словарь с копиями, как в первой практике. ApiCounterpartyRepository получает HTTP-клиента и берёт на себя всё, что знает только она:
- страницы -
activeзапрашивает страницу за страницей, покаnextне станет пустым, и отдаёт один список; - коды ответа -
404на чтение значит "нет такого", иfindвозвращает пустой ответ;409на запись превращается вVersionConflictс версией из тела ответа;503- вAccountingUnavailable; любой другой неуспешный код - вUnexpectedResponseс методом и путём; - поля - пара функций
toDomainиtoApiпереводитRef_Key,Description,INN,DeletionMark,DataVersionвid,name,taxId,archived,versionи обратно. Больше нигде имена полей API не встречаются.
Поведение обеих реализаций зафиксировано одним набором контрактных тестов. Поэтому тесты админки можно гонять на реализации в памяти и верить им: если она разойдётся с API, упадёт её прогон контракта.
Структура
В Go место HttpClient занимает интерфейс Doer с методом Do(*http.Request), который реализует и *http.Client; в TypeScript - тип Fetch, совместимый с fetch.
Ответы API и доменные ошибки
| Ответ API | Где | Что видит домен |
|---|---|---|
200 | везде | сущность, список или новая версия в counterparty.version |
404 | find | пустой ответ: null, None, undefined, в Go ErrCounterpartyNotFound |
409 | save | VersionConflict: "version 1, stored 2", хранимая версия из тела ответа |
503 | везде | AccountingUnavailable: учётная система недоступна, повторить позже |
| другой код | везде | UnexpectedResponse с методом, путём и кодом |
Таблица - главное, что прячет репозиторий. 404 на чтение - обычный ответ, а не сбой: что делать с отсутствием, решает вызывающий, как в базовом примере. 409 и 503 - разные беды с разной реакцией: на конфликт админка предлагает перечитать карточку, на недоступность - повторить позже, и путать их нельзя. Всё остальное (400, 500) - ошибка интеграции, её не прячут под доменное имя, а пишут в лог с путём.
Обход страниц устроен так, что вызывающий никогда не видит страницу: active возвращает полный список. Номер следующей страницы приходит от API, репозиторий его не вычисляет, поэтому последняя страница не теряется ни при каком размере.
Как это собирается
Репозиторий собирает контейнер админки: создаёт HTTP-клиента с адресом учётной системы и передаёт его в ApiCounterpartyRepository, а контроллеры получают интерфейс CounterpartyRepository. В примере вместо клиента FakeAccountingApi с четырьмя записями в формате API, одна из них помечена на удаление.
Список действующих приходит за два запроса и уже без удалённого C-3. Дальше админ открывает C-1, а бухгалтер в это время переименовывает его в учётной системе: версия становится 5. Админ сохраняет свою устаревшую копию, API отвечает 409, репозиторий бросает VersionConflict, и контроллер отвечает "перечитайте и повторите". Затем учётная система уходит на закрытие периода: 503 превращается в AccountingUnavailable и в ответ "повторите позже". Неизвестный номер - 404. Путь вызова: контроллер -> CounterpartyRepository -> HTTP-клиент -> учётная система; коды HTTP админки контроллер выбирает по доменным ошибкам, а не по кодам API.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Обе реализации не знают друг о друге. Реализация в памяти копирует сущность при записи и чтении, реализация поверх API копирует бесплатно: каждый ответ разбирается в новый объект. Версия приходит из ответа API, а не увеличивается на месте, так что правило "кто увеличивает версию" живёт в учётной системе.
| Язык | Особенность |
|---|---|
| PHP | Свой интерфейс HttpClient вместо PSR-18; форма строки API - @phpstan-type ApiRow, и заглушка импортирует её через @phpstan-import-type |
| Go | Doer - срез *http.Client; заглушка - http.ServeMux с шаблонами GET /counterparties/{id}, вызываемый через httptest.NewRecorder без сети; apiCounterparty с json-тегами переводится в Counterparty простым приведением типа |
| Python | Protocol для репозитория и клиента; маппинг - две функции над словарями; dataclasses.replace для копий |
| TypeScript | Методы асинхронные, клиент - тип Fetch, заглушка отвечает настоящими Response; ошибки - подклассы Error |
Запуск: make test T=patterns/enterprise/repository/practice-api
Что проверяют тесты
Тесты одинаковы во всех языках. Контракт - четыре случая, и каждый прогоняется на обеих реализациях: сохранённый C-1 находится с версией 1; отсутствующий C-9 даёт пустой ответ; из шести контрагентов, сохранённых вразнобой, с архивным C-5, active возвращает ровно C-1, C-2, C-3, C-4, C-6; две копии C-1 с версией 1 - первая сохраняется, вторая падает с VersionConflict "version 1, stored 2", и в хранилище остаётся имя из первой записи. Для API-реализации третий случай идёт через три страницы по две записи, четвёртый - через ответ 409.
Остальные тесты про то, что есть только у API-реализации. Строка в формате API с DeletionMark: true и DataVersion: 4 читается как архивный контрагент с версией 4. Пять действующих контрагентов при странице в две записи дают ровно три запроса page=1, page=2, page=3, ни одного лишнего. Ответ 503 становится AccountingUnavailable, ответ 500 - UnexpectedResponse, и в обоих текстах есть метод и путь.
Где граница
- Нужны выборки, которых API не умеет. Если учётная система фильтрует только по признаку удаления, а админке нужен поиск по ИНН и городу, обход всех страниц на каждый запрос быстро станет медленным. Тогда данные копируют к себе (синхронизация по событиям или ночная выгрузка), а репозиторий читает локальную копию и пишет в API.
- Сеть просит повторов и паузы. Повтор на
503, таймаут и ограничение частоты - забота HTTP-клиента или обёртки над ним, а не репозитория. Иначе в каждом методе появится свой цикл повторов. - Модели расходятся сильнее, чем переименование полей. Когда одна сущность домена собирается из трёх ресурсов API или у API свои статусы и справочники, функции
toDomain/toApiвырастают в отдельный слой перевода - Anti-Corruption Layer, а репозиторий остаётся тонким. - Избыточность. Если админка только показывает ответ API как есть и правил домена у контрагента нет, репозиторий и сущность - лишний слой; хватит клиента API с типизированными ответами.
Отличия между языками
- PHP - массивы из
json_decodeне типизированы, поэтому форма строки API описана один раз как@phpstan-type ApiRow; без неё PHPStan не пропустит обращения к ключам. Отдельный класс на каждую ошибку - обычный способ сделатьcatchпо смыслу. - Go - заглушка лежит в тестовом файле, как принято в Go: её видят тесты и
Example, но не пакет. Отсутствие - сентинелErrCounterpartyNotFound, а не пустое значение.apiCounterpartyиCounterpartyсовпадают по именам и типам полей, поэтому маппинг - приведениеCounterparty(row): json-теги при приведении не учитываются. Когда модели разойдутся, приведение перестанет компилироваться, и это сигнал написать перевод руками. - Python -
json.loadsотдаёт словари, маппинг читает их по ключам;Protocolпозволяет заглушке не наследоватьHttpClient. Страницаnext: nullприходит какNone, и циклwhile page is not Noneчитается буквально. - TypeScript - единственный язык, где методы асинхронные, как и было бы в проекте: настоящий
fetchсинхронно не вызывается. Заглушка отвечает объектамиResponseиз Node, поэтому репозиторий читает тело черезresponse.json()так же, как с сетью.
Связанные темы
- Репозиторий (базовый пример)
- Практика: один контракт на два хранилища - контрактные тесты и конфликт версий на файле вместо API
- Заместитель (Proxy) - его практика кэширует репозиторий, и кэш перед медленным API - её прямое продолжение
- Адаптер (Adapter) -
ApiCounterpartyRepositoryадаптирует чужой API к интерфейсу домена