handbookНаписать

patterns/creational/abstract-factory/practice/README.md · практика · 4 языка · 23 теста

база: Абстрактная фабрика

Практика: платёжные провайдеры (Payment providers) ​

Платёжный провайдер — это семейство: приём платежа, возвраты и разбор вебхуков. Возврат, ушедший не тому провайдеру, который принял платёж, — реальный инцидент, и абстрактная фабрика делает его невозможным по построению.

Базовый пример: Абстрактная фабрика. Там семейство выбирает тест. В жизни семейство выбирается данными (валюта, страна, магазин), меняется со временем, а продукты одного провайдера должны находить друг друга спустя месяцы: возврат по старому платежу обязан уйти туда, где платёж был сделан.

Задача ​

Магазин принимает платежи через двух провайдеров. У каждого свой клиент для списания, свой API возвратов и своя схема подписи вебхуков: один подписывает тело HMAC-SHA256 с секретом, другой присылает статичный токен. Маршрутизация по валюте задаётся в конфиге и меняется: рубли сначала шли одному провайдеру, потом другому.

В первой версии три сервиса (PaymentService, RefundService, WebhookController) каждый по-своему решали, какой провайдер нужен. Возвраты смотрели на текущую маршрутизацию по валюте, и после переключения рублей на нового провайдера возвраты по старым платежам полетели не туда.

Что урезано ​

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

Решение ​

Продукты — три интерфейса: Gateway принимает платёж и возвращает Charge с кодом провайдера, Refunder делает возврат по Charge, WebhookParser проверяет подпись и разбирает событие. Charge помнит, кто его создал, и умеет проверить принадлежность.

Абстрактная фабрика PaymentProvider выдаёт все три продукта одного провайдера и свой код. Семейства AcmePay и FastPay отличаются префиксами идентификаторов и схемой подписи; возврат чужого платежа каждое семейство отвергает.

Providers — реестр фабрик по коду плюс маршрутизация по валюте из конфига. Checkout выбирает провайдера дважды по-разному: для платежа — по валюте, для возврата — по коду из самого платежа. Вебхук приходит с кодом провайдера в адресе, и разбирает его парсер того же семейства.

Структура ​

по коду

creates

creates

creates

returns

checks owner

Checkout

+pay(amount, currency) : Charge

+refund(charge, amount) : Refund

+handleWebhook(provider, body, signature) : WebhookEvent

Providers

-byCurrency: config

-byCode: Map<string, PaymentProvider>

+forCurrency(currency) : PaymentProvider

+byCode(code) : PaymentProvider

«interface»

PaymentProvider

+code() : string

+gateway() : Gateway

+refunder() : Refunder

+webhooks() : WebhookParser

AcmePay

FastPay

«interface»

Gateway

+charge(amount, currency) : Charge

«interface»

Refunder

+refund(charge, amount) : Refund

«interface»

WebhookParser

+parse(body, signature) : WebhookEvent

Charge

+provider: string

+id: string

+mustBelongTo(provider)

Продукты несут код провайдера в данных (Charge.provider): так семейство можно восстановить спустя время по сохранённому платежу. Это отличие от учебного примера, где семейство существует только пока жива фабрика.

Реестр и маршрутизация ​

Конфиг задаёт маршрутизацию и запасной код:

yaml
fallback: acmepay
by_currency:
  RUB: fastpay

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

Как это собирается ​

Семейства регистрируются один раз при старте приложения: секреты AcmePay и FastPay приходят из окружения, маршрутизация по валюте и запасной код - из конфига. Готовый Providers отдаётся в Checkout, и дальше ни один вызывающий код не выбирает провайдера сам. Платёж в рублях уходит FastPay, в долларах - запасному AcmePay, и каждый Charge уносит код своего семейства.

Контроллер вебхуков берёт код провайдера из адреса и отдаёт тело в handleWebhook: разбирает его парсер того же семейства, а неверная подпись превращается в ответ 401, касса о схемах подписи не знает. Возврат идёт по Charge.provider, поэтому попадает к FastPay, который принял платёж.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Семейства собраны из общих заготовок: шлюз со счётчиком и префиксом, возврат с проверкой владельца. Разное у них — префикс идентификаторов и парсер вебхуков. Так видно, что семейство — это не обязательно шесть классов: это набор продуктов, согласованных по провайдеру.

ЯзыкОсобенность
PHPШлюз создаётся один раз и кэшируется в фабрике (??=), остальные продукты лёгкие и создаются на каждый вызов; hash_equals для подписи
GoОбщие заготовки продуктов: seqGateway, ownRefunder как строковый тип, parserFunc; подпись через crypto/hmac, токен через subtle.ConstantTimeCompare
PythonФабрика и продукты — Protocol; заготовки продуктов — приватные dataclass; hmac.compare_digest для обеих схем
TypeScriptПродукты — объектные литералы из фабричных функций; node:crypto для HMAC и timingSafeEqual

Запуск: make test T=patterns/creational/abstract-factory/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках. Платёж в рублях уходит FastPay, в долларах — запасному AcmePay. Главный тест воспроизводит инцидент: маршрутизация рублей переключена на AcmePay, а возврат по старому платежу FastPay всё равно уходит FastPay. Третий тест показывает защиту семейства: возвратчик AcmePay отвергает чужой платёж с понятной ошибкой. Четвёртый — каждое семейство проверяет вебхуки по-своему, подделанная подпись отклоняется. Пятый — код провайдера в конфиге без зарегистрированной фабрики падает сразу.

Где граница ​

  • Провайдеров пять и у каждого десяток продуктов — интерфейс фабрики растёт вместе с ними, каждый новый продукт трогает все семейства. Тогда семейство становится модулем или контейнером, который отдаёт продукты по типу, а не по методу на каждый.
  • Семейство выбирается на каждый вызов по данным — это всё ещё абстрактная фабрика, но реестр фабрик по коду обязателен: без него «какой провайдер» размажется по сервисам, как в первой версии.
  • Провайдер один — фабрика не нужна, нужны просто три сервиса. Приём окупается с появлением второго провайдера или тестового семейства «песочница».
  • Нужны не согласованные продукты, а один взаимозаменяемый алгоритм — это Стратегия.

Отличия между языками ​

  • PHP — фабрика может кэшировать тяжёлые продукты (??=): абстрактная фабрика часто ещё и владеет временем жизни клиентов. Исключения разных классов для «чужой платёж» и «плохая подпись».
  • Go — семейство собирается из заготовок: ownRefunder — это просто строка с методом, parserFunc — функция с методом. Три сентинел-ошибки, тесты сверяют их через errors.Is.
  • Python — Protocol для фабрики с @property code; класс провайдера хранит код атрибутом класса. Заготовки продуктов приватные, снаружи видны только семейства.
  • TypeScript — продукты как объектные литералы, фабрика как класс с полями-продуктами. timingSafeEqual требует буферы одной длины, поэтому длина сравнивается заранее.

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

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