Enterprise SaaS lives or dies on integrations. Calendar sync through Microsoft Graph, billing through Stripe, transactional email through SendGrid — and every one of them starts as 'just a service class' called from wherever it's needed. Eighteen months later, Graph API calls are scattered across 30 files, three subsystems handle Stripe webhooks slightly differently, and nobody can answer 'what happens if SendGrid is down' without reading code. I've inherited that codebase. I've also had the chance to rebuild it properly.
The hub: one owner per external system
The architecture that survives is a hub: every external provider gets exactly one module that owns all communication with it — authentication, rate limiting, retries, pagination, webhook verification. Domain code never imports an SDK. It talks to the hub through two narrow surfaces: commands going out ('send this email', 'create this subscription') and canonical events coming in ('payment.succeeded', 'calendar.event.updated'). The hub is the only place that knows Stripe spells things one way and Graph spells them another.
Anti-corruption layers are the point, not the overhead
Each provider module translates external payloads into your domain's canonical types at the boundary. This looks like ceremony until the day Stripe deprecates an API version or you add a second email provider. When SendGrid had a regional outage, swapping in a fallback provider touched one adapter — because nothing outside the hub knew SendGrid existed.
// Domain code depends on this — never on stripe-node or @microsoft/microsoft-graph-client.
interface PaymentGateway {
createSubscription(cmd: CreateSubscriptionCommand): Promise<SubscriptionResult>;
cancelSubscription(subscriptionId: string): Promise<void>;
}
// Inbound: every provider webhook is normalized to a canonical event.
interface CanonicalEvent {
id: string; // provider event id — the dedupe key
source: 'stripe' | 'msgraph' | 'sendgrid';
type: string; // 'payment.succeeded', 'email.bounced', ...
occurredAt: string;
tenantId: string;
payload: Record<string, unknown>;
}
// One pipeline: verify signature -> dedupe -> normalize -> publish to internal bus.Webhooks deserve a pipeline, not endpoints
Inbound is where integration codebases really rot, because each webhook gets written under deadline pressure. Resist it: build one ingestion pipeline with per-provider steps only where genuinely necessary. Verify the signature, persist the raw event immediately, ack fast, then process async. Persisting before processing sounds pedantic until a deploy bug makes you drop four hours of Stripe events — with the raw log, that's a replay command; without it, it's a support ticket avalanche.
- Ack webhooks in under a second; providers time out and retry, and slow handlers turn their retry storm into your outage.
- Dedupe on provider event ID — every provider on this list delivers at-least-once.
- Store raw payloads with a retention policy; replayability is the difference between a bug and an incident.
- Handle Graph subscription renewals as first-class scheduled work — expired subscriptions fail silently, which is the worst way to fail.
- Respect Retry-After on 429s per provider; Graph in particular will throttle you harder if you don't.
Reliability semantics live in the hub
Retries, circuit breakers, idempotency keys, rate-limit budgets — these are provider-relationship concerns, and they belong in the hub, once. Outbound commands go through a queue with per-provider backoff; Stripe calls carry idempotency keys derived from the triggering domain event; a circuit breaker per provider stops a Graph brownout from tying up every worker in the fleet. When domain code implements its own retry loop around a hub call, that's the first strand of spaghetti — the hub's contract should be strong enough that callers never feel the need.
An integration architecture is judged not by how it works on a good day, but by how many files you touch when a provider changes its mind.
Start smaller than this, but draw the boundary now
You don't need an integration platform team to do this. The first version is a folder per provider, a canonical event type, and the rule that SDK imports outside the hub fail code review. The architecture isn't the infrastructure — it's the boundary. Enforce the boundary from the first integration, and the second and tenth integrations get cheaper instead of exponentially more entangled.