27. An External Integration
Real systems don’t run in isolation. They call payment processors, talk to identity providers, accept webhooks from analytics tools, hand off shipping requests to carriers. The architectural decisions at the boundary — which calls cross your perimeter, what comes back, what to model and what to leave out — are different from anything covered in the previous two chapters.
This chapter models one such integration end to end: a payments service that integrates with Stripe (a payment processor) for outbound charges and accepts webhooks from Stripe for status updates. Compact, but every concern is real.
What we’re modeling
Section titled “What we’re modeling”Customer ──► Payments.authorizePayments ──► Stripe.charge (outbound, sync HTTP)Stripe ──► Payments.paymentWebhook (inbound, async)Payments ──► Ledger.recordThree ArchLang modules: Payments (yours), Stripe (external), Ledger (yours). Two boundaries — outbound from Payments to Stripe, inbound from Stripe to Payments via a webhook.
Why this is a separate worked design
Section titled “Why this is a separate worked design”Two patterns matter here that didn’t come up cleanly in Chapters 24-25:
- The
external_systemtype, which forces two required fields (ext.vendor,ext.contract.url) so external dependencies always document what they are and where their contract lives. - Webhooks as inbound interfaces on your module, with the external system as the caller. Newcomers often try to put the webhook handler on the external side; that’s wrong — the external system delivers the call; your handler receives it.
If you get those two right, integration modeling is straightforward. The rest of this chapter is the example.
Package layout
Section titled “Package layout”shop-payments/├── package.archspace├── payments.arch # Payments, Ledger├── integrations.arch # Stripe (external_system)├── processes.arch└── views.archManifest:
package: acme.paymentsversion: "0.1.0"
// Pick the working set explicitly — never `use *` the stdlib (it drags in// the whole surface and couples you to types you don't use).use service, external_system, rest_create, webhook from arch.backendA root manifest declares package: (the opaque unit of resolution); name: is for nested spaces, which a single-system integration like this doesn’t need. The use list is the explicit, discoverable palette for the whole package — anyone opening the manifest sees exactly what vocabulary is in play.
The modules
Section titled “The modules”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}The crucial bit: webhook paymentWebhook is an async handler interface on Payments (a webhook is async). There’s no subscription declared on it — the connection is the process edge Stripe > Payments.paymentWebhook (below); the edge is the subscription. From the model’s perspective, Stripe’s behavior triggers the handler; the fact that it arrives over an HTTP POST is implementation detail.
Note the repo.url, ext.runbook.url, and ext.console.url fields. Structured external links belong in fields, not the description — they turn the module into a hub that routes a reader to the real repo, runbook, and vendor console.
Rule. Make every module a links hub. A payments service should point at its repo, its runbook, and the vendor’s dashboard. Links are cheap per-element decoration and they’re where the model earns its keep day to day.
Rule.
security.zonehere is a compliance tier (PCI / External / Internal), not a network placement. The network plane — which segment a module physically sits in, and which segments may reach which — is a separate aspect plane (network-zone), modeled in Chapter 28. Keep the two planes distinct.
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." }}Three things to notice:
ext.vendorandext.contract.urlare required. They come from the stdlibexternal_systemtype. The team can’t get away with adding an external system without saying what it is and where its docs live.Stripe.chargeis a normalrest_create. From the architecture’s perspective it’s a synchronous interface — the call shape — even though the team isn’t the one running it.- The inbound events declare no interface on Stripe. Stripe’s side is just an event source; your
webhook paymentWebhookhandler is where they land. There’s no event interface to put on Stripe and nosubscribes:field — the inbound flow is the single process edgeStripe > Payments.paymentWebhook.
Mistake to avoid. Don’t model the webhook receiver as something on Stripe (“Stripe.webhookSender” or similar). The webhook receiver is yours — it’s an interface on your module. Stripe’s side is just an event source. The asymmetry is real: Stripe doesn’t model you in their architecture either.
The processes
Section titled “The processes”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 captures the synchronous side — Customer initiates, Payments calls Stripe, Stripe responds, the result lands in Ledger. The try/catch makes the decline path explicit.
InboundWebhook is the asymmetric flow. Stripe is the caller; the call lands on our paymentWebhook handler. Following the principle from Chapter 7: the caller is the entity making the call, which is Stripe. The interface lives on the receiver, 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 is the view a vendor-risk reviewer wants. The security.zone: "External" aspect is set on Stripe; the view picks up that and any other external system in the workspace.
What this gives you
Section titled “What this gives you”After validation:
- A diagram with three modules — your two, Stripe in a visibly distinct treatment (external systems get their own widget by default).
- Two process flows visible: the outbound sync charge and the inbound webhook.
- The inbound webhook drawn as a dashed async edge from
StripetopaymentWebhook, derived from theInboundWebhookprocess — nosubscribes:field needed. - A “vendor surface” view automatically populated when more external systems get added.
Patterns for richer integrations
Section titled “Patterns for richer integrations”This example has one external system, one outbound interface, one event. Real integrations vary:
Multiple operations per vendor. Stripe has 50+ API endpoints. Model the ones your architecture cares about — usually 3-8. The rest don’t need to appear; they’re vendor implementation detail.
Webhook routing through one endpoint. Many systems land all webhooks on a single URL and dispatch internally. That’s still modeled as multiple interfaces — the dispatch is implementation. From the model’s view, there are N webhook flows.
Vendor-replaceable systems. Plaid, Stripe, Adyen — you might support multiple providers. Define a project-local type like card_processor that pins the contract (required vendor, contract URL, webhook endpoint) and slot vendors into it. That type earns its keep through real required fields and a shared widget — not a do-nothing wrapper. Subject of Chapter 29.
Identity providers. Auth0, Okta, internal SSO. Same pattern as Stripe: an external_system with rest_create login, plus an async webhook handler on your side (webhook userWebhook) that a process edge connects (Auth0 > You.userWebhook). The shape doesn’t change with the vendor.
Async-only integrations. Some vendors only push (webhooks, no callable API). Just declare the async handler interface on your receiver and the inbound process edge; skip the outbound sync call. The model handles it fine.
Boundary discipline
Section titled “Boundary discipline”The single rule worth internalizing:
Calls go into your perimeter; you receive them. Calls leave your perimeter; you originate them. Always model who calls; the receiver is always the thing whose interface is invoked.
That sentence is the whole chapter. If you can answer “who calls?” you can model any integration. The grammar (Caller > Callee.Interface) and the external_system type do the rest.
Summary
Section titled “Summary”- Use
external_systemfor things outside your operational boundary; requiredext.vendorandext.contract.urlkeep documentation honest. - Webhook receivers are async handler interfaces on your module; the external system is the caller.
- A process edge (
External > You.handler) connects their push to your async handler — there’s nosubscribes:field; the edge is the subscription. - Model 3-8 interfaces per vendor — the ones your architecture talks to, not the vendor’s full API.
- Vendor-risk views fall out of
show @@security.zone:"External"once external systems carry that aspect. - Decorate every module with its real-world links (
repo.url,ext.runbook.url,ext.console.url) as fields — the model is a hub, not a dead picture. security.zoneis the compliance tier; the network plane (network-zone) is separate — see Chapter 28.
What’s next
Section titled “What’s next”Chapter 28: Compliance Boundaries → — aspects, views, and validation rules working together as governance.