Практика: оформление заказа (Place order)
Оформление заказа — это резерв на складе, списание оплаты и уведомление. Фасад
PlaceOrderпрячет не только порядок шагов, но и то, что делать, когда шаг не удался.
Базовый пример: Фасад. Там подсистемы не падают, и фасад только зовёт их по порядку. Здесь оплата может не пройти после того, как товар уже зарезервирован, а почтовый сервис может лечь в момент, когда деньги уже списаны, и фасад решает, что откатить, а что пережить.
Задача
Магазин принимает заказ из трёх мест: сайт, мобильное приложение, звонок в колл-центр. Везде одно и то же: зарезервировать товар, списать деньги, отправить письмо. Сначала каждый контроллер делал это сам. Потом выяснилось, что колл-центр не снимал резерв при отказе банка, и товар «висел» на складе до ночной сверки. А в приложении упавший почтовый сервис возвращал клиенту ошибку, хотя деньги уже были списаны, и клиент оформлял заказ второй раз.
Правила «если оплата не прошла — снять резерв» и «письмо не важнее заказа» должны жить в одном месте, и это место — фасад.
Что урезано
- Склад, платёжный шлюз и почта — интерфейсы с реализациями в памяти: остатки и балансы — словари, письмо — строка в списке.
- Цена не считается: сумма приходит в запросе готовой, в копейках.
- Нет идемпотентности и повторов: каждый вызов — новая попытка, номер заказа задаёт тест.
- Упавшее уведомление не ставится в очередь, фасад только отмечает в чеке, что письмо не ушло.
Решение
Контракты — три интерфейса подсистем (Inventory, Payments, Notifier), запрос OrderRequest, чек Receipt и две ошибки предметной области: OutOfStock и PaymentDeclined. Фасад зависит только от них, поэтому в тестах любую подсистему можно подменить, в том числе сломанной.
Фасад PlaceOrder задаёт сценарий:
reserve— нет товара, значит заказа нет, деньги не трогаем.charge— оплата упала, значитreleaseи та же ошибка наверх: компенсация шага 1.orderPlaced— ошибка уведомления ловится, заказ остаётся, в чекеnotified = false.
Порядок здесь не случайный: сначала дешёвое и обратимое (резерв), потом дорогое и трудно обратимое (деньги), в конце то, что можно повторить позже (письмо).
Структура
Подсистемы в памяти ведут себя как настоящие в главном: InMemoryInventory сначала проверяет все строки и только потом списывает остатки, а резерв запоминает по номеру заказа, чтобы release вернул ровно то, что взял. InMemoryPayments отказывает, если на балансе не хватает, и выдаёт номера платежей по счётчику. Ошибки называют заказ, товар и числа: по тексту видно, что именно не сошлось.
Компенсация
Компенсация — это действие, которое отменяет уже сделанный шаг, когда следующий не удался. В PlaceOrder она одна: release после неудачного charge. Ошибка оплаты после отката пробрасывается без изменений, чтобы вызывающий код отличал «нет товара» от «банк отказал» и показал клиенту нужное сообщение.
Уведомление компенсации не требует, оно просто не критично. Фасад ловит любую его ошибку и возвращает чек с notified = false: заказ оплачен и зарезервирован, а повторить письмо можно потом. Это решение фасада, а не почтового сервиса: сам Notifier честно падает, и в другом сценарии (например, письмо со ссылкой для входа) его ошибка была бы фатальной.
Как это собирается
PlaceOrder создаётся один раз в DI-контейнере вместе с подсистемами, и его получают все три контроллера: сайт, приложение и колл-центр. В примере склад и платежи - реализации в памяти, а почта отказывает одному покупателю, как это бывает с плохим адресом. Контроллер знает только handle() и переводит его исход в HTTP-ответ: чек - 201 с номером платежа, OutOfStock - 409, PaymentDeclined - 402.
Вывод показывает правила фасада в деле. Заказ A-2 из колл-центра не прошёл оплату, и стол вернулся на склад: следующим его купил A-3 из приложения. У A-3 не ушло письмо, но заказ создан, а контроллер только помечает, что письмо надо отправить позже. Ни одного release() и ни одного try вокруг почты в контроллерах нет.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | Ошибки — классы от RuntimeException; компенсация в catch (Throwable) с повторным throw; сломанный уведомитель в тесте — анонимный класс |
| Go | Ошибки — сентинелы с %w и контекстом заказа; фасад — структура с полями-интерфейсами; сломанный уведомитель — NotifierFunc |
| Python | Контракты — Protocol, подсистемы и фасад — dataclass; компенсация в except с голым raise |
| TypeScript | Ошибки — подклассы Error с name; сломанный уведомитель — объектный литерал, структурно совместимый с Notifier |
Запуск: make test T=patterns/structural/facade/practice
Что проверяют тесты
Тесты одинаковы во всех языках: одна таблица из четырёх случаев на складе с пятью лампами и балансе 10000 копеек. После каждого случая проверяется всё сразу: чек, остаток, баланс и отправленные письма.
| Случай | Заказ | Итог |
|---|---|---|
| happy path | 2 лампы за 4000 | чек, notified = true, остаток 3, баланс 6000, одно письмо |
| out of stock charges nothing | 9 ламп за 4000 | OutOfStock, остаток 5, баланс 10000: оплату даже не пробовали |
| declined payment releases stock | 2 лампы за 15000 | PaymentDeclined, остаток снова 5: резерв снят |
| failed notification keeps the order | 2 лампы за 4000, почта лежит | чек с notified = false, остаток 3, баланс 6000 |
Отдельный тест проверяет текст ошибки: order o-2: lamp wants 9, have 5 — по нему видно, какой заказ и какого товара не хватило.
Где граница
- Подсистемы — отдельные сервисы по сети, и ответ «таймаут» не значит «деньги не списаны». Тогда снять резерв сразу нельзя: нужны идемпотентные ключи, сверка статуса платежа и сага с журналом шагов вместо
try/catchв одном методе. - Уведомления, которые нельзя терять, уходят не вызовом, а записью в outbox в той же транзакции, что и заказ. Флаг
notifiedв чеке — честная заглушка, а не решение. - Шагов становится десять и у каждого своя компенсация — фасад превращается в оркестратор, и сценарий лучше описать данными (список шагов с парами «сделать/откатить»).
- Заказ оформляется из одного места и подсистема одна — фасад не нужен, хватит сервиса с одним методом.
Отличия между языками
- PHP —
catch (Throwable)с повторнымthrow $eсохраняет исходное исключение и его стек. Для уведомленияcatch (Throwable)без переменной: ошибка намеренно игнорируется, и это видно из кода. - Go — ошибок-исключений нет, поэтому компенсация — обычный
if err != nilсReleaseпередreturn. Ошибка оборачиваетсяplace order: %w, иerrors.Isпо-прежнему находит сентинел. Результат уведомления сводится к== nil. - Python — голый
raiseвexceptпробрасывает то же исключение с исходной трассировкой.Protocolпозволяет тесту передать любой класс с методомorder_placed, без наследования. - TypeScript — в
catchзначение имеет типunknown, поэтому оно пробрасывается как есть, без попыток разобрать. Сломанный уведомитель в тесте — литерал{ orderPlaced }: структурной типизации достаточно.
Связанные темы
- Фасад (базовый пример)
- Адаптер (Adapter) — если платёжный шлюз пришёл со своим API, адаптер приводит его к
Payments, а фасад остаётся прежним.