DEV Community

Hassan
Hassan

Posted on

Why Market Five Breaks Your Payment Backend

The integration debt that compounds quietly across payment providers, and the architecture decision that stops it.

The first payment integration is never the problem. You pick Stripe for Germany, wire up the webhook endpoint, handle payment_intent.succeeded, payment_intent.payment_failed, and charge.refunded, build a nightly reconciliation job, and ship. It takes five weeks. Everything works.

Market two takes four weeks. You add a second provider, extend the webhook handler with a conditional block, duplicate the reconciliation logic with minor differences for the new export format. The provider uses different event names for the same lifecycle states. You normalize them manually. Still fine.

By market five, the PR review for a new provider integration takes four days before anyone approves it, because no one fully understands the webhook handler anymore. Each provider fails differently. Refund flows are all slightly wrong in different ways. The reconciliation job is a 700-line script that runs for three hours and occasionally produces negative discrepancies no one can explain without reading the original engineer's Slack messages from two years ago.

This is not a code quality problem. It is a structural one, caused by a decision made at integration one that does not show its cost until integration five.

The First Integration Sets a Pattern

The natural approach when building the first payment integration is to model the provider's API surface directly. Stripe gives you PaymentIntent, Charge, Refund, and Webhook. You build types that mirror those, write handlers that know they are talking to Stripe, and ship. The code is clean, specific, and correct.

The problem is that Stripe-shaped code only supports Stripe. Every subsequent provider has a different data model, different webhook vocabulary, different lifecycle semantics, different retry contracts, and different reconciliation export formats. When you add provider two, you have two options: abstract, or duplicate. Most teams duplicate, because abstracting at that point requires a refactor that feels risky for a provider that has not shipped yet.

That decision does not hurt immediately. It hurts when:

A provider sends a duplicate webhook for an event the others send once. Your idempotency logic was written for Stripe's guarantee, not this provider's behavior. Payments start double-counting in the settlement report.

A payment that fails at provider A needs retrying at provider B. There is no shared payment entity in your system — just provider-specific records with incompatible status enums. You build the retry logic as a special case, which becomes technical debt the moment you add provider three.

A compliance audit asks for a full payment lifecycle audit trail across all markets. You can produce it per-provider. You cannot produce a unified view without writing a query layer that joins across three separate data schemas authored at different times.

None of these are hypothetical. They are the standard second-year failures of a payment backend built integration-by-integration without a shared domain model.

What Happens Without the Abstraction

At one client operating across five European markets, each payment integration had been built at the time of market entry — different retry policies, different webhook validation approaches, different error categorizations. The reconciliation process required a separate script per provider because each had a different export format, and those formats had drifted as providers released API updates.

A new market entry, which should have been a contained backend task, required touching all five existing integrations to normalize the shared error taxonomy the new provider's SDK introduced. Engineers who had not written the original integrations were modifying them. Code review was slow. The timeline slipped.

The engineering team was not slow. The code was not poorly written. The problem was that five provider adapters had been built in isolation rather than against a shared interface. Every cross-provider operation required coordinating across five data models with no common language between them.

The rebuild took one backend engineer six weeks: a shared Payment domain model with a normalized lifecycle (created, authorized, captured, settled, failed, refunded), a provider adapter pattern, and reconciliation logic that ran once against the shared model instead of per-provider. Three additional market entries since then have required writing one adapter each. The reconciliation job has not been touched.

The Architecture That Survives Scale

The abstraction layer that makes multi-provider payment backends maintainable has three components.

A shared Payment domain model. Define the payment lifecycle in your domain vocabulary, not your first provider's vocabulary. PaymentState.Authorized is not PaymentIntent.requires_capture. It is a domain concept that maps to different provider states per context. Internal systems — reconciliation, reporting, refund orchestration, customer notifications — work against this model exclusively. They never import provider-specific types.

Provider adapters with explicit contracts. Each adapter handles translation for one provider: inbound webhook events to domain events, outbound commands to provider API calls. The adapter boundary contains the inconsistencies. Stripe's idempotency contract is handled inside the Stripe adapter. If Adyen's is different, that is the adapter's problem, not the reconciliation engine's.

A minimal TypeScript adapter interface covers the surface area:

interface PaymentProviderAdapter {
  initiatePayment(request: PaymentRequest): Promise<PaymentResult>;
  handleWebhook(raw: Buffer, signature: string): Promise<DomainEvent[]>;
  issueRefund(paymentId: string, amount: Money): Promise<RefundResult>;
  reconcile(from: Date, to: Date): AsyncIterable<ReconciliationRecord>;
}
Enter fullscreen mode Exit fullscreen mode

Every provider implementation satisfies this contract. The reconciliation engine, retry scheduler, and refund handler call only the interface. Adding a new provider does not change those systems.

Idempotency at the domain level. Build idempotency around a client-generated key your system controls, not around provider-specific guarantees. Store the key with the payment record. Deduplicate incoming webhooks against it before processing. Provider idempotency contracts vary significantly between vendors and change between API versions. Depending on them makes your idempotency guarantees only as strong as your weakest provider's — which you discover during an incident.

When to Build It

The abstraction is not technically complex. The reason most teams skip it at integration one is that the engineering team is also building the core product, and the one-provider path is shorter. That is a rational call in the moment.

The teams that pay the rebuild cost earliest — at integration two or three, before the pattern is entrenched — do so because they had dedicated backend capacity for the refactor alongside the integration work. The teams that delay do so because the engineers who understand the existing integrations are maintaining them while also shipping the next one.

Senior backend engineers with payments domain experience in Germany take 38-54 days to hire at the junior end and considerably longer at senior level (Source: Stack Overflow Developer Survey, DACH 2025). For a fintech team entering two new markets per quarter, that hiring timeline conflicts with delivery if the refactor and the next integration are scheduled concurrently on the same people.

The abstraction is a two-sprint engineering project. The rebuild, once you have five providers, is a six-week one. The earlier it happens, the cheaper it is. That arithmetic is straightforward. What is less obvious is that it requires engineering capacity that is not already allocated to maintaining what you shipped.

Key Takeaways

  • Multi-provider payment backends accumulate structural debt when each integration is built against the previous provider's API surface rather than a shared domain model. The cost is invisible until integration four or five.
  • Define a Payment domain model before integration two. Internal systems should never import provider-specific types.
  • Provider adapters with a common interface contain inconsistencies at the integration boundary. Reconciliation, retry logic, and refund orchestration stay stable as providers change.
  • Build idempotency around a key your system controls. Provider idempotency guarantees are not consistent enough to be your foundation.
  • The abstraction rebuild is a two-sprint task at integration two and a six-week task at integration five. Scheduling it requires engineering capacity that is not already allocated to delivery — which means planning it ahead of the next market expansion, not alongside it.

SifrVentures builds dedicated engineering teams for tech companies. Based in Berlin. Learn how we work | Read more on our blog

Top comments (0)