# DDD en action : Comment verrouiller le taux de change dans les paiements a résolu quatre bugs critiques
Dans les systèmes de paiement, les erreurs dans les calculs de taux de change monétaire entraînent des écarts financiers et une perte de confiance. Une étude de cas réelle en Go montre comment un modèle de domaine avec un taux fixe dans l'agrégat résout des problèmes qui ne sont pas évidents dans les exemples DDD des manuels théoriques.
Le problème des trois taux de change dans un même paiement
L'architecture initiale récupérait les taux de sources multiples : checkout-api les obtenait de Redis, payment-service via HTTP depuis fx-rate-service, et ledger-service d'un instantané Postgres. Cela causait des écarts : le client voyait 12 430,00 KZT, la banque débitait 12 435,27 KZT, et le support dans le panneau d'administration affichait 12 428,91 KZT. Chaque service était techniquement correct, mais l'ensemble du système produisait des données incohérentes.
La solution temporaire — désactiver le cache dans checkout-api pour les gros paiements — a aggravé la latence p95 de 120 ms à 500 ms. La solution définitive a introduit une entité de domaine Quote qui verrouille le taux, le montant et la durée de vie comme un contrat métier. Désormais, les paiements sont créés via quote_id au lieu de données dynamiques. Cela a éliminé la possibilité de recalculer le taux lors du débit ou de l'entrée en ledger.
type Quote struct {
ID string
UserID string
From Money
To Money
Rate RateSnapshot
CreatedAt time.Time
}
func NewPayment(quote Quote, idemKey string) (*Payment, error) {
if time.Now().After(quote.Rate.ExpiresAt) {
return nil, ErrQuoteExpired
}
// ...
}
La table payments est devenue dénormalisée (stocker des copies des taux et montants), mais cela garantit l'atomicité des données pour les audits. Une seule ligne contient désormais toutes les infos nécessaires pour analyser un incident.
Erreurs d'arrondi : float64 vs decimal.Decimal
Le deuxième bug critique s'est manifesté par des écarts cumulés sur 8 000 lignes en une nuit. Le problème venait d'arrondis incohérents : payment-service utilisait math.Round, billing-service decimal.NewFromFloat, et les rapports la fonction SQL round(). La conversion float64 vers decimal via NewFromFloat causait une perte de précision.
La solution temporaire était un job nocturne rounding_adjustments qui accumulait les écarts jusqu'à 1 KZT. La solution finale comprenait :
- Interdiction de
float64dans les structs de domaine - Acceptation des montants sous forme de chaînes via
decimal.NewFromString - Arrondi centralisé dans la méthode
Money.RoundByCurrency - Précision liée à la devise (par ex., JPY arrondi à l'entier)
func (m Money) RoundByCurrency() Money {
scale := map[Currency]int32{
"USD": 2,
"JPY": 0,
}[m.currency]
return Money{
amount: m.amount.Round(scale),
currency: m.currency,
}
}
Les performances des opérations par lots ont chuté de 1,8 à 6,4 secondes pour 200 000 lignes, mais c'est acceptable pour les processus backoffice. La précision prime sur la vitesse.
Idempotence vs timeouts d'API externe
Le troisième problème est survenu avec le fournisseur de paiement : un timeout de 3 secondes dans acquirer-api entraînait des débits dupliqués. En cas de context deadline exceeded, le système relançait, mais la première requête était quand même traitée par le fournisseur. Résultat : double débit du même montant.
Le premier contournement — désactiver les relances automatiques — a augmenté les paiements bloqués en statut unknown. La solution finale :
idempotency_keyobligatoire avec un index unique sur(merchant_id, idempotency_key)- Méthode de domaine
Authorizequi vérifie le statut actuel du paiement - Worker de réconciliation pour les opérations bloquées
create unique index payments_idem_uq
on payments (merchant_id, idempotency_key);
func (p *Payment) Authorize(acquirerID string) error {
switch p.Status {
case PaymentAuthorized:
return nil
case PaymentCreated, PaymentUnknown:
p.Status = PaymentAuthorized
// ...
}
}
Le statut PaymentUnknown est devenu un état système explicite reflétant l'incertitude de l'API externe. Le worker de réconciliation gère les paiements de plus de 90 secondes en interrogeant le fournisseur pour le statut.
Retard du modèle de lecture : Données en temps réel pour le support
Après implémentation de Quote, un nouveau problème est apparu : le panneau d'administration du support affichait des données périmées à cause du lag Kafka (3-4 minutes). Même si les paiements étaient corrects, les opérateurs voyaient d'anciens montants, provoquant la panique des clients.
Correctifs rapides :
- Affichage de
quote_idetrate_versiondans le panneau d'administration - Lectures directes de la table
paymentspour les paiements de moins de 15 minutes - Abissement du seuil d'alerte de lag de 10 à 60 secondes
La solution finale sépare les sources de données :
- Pour les paiements récents : la table
payments - Pour les données historiques : le modèle de lecture basé sur ledger
Cela a ajouté de la complexité au panneau d'administration mais garantit la fraîcheur des données dans les scénarios critiques.
DDD sans dogme : L'agrégat comme cœur du domaine
Le succès du projet ne venait pas d'un dossier domain mais d'une repensée du taux de change comme contrat métier. L'agrégat Payment gère désormais :
- Validation de l'expiration de
Quoteà la création - Verrouillage du montant après confirmation
- Exclusion de
float64des opérations de domaine - Débits idempotents
- Gestion correcte de l'incertitude
unknown
La couche applicative est devenue fine : elle orchestre simplement les étapes, en déléguant la logique métier à l'agrégat. Exemple d'usage :
func (uc *UseCase) CreatePayment(ctx context.Context, cmd CreatePaymentCommand) (*Payment, error) {
quote, err := uc.quotes.Get(ctx, cmd.QuoteID)
if err != nil {
return nil, err
}
payment, err := NewPayment(*quote, cmd.IdempotencyKey)
// ...
}
Leçon clé : DDD fonctionne quand les règles de domaine sont intégrées au modèle, et non traitées comme des détails techniques.
Leçons clés
- Taux fixe dans l'agrégat élimine les écarts inter-services. Le taux doit faire partie du contrat de paiement, pas d'un paramètre dynamique.
- Types monétaires précis. Utilisez
decimal.Decimalavec arrondi centralisé ; évitezfloat64même en tests. - État
unknownexplicite reflète honnêtement l'incertitude des systèmes externes. Ajoutez un worker de réconciliation pour résolution manuelle. - Séparation des sources de données est cruciale pour les ops support. Lisez les opérations récentes directement dans la table, pas dans le modèle de lecture.
— Editorial Team
Aucun commentaire pour le moment.