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