Multi-Currency Service Low-Level Design: FX Rate Ingestion, Conversion, and Display Rounding

What Is a Multi-Currency Service?

A multi-currency service handles ingestion of foreign exchange (FX) rates, converts monetary amounts between currencies with correct decimal precision per ISO 4217, applies display rounding rules for UI presentation, and logs every conversion for audit purposes. E-commerce platforms, international payroll systems, and fintech apps all require this layer. The challenge is precision: floating-point arithmetic is unsuitable for money, and each currency has its own minor unit exponent that governs rounding.

Requirements

Functional Requirements

  • Ingest FX rates from one or more provider feeds (e.g., ECB, Open Exchange Rates)
  • Store rates with provider, timestamp, and bid/ask or mid-rate
  • Convert amounts between any two supported currencies
  • Apply ISO 4217 precision (e.g., JPY = 0 decimal places, USD = 2, KWD = 3)
  • Apply display rounding separately from storage precision
  • Produce an audit trail for every conversion with rate used and timestamp

Non-Functional Requirements

  • No floating-point arithmetic in monetary calculations; use fixed-point or arbitrary precision
  • Rate freshness: maximum staleness configurable per use case (e.g., 1 minute for checkout, 1 hour for reporting)
  • High read throughput for conversion endpoint (used on every price display)
  • Audit log must be append-only and immutable

Data Model

  • currencies: currency_code (ISO 4217, PK), name, minor_unit_exponent (0-3), symbol, is_active
  • fx_rates: rate_id, base_currency, quote_currency, rate (DECIMAL(20,10)), provider, effective_at, ingested_at, is_current
  • fx_rate_history: same schema as fx_rates; all rows including superseded rates; partitioned by effective_at date
  • conversion_audit: audit_id, reference_id (caller-supplied), from_currency, to_currency, from_amount, to_amount, rate_used, rate_id, converted_at, requester_id

Store rate as DECIMAL(20,10) to avoid floating-point representation errors. The fx_rates table keeps only the current rate per (base, quote) pair; fx_rate_history retains the full series. Monetary amounts in all tables are stored as integers in the smallest unit (cents for USD, yen for JPY) to eliminate decimal handling in storage.

Core Algorithms

FX Rate Ingestion

A scheduled poller fetches rates from each provider at the configured interval. On each fetch: parse the provider response into (base, quote, rate, effective_at) tuples, upsert into fx_rates (update where base+quote match, insert new row), write all received rates to fx_rate_history. Use a provider priority list: if multiple providers supply the same pair, prefer the higher-priority source and fall back on staleness threshold breach.

Conversion with ISO 4217 Precision

Use arbitrary-precision decimal arithmetic (Python Decimal, Java BigDecimal, or a fixed-point library in other languages). Algorithm: fetch rate from fx_rates for (from_currency, to_currency); if no direct pair exists, use a triangulation via a base currency (e.g., USD): rate(A->B) = rate(A->USD) * rate(USD->B). Apply the rate to from_amount using ROUND_HALF_EVEN (banker rounding) to the minor_unit_exponent of to_currency. Write to conversion_audit before returning the result. The audit write is synchronous to guarantee every returned conversion is recorded.

Display Rounding

Display rounding is separate from storage rounding and may differ by context. Common rules: round to 2 significant digits for small amounts, use currency-specific display precision from the currencies table for standard display, apply locale-specific grouping separators and decimal marks. Format only at the presentation layer; never store display-formatted strings in the database. Return amounts as integers in minor units via the API and let the client format using the minor_unit_exponent.

API Design

  • GET /rates/{base}/{quote} — fetch current rate with effective_at and provider
  • GET /rates/{base}/{quote}/history?from=&to= — rate time series for a window
  • POST /convert — body: from_currency, to_currency, amount (integer minor units), reference_id; returns to_amount and rate_used
  • GET /convert/audit/{reference_id} — fetch audit record for a specific conversion
  • GET /currencies — list all supported currencies with minor_unit_exponent

Scalability Considerations

FX rate reads are extremely high frequency (every price display hits this service). Cache the current rate table entirely in Redis as a hash map keyed by base:quote, refreshed on every ingestion cycle. Conversion requests read from Redis, not from the database, achieving sub-millisecond lookups. The database is the source of truth and audit store, not the hot read path.

The conversion_audit table grows at the rate of all conversions across all services. Partition by converted_at (monthly). Archive partitions older than the retention window to cold storage. Write audit rows in async batches when exact real-time visibility is not required, using a write buffer flushed every few seconds; accept small audit lag in exchange for higher write throughput.

For triangulated pairs (no direct rate), precompute and cache cross rates at ingestion time for the most common currency pairs to avoid two Redis lookups and a multiplication on the hot path.

Summary

A multi-currency service ingests FX rates from external providers into a decimal-precision rate table cached in Redis, performs conversions using arbitrary-precision arithmetic rounded to ISO 4217 minor unit exponents, separates display formatting from storage, and appends every conversion to a partitioned audit table. Keeping rates in Redis and amounts as integers throughout the data model eliminates floating-point errors and keeps the conversion path fast.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you design an FX rate ingestion pipeline?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Poll one or more rate providers (ECB, Open Exchange Rates, internal treasury feed) on a schedule (e.g., every 5 minutes for live rates). Normalize each rate as (base_currency, quote_currency, rate, fetched_at, source). Persist to a rates table for auditability; cache the latest rates in Redis for low-latency reads. Apply a staleness threshold — if the latest rate is older than N minutes, surface a degraded-mode warning and optionally fall back to a secondary provider.”
}
},
{
“@type”: “Question”,
“name”: “Why store monetary amounts as integers with ISO 4217 precision?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Floating-point types (float, double) cannot represent most decimal fractions exactly, causing accumulating rounding errors in financial calculations. Instead, store amounts as integers in the currency's minor unit (cents for USD, fils for KWD, etc.). ISO 4217 defines the exponent (number of decimal places) per currency — USD has 2, JPY has 0, KWD has 3. Divide by 10^exponent only at display time. This makes arithmetic exact and comparisons safe.”
}
},
{
“@type”: “Question”,
“name”: “How does triangulation via a base currency work?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Rather than storing N*(N-1) pairwise rates, store only N rates against a single base currency (e.g., USD). To convert EUR → GBP: convert EUR → USD using rate(EUR/USD), then USD → GBP using rate(USD/GBP) = 1/rate(GBP/USD). This reduces storage and update complexity to O(N). The tradeoff is two multiplications per cross-rate conversion and slight precision loss from chaining; for display purposes this is acceptable. For high-value FX desks, store direct rates for major pairs.”
}
},
{
“@type”: “Question”,
“name”: “What are the display rounding modes for multi-currency amounts?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Common rounding modes per IEEE 754 and accounting standards: HALF_UP (round 0.5 away from zero — most familiar to users), HALF_EVEN / banker's rounding (round 0.5 to nearest even digit — reduces statistical bias in large batches), FLOOR (always round down — used for fee calculations to favor the platform), CEILING (always round up). Use HALF_EVEN for aggregations and reporting; use HALF_UP for per-transaction display. Always specify the rounding mode explicitly — never rely on language defaults.”
}
}
]
}

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

See also: Shopify Interview Guide

Scroll to Top