DDD en acción: Cómo fijar el tipo de cambio en los pagos resolvió cuatro errores críticos
En los sistemas de pagos, los errores en los cálculos de tipos de cambio generan discrepancias financieras y pérdida de confianza. Un caso real en Go muestra cómo un modelo de dominio con un tipo fijo en el agregado resuelve problemas que no son evidentes en los ejemplos teóricos de DDD.
El problema de tres tipos de cambio en un solo pago
La arquitectura original obtenía los tipos de múltiples fuentes: checkout-api los sacaba de Redis, payment-service vía HTTP de fx-rate-service, y ledger-service de una instantánea en Postgres. Esto causaba discrepancias: el cliente veía 12.430,00 KZT, el banco cobraba 12.435,27 KZT y el soporte en el panel de administración veía 12.428,91 KZT. Cada servicio era técnicamente correcto, pero el sistema en conjunto producía datos inconsistentes.
La solución temporal —desactivar la caché en checkout-api para pagos grandes— empeoró la latencia p95 de 120 ms a 500 ms. La solución permanente introdujo una entidad de dominio Quote que fija el tipo, el monto y la vigencia como un contrato comercial. Ahora los pagos se crean mediante quote_id en lugar de datos dinámicos. Esto eliminó la posibilidad de recalcular el tipo durante el cobro o la entrada en el libro mayor.
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 tabla payments se desnormalizó (almacenando copias de tipos y montos), pero garantizó la atomicidad de los datos para auditorías. Una sola fila ahora contiene toda la información necesaria para analizar un incidente.
Errores de redondeo: float64 vs decimal.Decimal
El segundo error crítico se manifestó como discrepancias acumuladas en 8.000 filas durante la noche. El problema provenía de redondeos inconsistentes: payment-service usaba math.Round, billing-service usaba decimal.NewFromFloat y los informes usaban la función round() de SQL. Convertir float64 a decimal mediante NewFromFloat causaba pérdida de precisión.
La solución temporal fue un trabajo nocturno rounding_adjustments que acumulaba discrepancias de hasta 1 KZT. La solución final incluyó:
- Prohibir
float64en las estructuras de dominio - Aceptar montos como cadenas mediante
decimal.NewFromString - Redondeo centralizado en el método
Money.RoundByCurrency - Vincular la precisión a la moneda (p. ej., JPY redondea a números enteros)
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,
}
}
El rendimiento de operaciones por lotes cayó de 1,8 a 6,4 segundos para 200.000 filas, pero es aceptable para procesos de backoffice. La precisión tuvo prioridad sobre la velocidad.
Idempotencia vs tiempos de espera de APIs externas
El tercer problema surgió con el proveedor de pagos: un tiempo de espera de 3 segundos en acquirer-api provocó cobros duplicados. Al exceder el context deadline, el sistema reintentaba, pero la primera solicitud aún era procesada por el proveedor. Resultado: cobro doble del mismo monto.
El primer parche —desactivar reintentos automáticos— aumentó los pagos atascados en estado unknown. La solución final:
idempotency_keyobligatoria con un índice único en(merchant_id, idempotency_key)- Método de dominio
Authorizeque verifica el estado actual del pago - Trabajador de reconciliación para operaciones atascadas
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
// ...
}
}
El estado PaymentUnknown se convirtió en un estado explícito del sistema que refleja la incertidumbre de la API externa. El trabajador de reconciliación maneja pagos con más de 90 segundos de antigüedad consultando el estado al proveedor.
Retraso en el modelo de lectura: Datos en tiempo real para soporte
Tras implementar Quote, surgió un nuevo problema: el panel de administración de soporte mostraba datos obsoletos debido al retraso de Kafka (3-4 minutos). Aunque los pagos eran correctos, los operadores veían montos antiguos, generando pánico en los clientes.
Parches rápidos:
- Mostrar
quote_idyrate_versionen el panel de administración - Lecturas directas de la tabla
paymentspara pagos de menos de 15 minutos - Reducir el umbral de alerta de retraso de 10 a 60 segundos
La solución final dividió las fuentes de datos:
- Para pagos recientes: la tabla
payments - Para datos históricos: el modelo de lectura basado en ledger
Esto añadió complejidad al panel de administración, pero garantizó frescura de datos en escenarios críticos.
DDD sin dogmas: El agregado como núcleo del dominio
El éxito del proyecto no radicó en una carpeta domain, sino en repensar el tipo de cambio como un contrato comercial. El agregado Payment ahora:
- Valida la caducidad de
Quoteal crearse - Fija el monto tras la confirmación
- Excluye
float64de las operaciones de dominio - Garantiza cobros idempotentes
- Maneja correctamente la incertidumbre
unknown
La capa de aplicación se volvió delgada: solo orquesta pasos, delegando lógica de negocio al agregado. Ejemplo de uso:
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)
// ...
}
Lección clave: DDD funciona cuando las reglas de dominio están integradas en el modelo, no tratadas como detalles técnicos.
Lecciones clave
- Tipo fijo en el agregado elimina discrepancias entre servicios. El tipo debe formar parte del contrato de pago, no ser un parámetro dinámico.
- Tipos precisos para dinero. Usa
decimal.Decimalcon redondeo centralizado; evitafloat64incluso en pruebas. - Estado
unknownexplícito refleja honestamente la incertidumbre de sistemas externos. Añade un trabajador de reconciliación para resolución manual. - Fuentes de datos divididas es crítico para operaciones de soporte. Lee operaciones recientes directamente de la tabla, no del modelo de lectura.
— Editorial Team
Aún no hay comentarios.