handbookНаписать

patterns/structural/facade/practice/README.md · практика · 4 языка · 23 теста

база: Фасад

Практика: оформление заказа (Place order) ​

Оформление заказа — это резерв на складе, списание оплаты и уведомление. Фасад PlaceOrder прячет не только порядок шагов, но и то, что делать, когда шаг не удался.

Базовый пример: Фасад. Там подсистемы не падают, и фасад только зовёт их по порядку. Здесь оплата может не пройти после того, как товар уже зарезервирован, а почтовый сервис может лечь в момент, когда деньги уже списаны, и фасад решает, что откатить, а что пережить.

Задача ​

Магазин принимает заказ из трёх мест: сайт, мобильное приложение, звонок в колл-центр. Везде одно и то же: зарезервировать товар, списать деньги, отправить письмо. Сначала каждый контроллер делал это сам. Потом выяснилось, что колл-центр не снимал резерв при отказе банка, и товар «висел» на складе до ночной сверки. А в приложении упавший почтовый сервис возвращал клиенту ошибку, хотя деньги уже были списаны, и клиент оформлял заказ второй раз.

Правила «если оплата не прошла — снять резерв» и «письмо не важнее заказа» должны жить в одном месте, и это место — фасад.

Что урезано ​

  • Склад, платёжный шлюз и почта — интерфейсы с реализациями в памяти: остатки и балансы — словари, письмо — строка в списке.
  • Цена не считается: сумма приходит в запросе готовой, в копейках.
  • Нет идемпотентности и повторов: каждый вызов — новая попытка, номер заказа задаёт тест.
  • Упавшее уведомление не ставится в очередь, фасад только отмечает в чеке, что письмо не ушло.

Решение ​

Контракты — три интерфейса подсистем (Inventory, Payments, Notifier), запрос OrderRequest, чек Receipt и две ошибки предметной области: OutOfStock и PaymentDeclined. Фасад зависит только от них, поэтому в тестах любую подсистему можно подменить, в том числе сломанной.

Фасад PlaceOrder задаёт сценарий:

  1. reserve — нет товара, значит заказа нет, деньги не трогаем.
  2. charge — оплата упала, значит release и та же ошибка наверх: компенсация шага 1.
  3. orderPlaced — ошибка уведомления ловится, заказ остаётся, в чеке notified = false.

Порядок здесь не случайный: сначала дешёвое и обратимое (резерв), потом дорогое и трудно обратимое (деньги), в конце то, что можно повторить позже (письмо).

Структура ​

takes

returns

PlaceOrder

+handle(OrderRequest) : Receipt

«interface»

Inventory

+reserve(orderId, lines)

+release(orderId)

«interface»

Payments

+charge(orderId, customer, amountCents) : string

«interface»

Notifier

+orderPlaced(orderId, customer)

InMemoryInventory

InMemoryPayments

InMemoryNotifier

OrderRequest

+orderId: string

+customer: string

+lines: sku -> qty

+amountCents: int

Receipt

+orderId: string

+paymentId: string

+notified: bool

Подсистемы в памяти ведут себя как настоящие в главном: 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 path2 лампы за 4000чек, notified = true, остаток 3, баланс 6000, одно письмо
out of stock charges nothing9 ламп за 4000OutOfStock, остаток 5, баланс 10000: оплату даже не пробовали
declined payment releases stock2 лампы за 15000PaymentDeclined, остаток снова 5: резерв снят
failed notification keeps the order2 лампы за 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 }: структурной типизации достаточно.

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

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