Back to Home

DDD in Go: how to avoid errors with exchange rates in payments

Analysis of four critical bugs in a payment system on Go. It shows how implementing DDD with exchange rate fixation in the domain aggregate solves discrepancy issues, rounding errors, and double debits. Specific technical solutions with code examples are proposed.

How one payment got three rates: DDD lessons in a real payment system
Advertisement 728x90

DDD in Action: How Locking the Exchange Rate in Payments Fixed Four Critical Bugs

In payment systems, errors in currency exchange rate calculations lead to financial discrepancies and loss of trust. A real-world Go case study shows how a domain model with a fixed rate in the aggregate solves problems that aren't apparent in textbook DDD examples.

The Problem of Three Exchange Rates in One Payment

The original architecture pulled rates from multiple sources: checkout-api got data from Redis, payment-service via HTTP from fx-rate-service, and ledger-service from a Postgres snapshot. This caused discrepancies: the customer saw 12,430.00 KZT, the bank charged 12,435.27 KZT, and support in the admin panel saw 12,428.91 KZT. Each service was technically correct, but the system as a whole produced inconsistent data.

The temporary fix—disabling cache in checkout-api for large payments—worsened p95 latency from 120 ms to 500 ms. The permanent solution introduced a domain entity Quote that locks the rate, amount, and lifetime as a business contract. Now payments are created via quote_id instead of dynamic data. This eliminated the possibility of recalculating the rate during charging or ledger entry.

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
	}
	// ...
}

The payments table became denormalized (storing copies of rates and amounts), but it ensured data atomicity for audits. A single row now holds all the info needed to analyze an incident.

Rounding Errors: float64 vs decimal.Decimal

The second critical bug showed up as accumulating discrepancies across 8,000 rows overnight. The issue stemmed from inconsistent rounding: payment-service used math.Round, billing-service used decimal.NewFromFloat, and reports used SQL's round() function. Converting float64 to decimal via NewFromFloat caused precision loss.

The temporary fix was a nightly job rounding_adjustments that accumulated discrepancies up to 1 KZT. The final solution included:

Google AdInline article slot
  • Banning float64 in domain structs
  • Accepting amounts as strings via decimal.NewFromString
  • Centralized rounding in the Money.RoundByCurrency method
  • Tying precision to currency (e.g., JPY rounds to whole numbers)
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,
	}
}

Batch operation performance dropped from 1.8 to 6.4 seconds for 200k rows, but that's acceptable for backoffice processes. Accuracy took priority over speed.

Idempotency vs External API Timeouts

The third issue arose with the payment provider: a 3-second timeout in acquirer-api led to duplicate charges. On context deadline exceeded, the system retried, but the first request was still processed by the provider. Result: double charging of the same amount.

The first workaround—disabling automatic retries—increased payments stuck in unknown status. The final solution:

Google AdInline article slot
  • Mandatory idempotency_key with a unique index on (merchant_id, idempotency_key)
  • Domain method Authorize that checks the current payment status
  • Reconciliation worker for stuck operations
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
		// ...
	}
}

PaymentUnknown status became an explicit system state reflecting external API uncertainty. The reconciliation worker handles payments older than 90 seconds by querying the provider for status.

Read Model Lag: Real-Time Data for Support

After implementing Quote, a new issue emerged: the support admin panel showed stale data due to Kafka lag (3-4 minutes). Even though payments were correct, operators saw old amounts, sparking customer panic.

Quick fixes:

  • Displaying quote_id and rate_version in the admin panel
  • Direct reads from the payments table for payments under 15 minutes old
  • Lowering the lag alert threshold from 10 to 60 seconds

The final solution split data sources:

  • For recent payments: the payments table
  • For historical data: the ledger-based read model

This added complexity to the admin panel but ensured data freshness in critical scenarios.

DDD Without Dogma: The Aggregate as the Domain Core

The project's success wasn't about a domain folder but rethinking the exchange rate as a business contract. The Payment aggregate now:

  • Validates Quote expiration on creation
  • Locks the amount after confirmation
  • Excludes float64 from domain operations
  • Ensures idempotent charges
  • Properly handles unknown uncertainty

The application layer became thin: it just orchestrates steps, delegating business logic to the aggregate. Usage example:

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)
	// ...
}

Key takeaway: DDD works when domain rules are baked into the model, not treated as technical details.

Key Takeaways

  • Fixed rate in the aggregate eliminates inter-service discrepancies. The rate must be part of the payment contract, not a dynamic parameter.
  • Precise money types. Use decimal.Decimal with centralized rounding; avoid float64 even in tests.
  • Explicit unknown state honestly reflects external system uncertainty. Add a reconciliation worker for manual resolution.
  • Split data sources is critical for support ops. Read recent operations directly from the table, not the read model.

— Editorial Team

Advertisement 728x90

Read Next