DEV Community

arun rajkumar
arun rajkumar

Posted on

How We Built Atoa's Payment Infrastructure with 15 NestJS Microservices (And What Took the Most Figuring Out)

Open banking in the UK just crossed 24 billion successful API calls in 2025. Payment initiation grew 53% year-on-year. That's not a trend — that's a tectonic shift.

We've been building in this space for years now. Atoa processes open banking payments for UK merchants — Pay by Link, QR codes, POS terminals, online checkouts. Half the cost of card payments. Instant settlement. No Visa or Mastercard in the middle.

And our entire payment infrastructure runs on 15 NestJS microservices.

Here's what we got right. And what took the most figuring out.

Why NestJS for Payments

When I started architecting Atoa's backend, the decision came down to two things: developer velocity and reliability.

We were a small team. Mostly freshers and interns — people I'd bet on because of intent, not because of their resume. One of our earliest hires joined as a fresher. Five years later, he's our Technology Architect making every major tech decision. Another started as an intern. He coded our Open Banking module end-to-end.

These people needed a framework that was opinionated enough to enforce structure but flexible enough to let them move fast. NestJS gave us that. Decorators, dependency injection, modular architecture — it reads like a blueprint, not spaghetti.

We chose TypeScript everywhere. Zod for runtime validation. If a payment request hits our API with a malformed amount or missing merchant ID, it dies at the gate. In payments, a silent failure isn't a bug — it's someone's revenue disappearing.

The 15-Service Architecture

Here's a simplified view of our service boundaries:

┌─────────────────────────────────────────────┐
│                API Gateway                   │
│            (Traefik v3 + Auth)               │
└──────────┬──────────┬──────────┬────────────┘
           │          │          │
    ┌──────▼──┐ ┌─────▼────┐ ┌──▼──────────┐
    │ Payment │ │ Merchant │ │ Notification │
    │ Service │ │ Service  │ │   Service    │
    └──────┬──┘ └──────────┘ └─────────────┘
           │
    ┌──────▼──────────────────────────────┐
    │        Open Banking Gateway          │
    │  (Bank API adapters, token mgmt,     │
    │   consent flows, SCA handling)       │
    └──────┬──────────────────────────────┘
           │
    ┌──────▼──┐ ┌──────────┐ ┌────────────┐
    │ Ledger  │ │ Webhook  │ │ Settlement │
    │ Service │ │ Service  │ │  Service   │
    └─────────┘ └──────────┘ └────────────┘
Enter fullscreen mode Exit fullscreen mode

Each service owns its domain. The Payment Service doesn't know how settlements work. The Merchant Service doesn't touch bank APIs. Clean boundaries.

We use Traefik v3 as our API gateway — routing, rate limiting, TLS termination, health checks. It plays beautifully with Docker and our Kubernetes setup. Our DevOps lead (Kubestronaut certified, by the way) architected the infra. The only downtime we've ever had? AWS London went down during the UK heatwave about two years ago. That wasn't on us. Everything else — 100% uptime.

The Hard Part: Every Bank is Different

Here's what the "open banking is easy" crowd doesn't tell you.

Every UK bank implements authentication differently. What works in sandbox breaks in production. Extra consent screens. Different redirect logic. Strong Customer Authentication flows that behave one way on mobile, another on desktop.

We built an adapter layer inside our Open Banking Gateway. Each bank gets its own adapter that normalises the authentication flow into a consistent interface. When a merchant's customer pays via Atoa, they don't know (or care) that Barclays handles redirects differently than Monzo.

// Simplified bank adapter pattern
interface BankAdapter {
  initiatePayment(params: PaymentInitParams): Promise<ConsentUrl>;
  handleCallback(bankResponse: unknown): Promise<PaymentResult>;
  getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
}

// Each bank gets its own implementation
class BarclaysAdapter implements BankAdapter {
  async initiatePayment(params: PaymentInitParams) {
    // Barclays-specific consent flow
    // Handles their unique SCA requirements
    const validated = PaymentInitSchema.parse(params); // Zod validation
    // ... bank-specific logic
  }
}
Enter fullscreen mode Exit fullscreen mode

This adapter pattern saved us hundreds of hours. New bank? New adapter. Same interface. No touching the Payment Service.

What Took the Most Figuring Out: Local Development

Fifteen microservices. Each with its own database connection, environment variables, and dependencies. Onboarding a new developer used to take two weeks. Two weeks of "why isn't this service connecting" and "which env file do I need."

We fixed this. I wrote about it in detail on dev.to — how we went from 2 weeks to 1 day for developer onboarding. The short version: Docker Compose orchestration, shared environment templates, and a single make dev command that spins up the entire stack.

One of our developers joined with a B.Sc and "Googling" as his only listed skill. He was shipping code within days, not weeks. That's the real test of your developer experience. Not whether your senior architect can navigate it. Whether someone brand new can.

Lessons for Developers Building Payment Systems

1. Validate at every boundary. Zod on the API layer. Zod between services. Payments don't forgive data inconsistencies.

2. Idempotency is not optional. Network retries happen. Bank callbacks come twice. Every payment mutation needs an idempotency key. We learned this the hard way.

3. Treat webhooks as first-class citizens. Merchants need real-time payment status. We built a dedicated Webhook Service with retry logic, dead-letter queues, and delivery receipts. It's not glamorous. It's essential.

4. Abstract your bank integrations. The adapter pattern isn't clever engineering — it's survival. Banks change APIs. New banks join. Your payment logic should never care.

5. Invest in local dev early. The time you save on onboarding compounds. Every developer you hire benefits. Every feature ships faster.

Why Open Banking Over Cards

I'll be direct. If you're building a payment flow for the UK market in 2026, you should seriously consider open banking.

Card payments: ~1.5-2.5% processing fees, T+2 settlement, chargebacks, PCI-DSS compliance overhead.

Open banking via Atoa: lower fees, instant settlement, no chargebacks (because the customer authenticates with their bank), and simpler compliance.

We're FCA-authorised. ISO-27001 and SOC2 certified. We have SDKs for Flutter (atoa_sdk, atoa_flutter_sdk), a Vue-based Web Client SDK, a WooCommerce plugin, and full API docs at docs.atoa.me.

If you want to test it yourself: docs.atoa.me/api-reference/Payment/process-payment. Sandbox is free. Takes about 10 minutes to get your first payment flowing.

What's Next

We're investing heavily in AI-assisted code migration and developer tooling. The 15-service architecture is growing. But the principles stay the same: clean boundaries, validate everything, and build for the developer who joins tomorrow, not just the one who built it yesterday.

If you're building in payments — especially in the UK open banking space — I'd love to hear how you're approaching it. Drop a comment or find me on X: @mickyarun.

Top comments (0)