Every Node service that talks to Stripe, Twilio, a partner REST API, or any system it doesn't control has a decision to make: does vendor behavior stay contained at the edge, or does it spread into the rest of the codebase. The integration boundary is the deliberate seam that keeps it contained - a single place that owns the outbound call's timeout, its retry policy, its error translation, and its test double, so the rest of your application only ever talks to your interface, never the vendor's directly.
This page is the mental model behind the rest of the section: why that seam exists, what it actually has to own, and the vocabulary - adapter, anti-corruption layer, vendor coupling - that Integrations Basics, Stripe, Twilio & SDK Patterns, and Webhook Verification all build on.
An integration boundary is a single owned seam - typically a service module - that every call to a third-party system passes through, so vendor-specific behavior never leaks into route handlers or domain logic.
Insight: Without a boundary, a vendor's outage, rate limit, error shape, or API change becomes scattered across every place in the codebase that happened to call it directly, instead of one place you can fix.
Key Concepts:adapter, anti-corruption layer, vendor coupling, error mapping, idempotency at the boundary.
When to Use This Model: Adding any new third-party dependency (payments, messaging, partner APIs), deciding SDK versus raw HTTP, designing inbound webhook handlers, and reasoning about blast radius when a vendor has an incident.
Limitations/Trade-offs: A boundary adds a layer of indirection and code you have to maintain even for "simple" calls - worth it once a vendor matters to your reliability story, overkill for a call you'd delete in a week anyway.
Related Topics: retries and outbound resilience, circuit breakers, webhook signature verification, timeouts.
Think of the integration boundary the way you'd think of a border crossing. Goods and people cross constantly, but nothing gets waved straight through into the interior - everything is inspected, documented in the local system's terms, and only then allowed to move freely inside. A country doesn't rewrite its laws every time a foreign visitor arrives with different customs; the border is where translation happens, once, so the interior stays consistent no matter how many different countries it deals with.
A vendor SDK is a foreign country's customs: it has its own error types, its own retry defaults, its own rate limits, its own notion of what "success" means. Calling stripe.paymentIntents.create() directly from a route handler is like letting foreign law apply inside your own building - a StripeCardError now has to be understood by code that has no business knowing what Stripe is, and if you ever add a second payment provider, every call site that checked for Stripe's specific error shape has to be found and duplicated for the new one.
The integration boundary is the border crossing: a service module - paymentsService, smsService - that's the only code in your application allowed to import the vendor SDK directly. Everything else calls that service, gets back your own types, and never sees a vendor-specific shape at all.
Concretely, the boundary is where several concerns that are easy to scatter get concentrated into one place. Error mapping translates the vendor's failure modes into your own error taxonomy, so a route handler can respond consistently regardless of which vendor actually failed underneath.
export function mapStripeError(err: unknown): AppError { if (err instanceof Stripe.errors.StripeCardError) { return new AppError("card_declined", 402, { code: err.code }); } if (err instanceof Stripe.errors.StripeAPIError) { return new AppError("payment_provider_unavailable", 503); } return new AppError("internal", 500);}
That one function is what lets a route handler catch AppError and never learn Stripe exists. The same boundary is also where a timeout gets set explicitly - SDK defaults are frequently tuned for the vendor's convenience, not for your request's latency budget, so the boundary is where you impose your own deadline regardless of what the library ships with. And it's where idempotency for outbound calls belongs: passing a stable idempotency key on a payment capture is a boundary concern, not something every call site should have to remember to do correctly on its own.
The direction matters less than the discipline: inbound integrations - a webhook Stripe sends you when a payment succeeds - are the same boundary problem in reverse. A webhook handler is where an external system's data enters your system, and it needs the same containment: verify the signature before trusting anything in the payload, translate the vendor's event shape into your own, and only then hand it to your domain logic. Webhook Verification covers the specific mechanics of proving a webhook actually came from the vendor it claims to.
The boundary is also the natural place to layer resilience policy, because it's the one spot that knows every property of the call: which vendor, what the acceptable timeout is, whether retrying is even safe for this operation. Retries with backoff smooth over transient failures; a circuit breaker stops hammering a vendor that's clearly down, failing fast instead of queuing up a pile of doomed requests behind a slow dependency. Both belong at the boundary, not scattered per call site - Retry & Outbound Resilience and Circuit Breakers cover the mechanics in depth.
There's a real trade-off worth naming honestly: a boundary you build too generically, trying to abstract "any payment provider" before you have a second one, tends to produce a leaky abstraction - an interface shaped by guesses about a vendor you haven't integrated yet, that ends up fitting neither vendor well. The pragmatic version of this pattern wraps one vendor cleanly first, and only generalizes the interface once a second, real vendor exists to design against.
Testability is where the boundary pays for itself most visibly. Because nothing outside the service module imports the vendor SDK, tests can swap in a mock or a vendor sandbox at exactly that one seam, without touching HTTP routing or reaching across the network in CI. Observability follows the same logic: a single wrapped call site is also the natural place to attach an OpenTelemetry span per outbound request, correlate it with your own request id, and redact secrets in logs before they leave the boundary.
Integration style
Strength
Weakness
Best Fit
Synchronous SDK call
Simple mental model; immediate result
Blocks the caller for the vendor's full latency; couples your uptime to theirs
Fast, low-risk calls (lookups, validation)
Async/webhook-driven
Caller isn't blocked on vendor latency; naturally resilient to slow vendors
More moving parts; requires signature verification and idempotent handling
Payments, long-running vendor-side processing
Polling
No inbound endpoint to expose or secure
Wastes requests when nothing changed; adds latency proportional to poll interval
"The official SDK already handles resilience for me." SDKs handle protocol details - request signing, serialization - not your resilience policy; timeouts, retry counts, and circuit-breaking are still decisions you have to make explicitly.
"Wrapping a 'simple' vendor call in a service module is unnecessary overhead." The call that stays simple forever doesn't need it - but the moment error handling, retries, or a second vendor enters the picture, the wrapper is what keeps that complexity from spreading.
"Webhooks are just another API endpoint - no special handling needed." An unverified webhook handler trusts whatever hits the URL; without signature verification, anyone who finds the endpoint can forge events.
"One shared HTTP client configuration works for every vendor." Different vendors have different acceptable timeouts, retry semantics, and rate limits - a one-size-fits-all client config either under-serves fast vendors or over-trusts slow ones.
"Retrying a failed vendor call is always safe." Retrying a non-idempotent operation (like a payment capture without an idempotency key) can duplicate the side effect - safety depends on the operation, not just on whether the call failed.
The single seam in your codebase - typically one service module per vendor - that every call to a third-party system passes through, so vendor-specific types, errors, and behavior never spread into route handlers or domain logic.
Why not just call the vendor SDK directly from a route handler for something simple?
It works until it doesn't - the first time you need a timeout, an error translated for the client, a retry, or a test that doesn't hit the network, you either add the boundary retroactively across every call site or you wish you'd had it from the start.
Does the SDK's built-in retry logic replace the need for my own boundary?
No - SDK retry defaults are general-purpose and vendor-tuned, not aware of your request's latency budget or which of your operations are safe to retry; the boundary is where you decide that deliberately, often disabling the SDK's own retries in favor of your own policy.
How does error mapping actually help downstream code?
It lets a route handler catch one consistent error type from your own taxonomy instead of needing to know every vendor-specific exception shape, which means adding or swapping a vendor never requires touching route-level error handling.
Are inbound webhooks part of the "integration boundary" concept, or separate?
Part of it - a webhook handler is where an external system's data enters your system, and it needs the same containment discipline as an outbound call: verify it's genuinely from the vendor, translate its shape, and only then hand it to domain logic.
What's the risk of generalizing a boundary for "any vendor" too early?
You end up designing an abstraction shaped by guesses rather than a real second implementation, which tends to fit neither vendor well - a leaky abstraction that's harder to work with than two separate, honest wrappers would have been.
Why does idempotency belong at the boundary rather than in the route handler?
Because the boundary is the one place that knows the specific vendor operation being called and whether it's safe to retry - a route handler shouldn't have to know that capturing a payment needs a stable key while sending a status lookup doesn't.
How does a boundary make testing easier?
Because the vendor SDK is only imported inside the service module, tests can mock or stub exactly that module without needing to reach the network or understand HTTP routing - the rest of the application's tests stay vendor-agnostic.
Should every third-party call go through a formal service, even a one-off script?
Not necessarily - the boundary earns its cost once a vendor call matters to production reliability or gets called from more than one place; a genuinely one-off internal script calling an API once doesn't need the same ceremony.
What's the difference between a circuit breaker and a retry at the boundary?
A retry assumes the next attempt might succeed and tries again after a backoff; a circuit breaker recognizes a vendor is consistently failing and stops sending requests for a cooldown period, protecting both your system and the struggling vendor from a pile of doomed retries.
Why does the boundary matter for observability, not just error handling?
Because it's the one place every outbound call to a given vendor passes through, it's the natural spot to attach a tracing span, log a correlation id, and redact secrets consistently - scattered call sites would each have to remember to do all three correctly.