Couche Application
À quoi sert cette couche, et pourquoi
Section intitulée « À quoi sert cette couche, et pourquoi »La couche Application (api/src/application/) est le chef d’orchestre du backend. Elle ne contient ni règle métier pure (ça, c’est la Couche Domain), ni détail technique comme SQL ou HTTP (ça, c’est la Couche Infrastructure et la Couche HTTP).
Son rôle tient en trois verbes :
- Coordonner — un use case charge une entité du domaine, lui demande de changer d’état, puis persiste. Exemple : « ajouter une carte cadeau à une session », « rembourser une session ».
- Décrire ses besoins — elle déclare des ports (interfaces TypeScript) pour tout ce qu’elle ne sait pas faire elle-même : lire en base, appeler un PSP, signer un manifest, envoyer un email. Elle ne sait pas comment c’est fait, seulement ce qu’elle attend.
- Rester pure — aucun
importde Fastify, d’un driver SQL ou denode:fetchici. Tout passe par injection de dépendances dans le constructeur. C’est ce qui rend les use cases testables sans base ni réseau (voir Architecture hexagonale).
flowchart LR HTTP["HTTP (handlers)"] --> UC["Use case (application)"] UC --> DOM["Domain (entités pures)"] UC --> PORTS["Ports (interfaces)"] PORTS -.implémentés par.-> INFRA["Infrastructure (adapters)"]
Anatomie d’un use case
Section intitulée « Anatomie d’un use case »Tous les use cases suivent le même squelette : une classe avec un constructeur qui reçoit ses dépendances (les ports), et une unique méthode execute(input). Pas d’état partagé, pas de méthode statique magique.
Voici api/src/application/sessions/create-session.ts, dépouillé à l’essentiel :
export class CreateSession { constructor( private readonly uow: UnitOfWork, private readonly merchants: MerchantRepository, private readonly clock: Clock, private readonly ids: IdGenerator, ) {}
async execute(input: CreateSessionInput): Promise<PaymentSession> { const merchantId = id<'merchant'>('merchant', input.merchantId) as MerchantId const merchant = await this.merchants.findById(merchantId) if (!merchant || !merchant.active) { throw new ValidationError('Merchant not found or inactive', { merchantId: input.merchantId }) }
return this.uow.run(async ({ sessions }) => { const existing = await sessions.findByReference(merchantId, input.reference) if (existing) return existing // idempotence métier sur (merchant, reference)
const now = this.clock.now() const session = PaymentSession.create({ /* … */ }) // le DOMAINE crée l'entité await sessions.save(session) return session }) }}Les invariants à retenir :
- Injection par constructeur :
uow,merchants,clock,idssont des ports. Aucunnew FirebirdRepository()dans le use case. clocketidssont des ports : on n’appelle jamaisnew Date()nicrypto.randomUUID()directement → le temps et les identifiants sont déterministes en test.- La logique métier reste dans le domaine :
PaymentSession.create(...),session.addGiftLeg(...),session.submit(...). Le use case séquence, l’entité décide et valide. - Les erreurs sont des erreurs domaine :
ValidationError,InvalidStateTransition(depuisdomain/shared/errors.js), traduites en HTTP par la couche au-dessus. - L’
inputest un type plat (souvent desstring/numberbruts) ; le use case le « brande » en identifiants typés (MerchantId,SessionId) viaid<'…'>(…).
Use cases « session » (le cœur de l’orchestration)
Section intitulée « Use cases « session » (le cœur de l’orchestration) »Ces use cases pilotent le cycle de vie d’une PaymentSession et de ses PaymentLeg. La plupart écrivent dans la base et déposent une intention dans l’outbox (voir plus bas). Détail des transitions d’état dans Machines à états.
Fichier (api/src/application/sessions/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-session.ts |
CreateSession |
Vérifie le marchand actif, crée la session (TTL = sessionTtlMinutes du marchand). Idempotent sur (merchantId, reference) : un appel répété renvoie la session existante. |
get-session.ts |
GetSession |
Lecture seule d’une session par id (renvoie null si absente). |
list-sessions.ts |
ListSessions |
Liste paginée par marchand (filtres statut/référence/dates), pagination cursor base64url encodée/décodée ici (encodeCursor/decodeCursor). |
add-gift-leg.ts |
AddGiftLeg |
Crée un PaymentLeg carte cadeau (montant plafonné au min(solde, reste à couvrir)), l’attache à la session, enqueue DEBIT_GIFT dans l’outbox (idempotency debit:<legId>). |
set-psp-leg.ts |
SetPspLeg |
Pose le leg PSP couvrant le reste à payer. Vérifie qu’une config PSP active existe pour le marchand (MERCHANT_PSP_NOT_CONFIGURED sinon). N’enqueue rien : l’autorisation part au submit. |
remove-leg.ts |
RemoveLeg |
Retire un leg. Si c’est un leg cadeau déjà CAPTURED, ne le supprime pas en base mais enqueue CANCEL_GIFT (compensation) ; sinon retrait direct. |
submit-session.ts |
SubmitSession |
Valide la session (session.submit). S’il y a un leg PSP → enqueue AUTHORIZE_PSP et passe PROCESSING. Sinon, si tous les legs sont CAPTURED → COMPLETED. |
confirm-psp-action.ts |
ConfirmPspAction |
Après une action 3-D Secure côté client (leg PSP HELD), efface l’exigence d’action et enqueue CAPTURE_PSP (idempotency capture:<legId>). Renvoie ALREADY_CAPTURED/ENQUEUED/NO_OP. |
check-gift-balance.ts |
CheckGiftBalance |
Interroge le solde d’une carte dans le contexte d’une session via le GiftCardRegistry. Valide/normalise/masque le numéro. Renvoie un token + 4 derniers chiffres réutilisables par add-gift-leg. |
refund-session.ts |
RefundSession |
Rembourse les legs CAPTURED selon une strategy (gift_first / cb_first / all), enqueue un REFUND par leg (idempotency refund:<sessionId>:<legId>), recalcule le statut. |
L’orchestrateur asynchrone qui consomme les entrées d’outbox produites ci-dessus, api/src/application/workers/process-outbox-entry.ts, est documenté à part : voir L’orchestrateur. Le worker cleanup-expired-sessions.ts est couvert dans Workers.
Use cases « gift » (cartes cadeaux sans session)
Section intitulée « Use cases « gift » (cartes cadeaux sans session) »API directe sur les cartes cadeaux externes (gateway Titreo / Mock), découplée de toute session. Tous passent par le GiftCardRegistry → un GiftCardProvider. L’idempotencyKey reçu (en-tête Idempotency-Key) sert aussi de ticket provider : double protection contre le re-débit.
Fichier (api/src/application/gift/) |
Classe / export | Ce qu’il orchestre |
|---|---|---|
lookup-gift-balance.ts |
LookupGiftBalance |
Lit le solde d’une carte (/v1/gift-cards/check-balance), sans session. |
debit-gift-card.ts |
DebitGiftCard |
Débit immédiat (/v1/gift-cards/debit). TITREO_HTTP/TITREO_UNKNOWN = transport transitoire (5xx retentable) ; autres FAILED = refus métier déterministe (200). |
refund-gift-card.ts |
RefundGiftCard |
Recrédit / reversal (/v1/gift-cards/refund) — couvre release (annulation) et refund (post-paiement). |
gift-card-shared.ts |
normalizeCardNumber, isValidCardNumber, normalizeCvv, isValidCvv, maskCardNumber |
Helpers de validation/masquage partagés (pas un use case). |
Use cases « webhooks »
Section intitulée « Use cases « webhooks » »Fichier (api/src/application/webhooks/) |
Classe / export | Ce qu’il orchestre |
|---|---|---|
handle-psp-webhook.ts |
HandlePspWebhook |
Reçoit un webhook PSP : vérifie la signature (provider.verifyWebhook), déduplique via webhookEvents.markReceived, retrouve la session par authId, applique l’événement au leg PSP (succès/échec/capture/refund/dispute), déclenche la compensation des legs cadeaux (CANCEL_GIFT) si rollback, puis enqueue un webhook marchand si statut terminal. |
merchant-webhook.ts |
buildMerchantPayload, signMerchantPayload, enqueueMerchantWebhook, eventForStatus, TERMINAL_EVENTS |
Helpers (pas une classe use case) : construisent et signent (HMAC-SHA256) le payload sortant vers le marchand, et l’enqueuent en outbox MERCHANT_WEBHOOK (idempotency webhook:<sessionId>:<event>). |
Use cases « psp-configs » (configuration PSP par marchand)
Section intitulée « Use cases « psp-configs » (configuration PSP par marchand) »Fichier (api/src/application/psp-configs/) |
Classe | Ce qu’il orchestre |
|---|---|---|
upsert-psp-config.ts |
UpsertPspConfig |
Crée/met à jour la config PSP d’un marchand (clé secrète, secret webhook, clé publique…). Reçoit un PspConfigValidatorFn injecté ; calcule les …Last4 pour affichage. |
get-psp-config.ts |
GetPspConfig |
Lecture d’une config par (merchantId, providerType). |
list-psp-configs.ts |
ListPspConfigs |
Liste les configs d’un marchand. |
delete-psp-config.ts |
DeletePspConfig |
Supprime la config d’un provider pour un marchand. |
Use cases « merchants »
Section intitulée « Use cases « merchants » »Fichier (api/src/application/merchants/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-merchant.ts |
CreateMerchant |
Crée un marchand (TTL session 1..1440 min, branding vide par défaut). Refuse un id déjà existant. |
get-merchant.ts |
GetMerchant |
Lecture d’un marchand par id. |
list-merchants.ts |
ListMerchants |
Liste paginée (cursor) des marchands, filtre active. |
update-merchant.ts |
UpdateMerchant |
Mise à jour côté marchand : valide nom, URL webhook (http/https), secret webhook (≥ 16 car.), TTL. |
admin-update-merchant.ts |
AdminUpdateMerchant |
Variante admin (vérifie l’existence préalable du marchand). |
get-onboarding-state.ts |
GetMerchantOnboardingState |
Calcule l’avancement d’onboarding (PSP configuré, clé API créée, webhook, branding, plugin installé) en croisant 3 repositories. |
Use cases « api-keys »
Section intitulée « Use cases « api-keys » »Fichier (api/src/application/api-keys/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-api-key.ts |
CreateApiKey |
Génère une clé tep_live_/tep_test_ (via un ApiKeyHasher injecté), vérifie que l’acteur détient déjà les scopes accordés (hasScope), n’expose le token en clair qu’une fois. |
authenticate-api-key.ts |
AuthenticateApiKey |
Authentifie une requête : hash du token → findByHash, rejette révoquée/expirée, vérifie le marchand actif, met à jour lastUsed en fire-and-forget. |
list-api-keys.ts |
ListApiKeys |
Liste les clés d’un marchand (sans le secret). |
revoke-api-key.ts |
RevokeApiKey |
Révoque une clé (vérifie l’appartenance au marchand ; idempotent si déjà révoquée). |
Les scopes (Scope, hasScope) viennent du domaine domain/auth/scope.ts. Voir Sécurité.
Use cases « auth » (sessions utilisateur console/admin)
Section intitulée « Use cases « auth » (sessions utilisateur console/admin) »Authentification humaine (console marchand, dashboard admin) par session à cookie, distincte des clés API machine.
Fichier (api/src/application/auth/) |
Classe | Ce qu’il orchestre |
|---|---|---|
create-admin-user.ts |
CreateAdminUser |
Crée un utilisateur admin (mot de passe haché via PasswordHasher). |
login-admin.ts |
LoginAdmin |
Login admin : vérifie le mot de passe, ouvre une UserSession, émet un token via UserSessionTokenService. |
login-merchant.ts |
LoginMerchant |
Idem pour un utilisateur marchand. |
logout-user.ts |
LogoutUser |
Révoque la session utilisateur associée au token. |
resolve-user-session.ts |
ResolveUserSession |
Résout un token de cookie → utilisateur admin ou marchand (TTL glissant : 12 h admin, 24 h marchand). Pivot d’autorisation des routes console/admin. |
signup-merchant.ts |
SignupMerchant |
Auto-inscription marchand : crée marchand + 1er utilisateur, envoie l’email de vérification (EmailSender) vers consoleBaseUrl. |
verify-merchant-email.ts |
VerifyMerchantEmail |
Valide le token d’email reçu et marque l’utilisateur vérifié. |
Use cases « merchant-users » (équipe d’un marchand)
Section intitulée « Use cases « merchant-users » (équipe d’un marchand) »Fichier (api/src/application/merchant-users/) |
Classe | Ce qu’il orchestre |
|---|---|---|
invite-merchant-user.ts |
InviteMerchantUser |
Crée une invitation et envoie l’email d’invitation vers la console. |
accept-merchant-invite.ts |
AcceptMerchantInvite |
Transforme une invitation en utilisateur (création + mot de passe haché). |
revoke-merchant-invite.ts |
RevokeMerchantInvite |
Révoque une invitation en attente. |
list-merchant-users.ts |
ListMerchantUsers |
Liste les membres et les invitations en attente d’un marchand. |
update-merchant-user.ts |
UpdateMerchantUser |
Met à jour le rôle / l’état (désactivé) d’un membre. |
Use cases « audit » et « branding »
Section intitulée « Use cases « audit » et « branding » »| Fichier | Classe | Ce qu’il orchestre |
|---|---|---|
audit/write-audit-log.ts |
WriteAuditLog |
Écrit une entrée de journal d’audit (action, acteur, cible, IP, payload). |
audit/list-audit-logs.ts |
ListAuditLogs |
Liste paginée (cursor) des entrées d’audit, filtres marchand/action/dates. |
branding/get-branding.ts |
GetBranding |
Lit le branding (thème, logo) d’un marchand sous forme de BrandingDto. |
branding/update-branding.ts |
UpdateBranding |
Met à jour le branding d’un marchand. |
Use case « bootstrap »
Section intitulée « Use case « bootstrap » »Fichier (api/src/application/bootstrap/) |
Classe | Ce qu’il orchestre |
|---|---|---|
generate-manifest.ts |
GenerateBootstrapManifest |
Construit le manifest de bootstrap signé envoyé au SDK client : résout le SDK + l’adapter PSP (et gift) via ClientAdapterRegistry, injecte le branding du marchand et la clé publique PSP, canonicalise et signe le payload via ManifestSigner. Ne pose aucun leg, ne touche pas l’outbox. |
Les ports : tout ce que la couche Application réclame au monde extérieur
Section intitulée « Les ports : tout ce que la couche Application réclame au monde extérieur »Un port est une interface (type/interface TypeScript) dans api/src/application/ports/. Le use case s’en sert ; un adapter d’infrastructure l’implémente. Aucune dépendance concrète n’apparaît jamais ici.
Ports « persistance » (repositories)
Section intitulée « Ports « persistance » (repositories) »Port (ports/) |
Type/interface clé | Rôle |
|---|---|---|
session-repository.ts |
SessionRepository |
CRUD/lecture des PaymentSession : findById, findByReference, findByPspAuthId, save, list (paginé), listExpiredActive. |
outbox-repository.ts |
OutboxRepository, OutboxAction, OutboxEntry |
File transactionnelle d’intentions : enqueue, claim (atomique PENDING→PROCESSING), markCompleted, markFailed, listReady. Cœur du pattern outbox. |
merchant-repository.ts |
MerchantRepository, Merchant, MerchantBranding |
Marchands : findById, create, update, list. Porte aussi emptyBranding(). |
merchant-psp-config-repository.ts |
MerchantPspConfigRepository, MerchantPspConfig |
Configs PSP par marchand : findActive, findByProvider, upsert, listForMerchant, delete. |
api-key-repository.ts |
ApiKeyRepository, ApiKey |
Clés API : findByHash, findById, insert, listForMerchant, markRevoked, touchLastUsed. |
audit-log-repository.ts |
AuditLogRepository, AuditLog |
Journal d’audit : insert, list (paginé). |
admin-user-repository.ts |
AdminUserRepository |
Utilisateurs admin : findById, findByEmail, insert, updateLastLogin. |
merchant-user-repository.ts |
MerchantUserRepository |
Utilisateurs marchands : recherche, vérif email, maj mot de passe / rôle / désactivation. |
merchant-user-invite-repository.ts |
MerchantUserInviteRepository |
Invitations d’équipe : findByTokenHash, insert, markAccepted, markRevoked, listPendingByMerchant. |
user-session-repository.ts |
UserSessionRepository |
Sessions humaines (cookie) : insert, findByTokenHash, updateLastSeen, revoke, deleteExpired. |
webhook-deliveries-repository.ts |
WebhookDeliveriesRepository |
Historique des livraisons de webhooks marchands (statut, retries), pour observabilité. |
webhook-events-repository.ts |
WebhookEventsRepository |
Déduplication des événements PSP entrants : markReceived (renvoie duplicate), markProcessed. |
Ports « transaction »
Section intitulée « Ports « transaction » »| Port | Type clé | Rôle |
|---|---|---|
unit-of-work.ts |
UnitOfWork, TransactionalRepos |
Exécute un bloc dans une seule transaction DB, en exposant { sessions, outbox, webhookEvents? }. C’est le mécanisme qui garantit l’atomicité « écrire la session et enqueue l’outbox » (voir ci-dessous). |
Ports « services externes / providers »
Section intitulée « Ports « services externes / providers » »| Port | Type clé | Rôle |
|---|---|---|
payment-provider.ts |
PaymentProvider, AuthorizeResult, WebhookEvent… |
Contrat d’un PSP : authorize, capture, void, refund, confirmAction, verifyWebhook, mapNativeError + capabilities. Voir Contrats. |
psp-registry.ts |
PspRegistry |
Résout un PaymentProvider pour (providerType, merchantId) : get, has. |
gift-card-provider.ts |
GiftCardProvider, BalanceResult, DebitResult… |
Contrat d’un émetteur de cartes cadeaux : checkBalance, debit, cancel, refund. |
gift-card-registry.ts |
GiftCardRegistry |
Résout un GiftCardProvider par type : get, has. |
client-adapter-registry.ts |
ClientAdapterRegistry |
Résout les specs d’adapters côté client pour le manifest : resolveSdk, resolvePsp, resolveGiftCard. |
manifest-signer.ts |
ManifestSigner |
Signe le manifest de bootstrap : sign(message), keyId. |
webhook-sender.ts |
WebhookSender |
Émet un webhook HTTP sortant vers le marchand : send → WebhookSendResult. |
email-sender.ts |
EmailSender |
Envoie un email (send(message)) — vérif email, invitations. |
Ports « utilitaires déterministes »
Section intitulée « Ports « utilitaires déterministes » »| Port | Type clé | Rôle |
|---|---|---|
clock.ts |
Clock |
now(): Date. Temps injecté → tests déterministes, jamais new Date() dans un use case. |
id-generator.ts |
IdGenerator |
next(): string. Génération d’identifiants injectée. |
idempotency-store.ts |
IdempotencyStore, IdempotencyRecord |
Cache de réponses idempotentes HTTP : save, deleteExpired (+ lecture). Voir Idempotence. |
password-hasher.ts |
PasswordHasher |
hash / verify de mots de passe. |
user-session-token-service.ts |
UserSessionTokenService |
Génère/hash les tokens de session cookie : generate, hash. |
UnitOfWork + outbox : écrire l’état et l’intention dans la même transaction
Section intitulée « UnitOfWork + outbox : écrire l’état et l’intention dans la même transaction »C’est le pattern central de l’orchestration. Quand un use case modifie une session et déclenche une action externe (débiter une carte, autoriser un PSP, notifier le marchand), il ne fait pas l’appel réseau lui-même. Il fait deux écritures DB dans une seule transaction :
sessions.save(session)— le nouvel état métier.outbox.enqueue({...})— une intention d’action externe (« débite cette carte »).
Soit les deux sont commitées ensemble, soit aucune. Un worker séparé (L’orchestrateur) lira l’outbox plus tard et exécutera l’appel réseau réel. C’est ce qui rend le système résilient aux crashs : aucune action externe ne peut être « perdue » ou « jouée sans son état ».
sequenceDiagram participant UC as "Use case (ex: AddGiftLeg)" participant UoW as "UnitOfWork (1 transaction DB)" participant SR as "sessions (repo)" participant OB as "outbox (repo)" UC->>UoW: run(work) UoW->>SR: findById(sessionId) UC->>UC: session.addGiftLeg(leg, now) UC->>OB: enqueue(action DEBIT_GIFT, idempotencyKey debit:legId) UC->>SR: save(session) UoW-->>UC: COMMIT (état + intention, atomique)
Extrait réel de add-gift-leg.ts (chemin d’appel commenté) :
return this.uow.run(async ({ sessions, outbox }) => { const session = await sessions.findById(sessionId) // (1) charge l'agrégat // … validations de montant … session.addGiftLeg(leg, now) // (2) le DOMAINE applique la transition
const outboxId = this.ids.next() await outbox.enqueue({ // (3) intention d'action externe id: outboxId, sessionId, legId, action: 'DEBIT_GIFT', // => le worker débitera la carte payload: { token: input.cardToken, amount: debitAmount, emitter: input.emitter }, idempotencyKey: `debit:${legId}`, // clé d'idempotence stable (rejeu sûr) now, })
await sessions.save(session) // (4) état persisté DANS la même tx return { legId, amount: debitAmount, outboxId }}) // COMMIT atomique en sortie de run()Les use cases en lecture seule (GetSession, ListSessions, GetMerchant, ListApiKeys…) n’utilisent pas UnitOfWork : ils prennent directement le repository concerné et appellent une méthode de lecture. UnitOfWork n’est requis que lorsqu’il faut une atomicité écriture-état + écriture-outbox (ou plusieurs écritures cohérentes).
Où aller ensuite
Section intitulée « Où aller ensuite »- Couche Domain — les entités (
PaymentSession,PaymentLeg) et leurs invariants que les use cases pilotent. - L’orchestrateur — le worker qui consomme l’outbox et appelle réellement les providers.
- Couche Infrastructure — les adapters qui implémentent ces ports (Firebird, PSP, Titreo, email…).
- Couche HTTP — les handlers qui parsent la requête, appellent un use case et sérialisent.
- Parcours d’appel — un flow complet de bout en bout, HTTP → use case → outbox → worker.