Retour à l'accueil

DDD en Go : comment éviter les erreurs avec les taux de change dans les paiements

Analyse de quatre bugs critiques dans un système de paiement en Go. Cela montre comment l'implémentation de DDD avec fixation du taux de change dans l'agrégat de domaine résout les problèmes d'écarts, les erreurs d'arrondi et les débits doubles. Solutions techniques spécifiques avec exemples de code sont proposées.

Comment un paiement a obtenu trois taux : leçons DDD dans un vrai système de paiement
Advertisement 728x90

# 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.

Google AdInline article slot
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 :

Google AdInline article slot
  • Interdiction de float64 dans 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 :

Google AdInline article slot
  • idempotency_key obligatoire avec un index unique sur (merchant_id, idempotency_key)
  • Méthode de domaine Authorize qui 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_id et rate_version dans le panneau d'administration
  • Lectures directes de la table payments pour 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 float64 des 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.Decimal avec arrondi centralisé ; évitez float64 même en tests.
  • État unknown explicite 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

Advertisement 728x90

Lire ensuite