Currency Converter Service — Low-Level Design
A currency converter service fetches exchange rates from external providers, caches them, and applies them to monetary amounts with precision. It must handle rate staleness, currency normalization, and rounding rules. This design is relevant to interviews at Stripe, Coinbase, and any fintech or marketplace company.
Core Data Model
ExchangeRate
base_currency TEXT NOT NULL -- 'USD'
quote_currency TEXT NOT NULL -- 'EUR'
rate NUMERIC(20,10) -- 0.9234567890 (10 decimal places)
provider TEXT NOT NULL -- 'openexchangerates', 'ecb'
fetched_at TIMESTAMPTZ NOT NULL
PRIMARY KEY (base_currency, quote_currency, provider)
-- Materialized view: latest rate per currency pair
CREATE MATERIALIZED VIEW LatestRate AS
SELECT DISTINCT ON (base_currency, quote_currency)
base_currency, quote_currency, rate, fetched_at
FROM ExchangeRate
ORDER BY base_currency, quote_currency, fetched_at DESC;
CREATE UNIQUE INDEX ON LatestRate(base_currency, quote_currency);
Fetching and Caching Rates
def refresh_rates():
"""Run every 60 seconds via cron."""
# Most providers return all rates from a base currency (USD)
response = requests.get(
'https://openexchangerates.org/api/latest.json',
params={'app_id': OPENEXCHANGE_API_KEY, 'base': 'USD'}
)
data = response.json()
rates = data['rates'] # {'EUR': 0.92, 'GBP': 0.79, ...}
rows = [
{'base': 'USD', 'quote': code, 'rate': rate,
'provider': 'openexchangerates', 'fetched_at': now()}
for code, rate in rates.items()
]
db.bulk_insert(ExchangeRate, rows, on_conflict='update')
# Refresh the materialized view
db.execute('REFRESH MATERIALIZED VIEW CONCURRENTLY LatestRate')
# Cache in Redis with 90-second TTL (longer than refresh interval for resilience)
for code, rate in rates.items():
redis.set(f'rate:USD:{code}', str(rate), ex=90)
redis.set('rates:last_updated', now().isoformat(), ex=90)
Conversion with Precision
from decimal import Decimal, ROUND_HALF_UP
def convert(amount_cents, from_currency, to_currency):
"""Convert an amount (in smallest unit) between currencies."""
if from_currency == to_currency:
return amount_cents
rate = get_rate(from_currency, to_currency)
# Work in Decimal to avoid float errors
amount = Decimal(amount_cents)
converted = amount * Decimal(str(rate))
# Round to nearest integer (smallest currency unit)
return int(converted.quantize(Decimal('1'), rounding=ROUND_HALF_UP))
def get_rate(from_currency, to_currency):
"""Get rate with Redis cache, fallback to DB."""
# Try direct rate first
cached = redis.get(f'rate:{from_currency}:{to_currency}')
if cached:
return Decimal(cached)
# Cross-rate via USD: from → USD → to
rate_to_usd = get_direct_rate(from_currency, 'USD')
rate_usd_to_target = get_direct_rate('USD', to_currency)
return rate_to_usd * rate_usd_to_target
def get_direct_rate(base, quote):
cached = redis.get(f'rate:{base}:{quote}')
if cached:
return Decimal(cached)
# Fallback to DB
row = db.execute("""
SELECT rate FROM LatestRate
WHERE base_currency=%(base)s AND quote_currency=%(quote)s
""", {'base': base, 'quote': quote}).first()
if not row:
raise RateNotFound(f'No rate for {base}/{quote}')
return Decimal(str(row.rate))
Staleness Detection
def get_rate_with_staleness_check(from_currency, to_currency, max_age_seconds=300):
last_updated = redis.get('rates:last_updated')
if last_updated:
age = (now() - datetime.fromisoformat(last_updated)).total_seconds()
if age > max_age_seconds:
# Rates are stale — alert and optionally refuse conversion
alert_on_call(f'Exchange rates stale: {age:.0f}s old')
if age > 3600:
raise StaleRatesError('Exchange rates unavailable, try again later')
return get_rate(from_currency, to_currency)
Display Formatting per Currency
CURRENCY_CONFIG = {
'USD': {'symbol': '$', 'decimals': 2, 'position': 'before'},
'EUR': {'symbol': '€', 'decimals': 2, 'position': 'before'},
'JPY': {'symbol': '¥', 'decimals': 0, 'position': 'before'}, # no cents
'KWD': {'symbol': 'KD', 'decimals': 3, 'position': 'after'}, # 3 decimal places
'BTC': {'symbol': '₿', 'decimals': 8, 'position': 'before'},
}
def format_amount(amount_smallest_unit, currency_code):
config = CURRENCY_CONFIG[currency_code]
divisor = 10 ** config['decimals']
amount = Decimal(amount_smallest_unit) / divisor
formatted = f"{amount:.{config['decimals']}f}"
if config['position'] == 'before':
return f"{config['symbol']}{formatted}"
return f"{formatted} {config['symbol']}"
Key Interview Points
- Never use float for money or rates: Use NUMERIC in PostgreSQL and Decimal in Python/Java. Float arithmetic has rounding errors that accumulate: 1.1 + 2.2 ≠ 3.3 in floating point.
- Cross-rate via USD: Most providers give rates relative to one base currency. Convert EUR→GBP as EUR→USD→GBP (multiply two rates). Store rates from USD as the canonical form.
- Stale rate handling: Rate refresh can fail (provider outage). Track last_updated and alert when stale. For financial applications, refuse conversions after rates exceed a threshold age (e.g., 1 hour).
- Currency-specific rounding rules: JPY has no sub-unit (0 decimal places). KWD has 3. Always round at the final step, not intermediate calculations.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”Why use Decimal instead of float for currency conversions?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Floating-point arithmetic uses binary representation, which cannot exactly represent many decimal fractions. 1.1 + 2.2 in float equals 3.3000000000000003, not 3.3. Repeated rounding errors accumulate across thousands of transactions, causing real money to be lost or gained. Use Python’s Decimal class or Java’s BigDecimal, which perform arithmetic in base-10. Store exchange rates as NUMERIC(20,10) in PostgreSQL — never as FLOAT or DOUBLE PRECISION. Compute the conversion result in Decimal, then round once at the final step.”}},{“@type”:”Question”,”name”:”How do you calculate a cross-rate between two non-USD currencies?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Most exchange rate providers give all rates relative to a single base currency (USD). To convert EUR to GBP: (1) fetch rate USD→EUR and USD→GBP, (2) compute rate EUR→GBP = (1 / USD→EUR) * (USD→GBP), or equivalently: divide the target rate by the base rate. Example: if USD→EUR=0.92 and USD→GBP=0.79, then EUR→GBP=0.79/0.92=0.8587. Cache the computed cross-rates in Redis with the same TTL as the raw rates.”}},{“@type”:”Question”,”name”:”How do you detect and handle stale exchange rates?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Track the last_updated timestamp of rate fetches in Redis. On every conversion request, check if last_updated is older than a threshold (e.g., 5 minutes for tolerance, 60 minutes for hard limit). For a soft staleness (5 min): log a warning and continue. For hard staleness (60 min+): return a 503 Service Unavailable or use cached rates with a staleness warning in the response. Alert the on-call team when the rate refresh job fails. Never silently use rates that are hours old for financial transactions.”}},{“@type”:”Question”,”name”:”How do you format currency amounts for different locales?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Different currencies have different decimal places: JPY has 0 (no sub-unit), USD has 2 (cents), KWD has 3 (fils). Store amounts in the smallest unit (integer cents/fils). For display: divide by 10^decimal_places. Use a CURRENCY_CONFIG lookup table keyed by ISO 4217 currency code to get decimal places, symbol, and symbol position. For locale-specific number formatting (comma vs period as thousands separator), use the platform’s internationalization library (Intl.NumberFormat in JavaScript, java.util.Currency in Java) rather than hand-rolling string formatting.”}},{“@type”:”Question”,”name”:”How do you prevent multiple concurrent rate refreshes?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a distributed lock: SET currency:refresh:lock 1 NX EX 120 in Redis. The NX flag (set if not exists) ensures only one process acquires the lock. The EX 120 sets a 120-second expiry so the lock is released even if the process crashes. The winner refreshes rates; others skip. Without a lock, multiple servers all attempt to refresh simultaneously at the cron interval, generating N × provider_cost API calls per minute and potentially triggering rate limiting on the provider’s API.”}}]}
Currency conversion and financial precision design is discussed in Stripe system design interview questions.
Currency converter and exchange rate system design is covered in Coinbase system design interview preparation.
Multi-currency and conversion system design is discussed in Shopify system design interview guide.