AboutExpertiseProjectsJourneyBlogContact
Discuss
AboutExpertiseProjectsJourneyBlogContactDiscuss

Yao David Logan

Software Engineer fullstack specialized in SaaS, business automation and scalable web/mobile platforms.

NavigationExpertiseProjectsJourneyBlogContact
LinksGitHubLinkedInEmail
© 2026 Yao David Logan. All rights reserved.
Microservices in Node.js without debt — field notes from a multi-surface fintech platform
Back to blog

Tech

Xin@

Microservices in Node.js without debt — field notes from a multi-surface fintech platform

YDLYao David Logan16 min readSeptember 18, 2026

CategoryTech

Read16 min read

PublishedSeptember 18, 2026

Views1

Shares0

Comments0

Contents
  1. 011. The context
  2. 022. Symptoms that would have made me retreat
  3. 033. The approach
  4. 044. Field story: a webhook that retried 200 times
  5. 055. PostgreSQL + Redis — who does what
  6. 066. The admin WebSocket
  7. 077. Schema migrations without downtime
  8. 088. Pitfalls to avoid
  9. 099. The verdict
  10. 1010. Closing

Field report on the Node.js microservices architecture of a production financial investment platform: three services, PostgreSQL + Redis, idempotent webhooks, zero downtime.

Building a multi-surface fintech platform with Node.js microservices, without falling into the "microservice for the sake of it" trap. Field report from Nexus, seven months in production.

1. The context

Nexus Corporation needed a financial investment platform serving a public web site, a cross-platform mobile app, an admin dashboard and a technical blog — all on a unified backend. The usual trap would have been to stack microservices by mimicry. The "microservices" marketing pitch often hides a distributed monolith: painful to debug, slow to deploy, fragile under load.

I took the opposite bet: start with a well-structured modular monolith, then extract microservices only for domains that justified it — payment, scoring, sessions. Three criteria guided me: need to scale independently, different security constraints, specific deployment cadence.

Result: three services (auth/RBAC, investment, payment) behind a Node.js API gateway, sharing PostgreSQL for relational storage and Redis for cache plus volatile sessions. The surfaces (Next.js 14 web, React Native mobile, admin) consume the same API.

2. Symptoms that would have made me retreat

Before splitting, I listed the signals we saw on competing architectures:

  • 12+ minute monolithic deployments blocking hotfixes
  • Load spikes on payment routes taking down authentication
  • Payment gateway webhooks retrying in loops because the service was down
  • Schema migrations locking tables for 4 minutes in production
  • Logs drowned in noise, impossible to isolate a transaction

None of these symptoms were caused by the monolith itself. All were caused by bad domain boundaries. But splitting into services forces discipline.

3. The approach

3.1 The gateway service

The gateway is deliberately thin. It does three things: route, authenticate, log. No business logic. No direct database access. Its only autonomy: refuse a malformed request, rate-limit, and emit a trace ID propagated to all downstream services.

TypeScript
1// src/middlewares/traceContext.ts
2import { randomUUID } from "node:crypto";
3import type { Request, Response, NextFunction } from "express";
4 
5export function traceContext(req: Request, res: Response, next: NextFunction) {
6 const traceId = req.header("x-trace-id") ?? randomUUID();
7 res.setHeader("x-trace-id", traceId);
8 req.app.locals.traceId = traceId;
9 next();
10}

3.2 The auth & RBAC service

Split out for two reasons: its security surface differs from the rest (secret rotation, mandatory audit), and it must scale independently when sessions spike (typically at market open).

The contract is narrow: one POST /sessions endpoint returning a short JWT (15 min) plus a long refresh token (7 days, hashed at rest). Roles are materialized in the JWT claim, verified at every downstream request without callbacks.

3.3 The payment service

The most instructive one. It integrates several gateways (card, mobile money, bank transfer), each with its own webhook format and idempotency logic. The service exposes a unified API: createPayment, confirmPayment, refundPayment. Internally, an adapter pattern isolates each gateway in its own module.

TypeScript
1// src/services/payment/PaymentGateway.ts
2export interface PaymentGateway {
3 readonly name: string;
4 initiate(input: PaymentInitiateInput): Promise<PaymentSession>;
5 confirm(sessionId: string, evidence: WebhookPayload): Promise<PaymentResult>;
6 refund(transactionId: string, amount: number): Promise<RefundResult>;
7}

Each gateway implements the interface. The payment service picks the right one through a router based on the client's chosen method. Webhooks land on gateway-dedicated routes (/webhooks/cb, /webhooks/mtn, /webhooks/orange) then dispatch through an idempotent job.

4. Field story: a webhook that retried 200 times

First month in production, a local gateway webhook tried the same notification 200 times. The bug came from a late 200 OK acknowledgement: we were processing the webhook synchronously (DB check + update + user notification), and some transactions took 8 seconds. The gateway considered the call failed and replayed.

Fix: acknowledge receipt immediately and offload processing to a background job, idempotent by event_id.

TypeScript
1// src/controllers/payment/webhookHandler.ts
2export async function handleGatewayWebhook(req: Request, res: Response) {
3 const payload = req.body as WebhookPayload;
4 
5 // 1. Idempotency — silently refuse replays
6 const exists = await WebhookEvent.findOne({
7 where: { gatewayId: payload.gateway, eventId: payload.id },
8 });
9 if (exists) return res.status(200).json({ ok: true, deduped: true });
10 
11 // 2. Persist the event BEFORE any processing
12 await WebhookEvent.create({
13 gatewayId: payload.gateway,
14 eventId: payload.id,
15 rawBody: payload,
16 status: "received",
17 });
18 
19 // 3. Acknowledge immediately
20 res.status(200).json({ ok: true });
21 
22 // 4. Process asynchronously via queue
23 await paymentQueue.add("process-webhook", { eventId: payload.id });
24}

Three essential lines: persist before processing, acknowledge early, process via an idempotent queue. Gateway retries become harmless.

5. PostgreSQL + Redis — who does what

PostgreSQL stores what must survive: transactions, accounts, balances, audit logs. Every financial operation runs in ACID transactions with SERIALIZABLE isolation on accounts.

Redis stores what can disappear: revoked JWT sessions (TTL 15 min), per-IP rate-limit, real-time price cache (TTL 2 seconds), and a publish/subscribe to push WebSocket streams to the admin.

Rule of thumb: if the data must survive a restart, it lives in Postgres. If it can be recomputed in under 5 seconds, Redis is enough.

6. The admin WebSocket

The admin dashboard must display transactions in real time. The naive solution — polling every 2 seconds — would saturate the database. Instead: Redis Pub/Sub server-side, Socket.IO client-side. Each service publishes its events (payment.confirmed, user.kyc.validated, investment.created) on a Redis channel. The gateway subscribes and pushes to authenticated admin clients.

TypeScript
1// src/services/realtime/eventBus.ts
2import { redis } from "../../config/redis.js";
3 
4type RealtimeEvent = "payment.confirmed" | "user.kyc.validated" | "investment.created";
5 
6export async function publishEvent<T>(event: RealtimeEvent, payload: T) {
7 await redis.publish(`events:${event}`, JSON.stringify({ event, payload, at: Date.now() }));
8}

Admin side: a single multiplexed socket per user, event filtering by role, deduplication by hash on the last 30 seconds.

7. Schema migrations without downtime

Schema migrations on a production fintech platform carry high risk. Three non-negotiable rules:

  1. Backward compatible first — every migration must run without breaking the previous code version. Add a nullable column, never rename.
  2. No locking ALTER TABLE — on Postgres, ADD COLUMN ... DEFAULT rewrites the whole table (except on 11+). Always prefer nullable ADD COLUMN then batched UPDATE.
  3. One change per migration — otherwise partial rollback is unmanageable.
SQL
1-- ✅ Good: nullable add, filled progressively
2ALTER TABLE transactions ADD COLUMN risk_score INTEGER NULL;
3 
4-- ❌ Bad: locks the table for rewrite
5ALTER TABLE transactions ADD COLUMN risk_score INTEGER NOT NULL DEFAULT 0;

8. Pitfalls to avoid

PitfallSymptomFix
Premature microserviceInter-service latency > DB latencyStep back to a modular monolith
No webhook idempotencyDuplicated transactionswebhook_events table + idempotent queue
Auth in every serviceDuplicated logic, rule driftCentral auth service, JWT with claims
Logs without trace IDImpossible to isolate a requestTrace ID propagated as inter-service header
Shared schemas via DBHidden coupling, grouped deploymentsPrivate schemas per service, JSON contracts
No circuit breakerA slow gateway brings everything downTimeout + circuit breaker per adapter

9. The verdict

Seven months later, the platform runs at a measured 99.9% uptime, p95 latency on critical routes under 200 ms, and zero deployment-blocking incident in production. Three services proved enough — I never needed to go further.

The real win isn't technical. It's intent clarity: each service has a business owner, a narrow responsibility, an independent release cycle. The team can ship payment without fearing it will break authentication.

10. Closing

Microservices aren't a goal. They're a tool to solve specific problems: differentiated scaling, distinct security constraints, independent release cadence. If you have none of those three problems, stay on a modular monolith — it ships ten times faster and debugs five times more easily.

Good domain boundaries matter more than the service count. A platform well structured around three services beats any distributed architecture cobbled together with fifteen.

Comments

Reader reactions

No comment yet

No spam — email is only used to verify your identity.

·

Be the first to share your reaction.

←PreviousMulti-gateway payment and E2E encryption — the pattern that doesn't breakTech · 18 min read
Next conversation

Turn this reading into a product decision.

If this topic feels close to a real product problem, I can help on diagnosis, architecture, backend, interface and automations that make a platform usable in production.

Format
Full-time, freelance, long mission
Focus
SaaS, API, back-office, automation
Discuss the topicDownload CV

Newsletter

Get the next technical notes.

A short selection on SaaS, backend architecture, business automation and product quality. No noise, only applicable ideas.