Перейти к содержимому

27. Внешняя интеграция

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

Эта глава целиком моделирует одну такую интеграцию: платёжный сервис, который интегрируется со Stripe (платёжным процессором) для исходящих списаний и принимает вебхуки от Stripe для обновлений статуса. Компактно, но каждая забота — настоящая.

Customer ──► Payments.authorize
Payments ──► Stripe.charge (исходящий, синхронный HTTP)
Stripe ──► Payments.paymentWebhook (входящий, асинхронный)
Payments ──► Ledger.record

Три модуля ArchLang: Payments (ваш), Stripe (внешний), Ledger (ваш). Две границы — исходящая от Payments к Stripe и входящая от Stripe к Payments через вебхук.

Здесь важны два паттерна, которые не возникли в чистом виде в главах 24-25:

  • Тип external_system, который заставляет указывать два обязательных поля (ext.vendor, ext.contract.url), так что внешние зависимости всегда документируют, что они такое и где живёт их контракт.
  • Вебхуки как входящие интерфейсы на вашем модуле, при этом внешняя система — вызывающий. Новички часто пытаются поместить обработчик вебхука на внешнюю сторону; это неверно — внешняя система доставляет вызов, ваш обработчик его принимает.

Если правильно осмыслить эти два пункта, моделирование интеграций становится прямолинейным. Остальная часть главы — пример.

shop-payments/
├── package.archspace
├── payments.arch # Payments, Ledger
├── integrations.arch # Stripe (external_system)
├── processes.arch
└── views.arch

Манифест:

package: acme.payments
version: "0.1.0"
// Выбираем рабочий набор явно — никогда не делайте `use *` по стандартной
// библиотеке (это затягивает всю поверхность и связывает вас с типами,
// которыми вы не пользуетесь).
use service, external_system, rest_create, webhook from arch.backend

Корневой манифест объявляет package: (непрозрачную единицу разрешения имён); name: нужен для вложенных пространств, которые интеграции на одну систему вроде этой не требуются. Список use — это явная, легко обнаруживаемая палитра для всего пакета: любой, кто открыл манифест, видит ровно тот словарь, что в игре.

payments.arch:

service Payments {
aspect team: "Payments"
repo.url: "https://github.com/acme/payments"
ext.runbook.url: "https://wiki.acme.com/payments-runbook"
ext.console.url: "https://dashboard.stripe.com/acme"
aspect {
domain: "Payments"
security.zone: "PCI"
}
"Card payment processing. Authorizes via Stripe; receives webhooks for async outcomes."
rest_create authorize {
"Customer-facing authorize. Calls Stripe synchronously, returns a hold token."
}
rest_create capture {
"Capture a previously authorized hold."
}
rest_create refund {
"Refund a captured payment."
}
webhook paymentWebhook {
"Inbound webhook from Stripe — payment_succeeded, payment_failed, refund.created.
Verified by HMAC signature on the request."
}
}
service Ledger {
aspect team: "Finance"
repo.url: "https://github.com/acme/ledger"
aspect {
domain: "Finance"
security.zone: "Internal"
}
"Immutable financial record."
rest_create record
}

Ключевой момент: webhook paymentWebhook — это асинхронный интерфейс-обработчик на Payments (webhook асинхронен). На нём не объявлена подписка — соединение делает ребро процесса Stripe > Payments.paymentWebhook (ниже); ребро и есть подписка. С точки зрения модели поведение Stripe запускает обработчик; то, что оно приходит через HTTP POST, — деталь реализации.

Обратите внимание на поля repo.url, ext.runbook.url и ext.console.url. Структурированные внешние ссылки место в полях, а не в описании — они превращают модуль в узел, который направляет читателя к настоящему репозиторию, ранбуку и консоли поставщика.

Правило. Делайте каждый модуль узлом ссылок. Платёжный сервис должен указывать на свой репозиторий, свой ранбук и дашборд поставщика. Ссылки — это дешёвое поэлементное украшение, и именно здесь модель оправдывает себя в повседневной работе.

Правило. security.zone здесь — это уровень соответствия требованиям (PCI / External / Internal), а не сетевое размещение. Сетевая плоскость — в каком сегменте модуль физически находится и какие сегменты могут достучаться до каких — это отдельная аспектная плоскость (network-zone), которую мы моделируем в главе 28. Держите эти две плоскости раздельно.

integrations.arch:

external_system Stripe {
aspect team: "External"
ext.vendor: "Stripe"
ext.contract.url: "https://stripe.com/docs/api"
ext.console.url: "https://dashboard.stripe.com"
aspect {
domain: "Payments"
security.zone: "External"
}
"External card processor. Outbound HTTP calls + inbound webhooks."
rest_create charge {
"Outbound. authorize/capture/refund routed here."
}
}

Три вещи, которые стоит заметить:

  1. ext.vendor и ext.contract.url обязательные. Они идут из типа external_system стандартной библиотеки. Команда не может просто так добавить внешнюю систему, не указав, что это такое и где её документация.
  2. Stripe.charge — обычный rest_create. С точки зрения архитектуры это синхронный интерфейс — форма вызова — даже если запускает её не ваша команда.
  3. Входящие события не объявляют интерфейс на Stripe. Сторона Stripe — просто источник событий; ваш обработчик webhook paymentWebhook — место, куда они приземляются. Нет ни интерфейса события, который можно было бы повесить на Stripe, ни поля subscribes: — входящий поток — это единственное ребро процесса Stripe > Payments.paymentWebhook.

Ошибка, которой стоит избегать. Не моделируйте получатель вебхука как что-то на стороне Stripe («Stripe.webhookSender» или подобное). Получатель вебхука — ваш, это интерфейс на вашем модуле. Сторона Stripe — просто источник событий. Асимметрия здесь настоящая: Stripe тоже не моделирует вас в своей архитектуре.

processes.arch:

process #c3v7kd OutboundCharge {
Customer > Payments.authorize
Payments > Stripe.charge "synchronous HTTP POST"
try {
Payments > Ledger.record "log the hold"
} catch "declined" {
Payments > Ledger.record "log the decline"
}
}
process #n5b1qw InboundWebhook {
Stripe > Payments.paymentWebhook "HMAC-verified async delivery"
Payments > Ledger.record
}

OutboundCharge отражает синхронную сторону — клиент инициирует, Payments вызывает Stripe, Stripe отвечает, результат попадает в Ledger. try/catch явно отделяет путь отказа.

InboundWebhook — асимметричный поток. Stripe — вызывающий; вызов попадает на наш обработчик paymentWebhook. Следуя принципу из главы 7: вызывающий — это сущность, выполняющая вызов, то есть Stripe. Интерфейс живёт на получателе, Payments.

views.arch:

view PaymentBoundary {
"Both sides of the Stripe boundary, with the inbound and outbound flows."
show @@domain:"Payments"
group by @@team
}
view ExternalSurface {
"Every external system we depend on. For vendor-risk review."
show @@security.zone:"External"
}

ExternalSurface — это проекция, которая нужна тому, кто оценивает риски по поставщикам. Аспект security.zone: "External" стоит на Stripe; проекция подхватывает её и любую другую внешнюю систему в рабочем пространстве.

После валидации:

  • Диаграмма с тремя модулями — двумя вашими и Stripe в визуально отличном оформлении (внешние системы по умолчанию получают свой виджет).
  • Видны два потока процессов: исходящий синхронный платёж и входящий вебхук.
  • Входящий вебхук, нарисованный как пунктирное асинхронное ребро от Stripe к paymentWebhook, выведенное из процесса InboundWebhook — поле subscribes: не нужно.
  • Проекция «поверхности поставщиков», автоматически наполняемая по мере добавления новых внешних систем.

В этом примере одна внешняя система, один исходящий интерфейс, одно событие. Реальные интеграции бывают разными:

Несколько операций на одного поставщика. У Stripe 50+ конечных точек API. Моделируйте те, которые важны для вашей архитектуры, — обычно 3-8. Остальные не обязаны появляться, это детали реализации поставщика.

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

Системы с заменяемым поставщиком. Plaid, Stripe, Adyen — вы можете поддерживать несколько провайдеров. Определите проектный тип вроде card_processor, который фиксирует контракт (обязательный поставщик, URL контракта, эндпоинт вебхука), и вставляйте в него поставщиков. Такой тип оправдывает себя за счёт настоящих полей required и общего виджета — а не пустой обёрткой. Тема главы 29.

Провайдеры идентификации. Auth0, Okta, внутренний SSO. Тот же паттерн, что и Stripe: external_system с rest_create login плюс асинхронный интерфейс-обработчик на вашей стороне (webhook userWebhook), который соединяет ребро процесса (Auth0 > You.userWebhook). Форма не меняется с поставщиком.

Только асинхронные интеграции. Некоторые поставщики только отправляют (вебхуки, без вызываемого API). Просто объявите асинхронный интерфейс-обработчик на вашем получателе и входящее ребро процесса; пропустите исходящий синхронный вызов. Модель прекрасно это обрабатывает.

Единственное правило, которое стоит усвоить:

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

Эта фраза — вся глава. Если вы можете ответить «кто вызывает?», вы можете смоделировать любую интеграцию. Грамматика (Caller > Callee.Interface) и тип external_system делают остальное.

  • Используйте external_system для всего, что вне вашей операционной границы; обязательные ext.vendor и ext.contract.url поддерживают честность документации.
  • Получатели вебхуков — это асинхронные интерфейсы-обработчики на вашем модуле; внешняя система — вызывающий.
  • Ребро процесса (External > You.handler) соединяет их push с вашим асинхронным обработчиком — поля subscribes: нет; ребро и есть подписка.
  • Моделируйте 3-8 интерфейсов на поставщика — те, с которыми общается ваша архитектура, а не весь API поставщика.
  • Проекции рисков по поставщикам сами получаются из show @@security.zone:"External", как только у внешних систем стоит этот аспект.
  • Украшайте каждый модуль его реальными ссылками (repo.url, ext.runbook.url, ext.console.url) как полями — модель это узел, а не мёртвая картинка.
  • security.zone — это уровень соответствия требованиям; сетевая плоскость (network-zone) отдельна — см. главу 28.

Глава 28: Границы соответствия требованиям → — аспекты, проекции и правила валидации, работающие вместе как механизм управления.