Architecture Patterns
L'architecture BaaS de YaniPay repose sur des patterns eprouves : Adapter Pattern, Factory Pattern, Circuit Breaker et Result Types pour une integration robuste et maintenable.
Vue d'ensemble
L'integration BaaS de YaniPay suit une architecture en couches ou chaque niveau a une responsabilite unique. Les clients (mobile et web) communiquent avec les API Routes Next.js, qui delegent au BaaSService. Ce dernier utilise la factory pour obtenir le bon provider (Swan ou Treezor), qui traduit les appels vers l'API externe correspondante.
Adapter Pattern
Interface commune BaaSProvider qui abstrait les differences entre Swan (GraphQL) et Treezor (REST).
Factory Pattern
Singleton getBaaSProvider() qui instancie le bon provider selon la variable BAAS_PROVIDER.
Circuit Breaker
Protection automatique contre les pannes du fournisseur avec fenetre glissante et cooldown.
Result Types
Tagged unions BaaSError pour un typage exhaustif des erreurs sans exceptions non capturees.
Zod Validation
Validation stricte des variables d'environnement BaaS au demarrage de l'application.
Colocation
Fichiers BaaS regroupes dans core/baas/ avec separation claire des responsabilites.
Adapter Pattern
Le coeur de l'architecture est l'interface BaaSProvider. Elle definit un contrat commun que chaque fournisseur doit implementer. Le code metier ne manipule jamais l'implementation concrete (Swan ou Treezor), uniquement cette interface abstraite. Cela permet de changer de fournisseur sans modifier une seule ligne de logique applicative.
Swan traduit chaque methode en mutations/queries GraphQL, tandis que Treezor les traduit en appels REST. Les reponses sont normalisees vers les types communs (BaaSAccount, BaaSCard, BaaSTransfer).
1 export interface BaaSProvider { 2 readonly name: 'swan' | 'treezor'; 3 4 createAccount(input: CreateAccountInput): Promise<BaaSAccount>; 5 getAccount(id: string): Promise<BaaSAccount | null>; 6 getBalance(id: string): Promise<{ balanceCents: number; currency: string }>; 7 createCard(input: CreateCardInput): Promise<BaaSCard>; 8 lockCard(id: string): Promise<BaaSCard>; 9 unlockCard(id: string): Promise<BaaSCard>; 10 initiateTransfer(input: InitiateTransferInput): Promise<BaaSTransfer>; 11 onboardUser(input: OnboardUserInput): Promise<BaaSOnboardingResult>; 12 healthCheck(): Promise<{ healthy: boolean; latencyMs: number }>; 13 } 14 // 25+ methods total including optional: viewCardNumbers, initiateStandingOrder, 15 // listStatements, addBeneficiary, addVirtualIban, inviteMember, getConsent...
Les methodes optionnelles (comme viewCardNumbers ou initiateStandingOrder) sont definies en tant que methodes avec retour Promise<...> | undefined pour permettre aux providers de ne pas implementer les fonctionnalites non supportees par leur API.
Factory Pattern
La factory getBaaSProvider()est le point d'entree unique pour obtenir une instance du provider BaaS. Elle lit la variable d'environnement BAAS_PROVIDERet retourne un singleton cache en memoire. Cela garantit qu'un seul provider est instancie par processus, evitant les connexions multiples inutiles.
1 let cachedProvider: BaaSProvider | null = null; 2 3 export function getBaaSProvider(): BaaSProvider { 4 if (cachedProvider) return cachedProvider; 5 6 const providerName = (process.env.BAAS_PROVIDER as BaaSProviderName) || 'swan'; 7 8 switch (providerName) { 9 case 'swan': 10 cachedProvider = new SwanProvider(); 11 break; 12 case 'treezor': 13 cachedProvider = new TreezorProvider(); 14 break; 15 default: 16 throw new Error(`Unknown BaaS provider: "${providerName}"`); 17 } 18 return cachedProvider; 19 } 20 21 // For testing — creates a non-cached instance 22 export function createBaaSProvider( 23 name: BaaSProviderName, 24 config?: Record<string, string> 25 ): BaaSProvider { 26 switch (name) { 27 case 'swan': 28 return new SwanProvider(config); 29 case 'treezor': 30 return new TreezorProvider(config); 31 default: 32 throw new Error(`Unknown BaaS provider: "${name}"`); 33 } 34 }
Singleton et serverless
getBaaSProvider()est cache en memoire. Dans un environnement Docker avec plusieurs instances, chaque conteneur cree sa propre instance. Cela n'a pas d'impact fonctionnel mais peut affecter les metriques du circuit breaker.Circuit Breaker
Le circuit breaker protege l'application contre les pannes du fournisseur BaaS. Lorsque le nombre d'echecs consecutifs depasse un seuil configurable, le circuit s'ouvre et bloque les requetes pendant une periode de cooldown. Apres ce delai, une requete de probe est envoyee pour verifier si le fournisseur est de nouveau disponible.
Le circuit breaker utilise trois etats :
- CLOSED — Fonctionnement normal, toutes les requetes passent. Les echecs sont comptes dans une fenetre glissante.
- OPEN— Le seuil d'echecs est atteint. Toutes les requetes sont immediatement rejetees sans contacter le fournisseur.
- HALF_OPEN — Apres le cooldown, une seule requete de probe est autorisee. Si elle reussit, le circuit repasse en CLOSED. Sinon, il retourne en OPEN.
1 type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; 2 3 const DEFAULT_CONFIG = { 4 failureThreshold: 5, // 5 consecutive failures to open 5 cooldownMs: 30_000, // 30s before probe request 6 windowMs: 60_000, // 60s failure tracking window 7 }; 8 9 export class SwanCircuitBreaker { 10 private state: CircuitState = 'CLOSED'; 11 private failures: number[] = []; 12 private lastOpenedAt: number = 0; 13 private config: typeof DEFAULT_CONFIG; 14 15 constructor(config?: Partial<typeof DEFAULT_CONFIG>) { 16 this.config = { ...DEFAULT_CONFIG, ...config }; 17 } 18 19 canExecute(): boolean { 20 this.pruneOldFailures(); 21 22 switch (this.state) { 23 case 'CLOSED': 24 return true; 25 case 'OPEN': { 26 const elapsed = Date.now() - this.lastOpenedAt; 27 if (elapsed >= this.config.cooldownMs) { 28 this.state = 'HALF_OPEN'; 29 return true; // Allow one probe request 30 } 31 return false; 32 } 33 case 'HALF_OPEN': 34 return false; // Probe already in flight 35 } 36 } 37 38 recordSuccess(): void { 39 this.state = 'CLOSED'; 40 this.failures = []; 41 } 42 43 recordFailure(): void { 44 this.failures.push(Date.now()); 45 this.pruneOldFailures(); 46 47 if (this.state === 'HALF_OPEN') { 48 this.state = 'OPEN'; 49 this.lastOpenedAt = Date.now(); 50 return; 51 } 52 53 if (this.failures.length >= this.config.failureThreshold) { 54 this.state = 'OPEN'; 55 this.lastOpenedAt = Date.now(); 56 } 57 } 58 59 private pruneOldFailures(): void { 60 const cutoff = Date.now() - this.config.windowMs; 61 this.failures = this.failures.filter((t) => t > cutoff); 62 } 63 64 getState(): CircuitState { 65 return this.state; 66 } 67 }
Fenetre glissante
Error Handling (BaaSError)
Les erreurs BaaS sont modelisees sous forme de tagged unionsTypeScript. Chaque type d'erreur possede un tagdiscriminant qui permet au compilateur de verifier l'exhaustivite du traitement. Contrairement aux exceptions classiques, ce pattern rend impossible l'oubli d'un cas d'erreur.
1 // Tagged union for typed errors 2 type BaaSError = 3 | { tag: 'NetworkError'; message: string } 4 | { tag: 'AuthenticationError'; message: string } 5 | { tag: 'ValidationError'; message: string; fields?: string[] } 6 | { tag: 'NotFoundError'; resource: string; id: string } 7 | { tag: 'ConsentRequired'; consentUrl: string } 8 | { tag: 'RateLimitError'; retryAfterMs: number } 9 | { tag: 'ProviderError'; code: string; message: string }; 10 11 // Usage example 12 function handleBaaSError(error: BaaSError): Response { 13 switch (error.tag) { 14 case 'NetworkError': 15 return Response.json({ error: 'Service temporarily unavailable' }, { status: 503 }); 16 case 'AuthenticationError': 17 return Response.json({ error: 'BaaS authentication failed' }, { status: 401 }); 18 case 'ValidationError': 19 return Response.json({ error: error.message, fields: error.fields }, { status: 400 }); 20 case 'NotFoundError': 21 return Response.json({ error: `${error.resource} not found: ${error.id}` }, { status: 404 }); 22 case 'ConsentRequired': 23 return Response.json({ consentUrl: error.consentUrl }, { status: 202 }); 24 case 'RateLimitError': 25 return Response.json({ error: 'Rate limited', retryAfterMs: error.retryAfterMs }, { status: 429 }); 26 case 'ProviderError': 27 return Response.json({ error: error.message, code: error.code }, { status: 502 }); 28 } 29 }
Le tag ConsentRequiredest specifique a Swan, ou certaines operations (virements, ajout de beneficiaires) necessitent un consentement SCA de l'utilisateur via une URL de redirection. Le tag RateLimitError inclut le delai avant retry, permettant au client de reessayer automatiquement.
Validation Zod
Les variables d'environnement BaaS sont validees au demarrage via un schema Zod. En production, une erreur de configuration arrete immediatement l'application avec un message explicite, evitant des erreurs cryptiques a l'execution.
1 import { z } from 'zod'; 2 3 const baasEnvSchema = z.object({ 4 SWAN_CLIENT_ID: z.string().min(1), 5 SWAN_CLIENT_SECRET: z.string().min(1), 6 SWAN_WEBHOOK_SECRET: z.string().min(1), 7 BAAS_PROVIDER: z.enum(['swan', 'treezor']).default('swan'), 8 NEXT_PUBLIC_APP_URL: z.string().url().optional(), 9 SWAN_API_URL: z.string().url().optional(), 10 SWAN_OAUTH_URL: z.string().url().optional(), 11 }); 12 13 export type BaaSEnv = z.infer<typeof baasEnvSchema>; 14 15 export function validateBaaSEnvironment(): BaaSEnv { 16 if (process.env.NODE_ENV !== 'production') { 17 // In development, return defaults for missing values 18 return baasEnvSchema.parse({ 19 SWAN_CLIENT_ID: process.env.SWAN_CLIENT_ID || 'dev-client-id', 20 SWAN_CLIENT_SECRET: process.env.SWAN_CLIENT_SECRET || 'dev-secret', 21 SWAN_WEBHOOK_SECRET: process.env.SWAN_WEBHOOK_SECRET || 'dev-webhook', 22 BAAS_PROVIDER: process.env.BAAS_PROVIDER || 'swan', 23 NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 24 SWAN_API_URL: process.env.SWAN_API_URL, 25 SWAN_OAUTH_URL: process.env.SWAN_OAUTH_URL, 26 }); 27 } 28 29 const result = baasEnvSchema.safeParse(process.env); 30 if (!result.success) { 31 const formatted = result.error.issues 32 .map((i) => ` - ${i.path.join('.')}: ${i.message}`) 33 .join('\n'); 34 throw new Error( 35 `BaaS environment validation failed:\n${formatted}` 36 ); 37 } 38 return result.data; 39 }
Structure des fichiers
Les fichiers BaaS suivent le principe de colocation : tout ce qui concerne l'integration bancaire est regroupe dans apps/platform/core/baas/, avec les utilitaires partages dans lib/.
Le fichier swan-provider.ts est le plus volumineux avec plus de 2 000 lignes, car il implemente toutes les mutations et queries GraphQL Swan, incluant la gestion des tokens OAuth2, la pagination et le mapping des types. Le fichier treezor-provider.tsest plus compact car l'API REST de Treezor suit des conventions plus simples.
Pages associees
Explorez les autres pages de la documentation BaaS pour approfondir chaque aspect :