Currency Converter Service Low-Level Design

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.

Scroll to Top