

Retour d'expérience sur l'architecture microservices Node.js d'une plateforme d'investissement financier en production : trois services, PostgreSQL + Redis, webhooks idempotents, zéro downtime.
Construire une plateforme fintech multi-canal en microservices Node.js, sans tomber dans le piège du "microservice pour le microservice". Retour d'expérience sur Nexus, sept mois de production.
Nexus Corporation voulait une plateforme d'investissement financier servant simultanément un site web public, une application mobile cross-platform, un dashboard admin et un blog technique — le tout sur un backend unifié. Le piège classique aurait été d'empiler des microservices par mimétisme. La promesse marketing "microservices" cache souvent un monolithe distribué : pénible à débugger, lent à déployer, fragile sous charge.
J'ai pris le pari inverse : commencer par un monolithe modulaire bien découpé, puis extraire en microservices uniquement les domaines qui le justifient — paiement, scoring, sessions. Trois critères m'ont guidé : besoin de scaler indépendamment, contrainte de sécurité différente, vitesse de déploiement spécifique.
Le résultat : trois services (auth/RBAC, investissement, paiement) derrière une API gateway Node.js, partageant PostgreSQL pour le relationnel et Redis pour le cache et les sessions volatiles. Les surfaces (web Next.js 14, app mobile React Native, admin) consomment la même API.
Avant de découper, j'ai listé les signaux qu'on observait sur des architectures concurrentes :
Aucun de ces symptômes n'était lié au monolithe en lui-même. Tous étaient liés à un mauvais découpage de domaines. Mais le découpage en services force la discipline.
Le gateway est délibérément mince. Il fait trois choses : router, authentifier, journaliser. Pas de logique métier. Pas d'accès direct à la base. Sa seule autonomie : refuser une requête mal formée, rate-limiter, et émettre un trace ID propagé à tous les services downstream.
| 1 | // src/middlewares/traceContext.ts |
| 2 | import { randomUUID } from "node:crypto"; |
| 3 | import type { Request, Response, NextFunction } from "express"; |
| 4 | |
| 5 | export 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 | // Disponible dans tous les services downstream via le client interne |
| 9 | req.app.locals.traceId = traceId; |
| 10 | next(); |
| 11 | } |
Séparé pour deux raisons : sa surface de sécurité diffère du reste (rotation de secrets, audit obligatoire), et il doit scaler indépendamment quand les sessions explosent (typiquement à l'ouverture du marché).
Le contrat est étroit : un endpoint POST /sessions qui retourne un JWT court (15 min) + un refresh token long (7 jours, stocké hashé en DB). Les rôles sont matérialisés dans le claim JWT, vérifiés à chaque requête downstream sans rappel.
C'est le plus instructif. Il intègre plusieurs passerelles (carte, mobile money, virement), chacune avec son propre format de webhook et sa propre logique d'idempotence. Le service expose une API unifiée : createPayment, confirmPayment, refundPayment. À l'intérieur, un adapter pattern isole chaque passerelle dans son propre module.
| 1 | // src/services/payment/PaymentGateway.ts |
| 2 | export 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 | } |
Chaque passerelle implémente l'interface. Le service paiement choisit la bonne via un router basé sur le moyen demandé par le client. Les webhooks sont reçus sur des routes dédiées par passerelle (/webhooks/cb, /webhooks/mtn, /webhooks/orange) puis dispatchés via un job idempotent.
Premier mois en production, un webhook d'une passerelle locale a tenté 200 fois la même notification. Le bug venait d'une accusation de réception 200 OK trop tardive : nous traitions le webhook en synchrone (vérification base + mise à jour + notification utilisateur), et certaines transactions prenaient 8 secondes. La passerelle considérait l'envoi comme échoué et rejouait.
La correction : accuser réception immédiatement et déléguer le traitement à un job en arrière-plan, idempotent par event_id.
| 1 | // src/controllers/payment/webhookHandler.ts |
| 2 | export async function handleGatewayWebhook(req: Request, res: Response) { |
| 3 | const payload = req.body as WebhookPayload; |
| 4 | |
| 5 | // 1. Idempotence — refuse les rejeux silencieusement |
| 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. Persiste l'event AVANT tout traitement |
| 12 | await WebhookEvent.create({ |
| 13 | gatewayId: payload.gateway, |
| 14 | eventId: payload.id, |
| 15 | rawBody: payload, |
| 16 | status: "received", |
| 17 | }); |
| 18 | |
| 19 | // 3. Accuse réception immédiatement |
| 20 | res.status(200).json({ ok: true }); |
| 21 | |
| 22 | // 4. Traitement asynchrone via la queue |
| 23 | await paymentQueue.add("process-webhook", { eventId: payload.id }); |
| 24 | } |
Trois lignes essentielles : persister avant de traiter, accuser réception tôt, traiter en queue idempotente. Les retries de la passerelle deviennent inoffensifs.
PostgreSQL stocke ce qui doit survivre : transactions, comptes, soldes, journaux d'audit. Toutes les opérations financières sont en transactions ACID avec niveau d'isolation SERIALIZABLE sur les comptes.
Redis stocke ce qui peut disparaître : sessions JWT révoquées (TTL 15 min), rate-limit par IP, cache de prix temps réel (TTL 2 secondes), et un publish/subscribe pour pousser les flux WebSocket à l'admin.
Règle de pouce : si la donnée doit survivre à un redémarrage, elle est dans Postgres. Si elle peut être recalculée en moins de 5 secondes, Redis suffit.
Le dashboard admin doit afficher les transactions en temps réel. La solution naïve — polling toutes les 2 secondes — saturerait la base. À la place : Redis Pub/Sub côté serveur, Socket.IO côté client. Chaque service publie ses événements (payment.confirmed, user.kyc.validated, investment.created) sur un canal Redis. Le gateway s'abonne et pousse aux clients admins authentifiés.
| 1 | // src/services/realtime/eventBus.ts |
| 2 | import { redis } from "../../config/redis.js"; |
| 3 | |
| 4 | type RealtimeEvent = "payment.confirmed" | "user.kyc.validated" | "investment.created"; |
| 5 | |
| 6 | export async function publishEvent<T>(event: RealtimeEvent, payload: T) { |
| 7 | await redis.publish(`events:${event}`, JSON.stringify({ event, payload, at: Date.now() })); |
| 8 | } |
Côté admin : un seul socket multiplexé par utilisateur, filtrage des événements selon le rôle, déduplication par hash sur les 30 dernières secondes.
Les migrations de schéma sur une plateforme financière en production sont à haut risque. Trois règles non négociables :
ALTER TABLE qui locke — sur Postgres, ADD COLUMN ... DEFAULT rewrite la table entière (sauf en 11+). Toujours préférer ADD COLUMN nullable puis UPDATE par lot.| 1 | -- ✅ Bon : ajoute la colonne nullable, sera remplie progressivement |
| 2 | ALTER TABLE transactions ADD COLUMN risk_score INTEGER NULL; |
| 3 | |
| 4 | -- ❌ Mauvais : locke la table pour rewrite |
| 5 | ALTER TABLE transactions ADD COLUMN risk_score INTEGER NOT NULL DEFAULT 0; |
| Piège | Symptôme | Correction |
|---|---|---|
| Microservice prématuré | Latence inter-service > latence DB | Reculer vers un monolithe modulaire |
| Pas d'idempotence sur webhooks | Doublons de transactions | Table webhook_events + queue idempotente |
| Auth dans chaque service | Logique dupliquée, drift de règles | Service auth centralisé, JWT avec claims |
| Logs sans trace ID | Impossible d'isoler une requête | Trace ID propagé en header inter-service |
| Schemas partagés via DB | Couplage caché, déploiements groupés | Schemas privés par service, contracts JSON |
| Pas de circuit breaker | Une passerelle lente fait tomber tout | Timeout + circuit breaker par adapter |
Sept mois plus tard, la plateforme tourne avec un uptime mesuré à 99,9 %, une latence p95 sur les routes critiques sous 200 ms, et zéro incident de déploiement bloquant en production. Le découpage en trois services s'est révélé suffisant — je n'ai pas eu besoin d'aller plus loin.
Le vrai gain n'est pas technique. C'est la clarté d'intention : chaque service a un propriétaire métier, une responsabilité étroite, un cycle de release indépendant. L'équipe peut livrer du paiement sans craindre de casser l'authentification.
Les microservices ne sont pas un objectif. Ce sont un outil pour résoudre des problèmes spécifiques : scalabilité différenciée, contraintes de sécurité distinctes, cadence de release indépendante. Si vous n'avez aucun de ces trois problèmes, restez sur un monolithe modulaire — il est dix fois plus rapide à livrer et cinq fois plus facile à débugger.
Le bon découpage de domaines compte plus que le nombre de services. Une plateforme bien structurée en trois services bat n'importe quelle architecture distribuée bricolée en quinze.
Si ce sujet ressemble à un problème réel dans votre produit, je peux intervenir sur le diagnostic, l'architecture, le backend, l'interface et les automatisations qui rendent une plateforme exploitable.
Réactions des lecteurs
Aucun commentaire pour l'instant
Soyez le premier à réagir à cet article.