Low Level Design: Currency Conversion Service

Problem Statement

Design a currency conversion service that ingests exchange rates from one or more external providers, stores current and historical rates, performs accurate currency conversions at runtime, enforces rounding policies per currency, and provides historical rate lookup for audit and reporting purposes.

Core Requirements

  • Ingest exchange rates from external providers on a configurable schedule
  • Store a full history of rates with timestamps
  • Convert an amount from one currency to another using the latest or a specified historical rate
  • Support indirect conversion via a pivot currency (USD) when a direct pair is unavailable
  • Enforce per-currency rounding policies
  • Expose a read-optimized API suitable for low-latency inline calls
  • Alert on stale or anomalous rates

Key Entities

ExchangeRate

ExchangeRate {
  id           : UUID
  baseCurrency : string     // ISO 4217, e.g. USD
  quoteCurrency: string     // ISO 4217, e.g. EUR
  rate         : decimal    // how many quote units per 1 base unit
  provider     : string
  effectiveAt  : timestamp
  ingestedAt   : timestamp
}

ConversionRequest

ConversionRequest {
  amount        : decimal
  fromCurrency  : string
  toCurrency    : string
  asOf          : timestamp   // optional; defaults to latest
  roundingPolicy: HALF_UP | HALF_EVEN | CEILING | FLOOR | TRUNCATE
}

ConversionResult

ConversionResult {
  originalAmount   : decimal
  convertedAmount  : decimal
  fromCurrency     : string
  toCurrency       : string
  rateUsed         : decimal
  rateEffectiveAt  : timestamp
  provider         : string
  conversionPath   : string[]   // e.g. [USD, EUR] or [GBP, USD, JPY]
  roundedAt        : string     // rounding policy name
}

CurrencyConfig

CurrencyConfig {
  code            : string
  decimalPlaces   : int        // 2 for USD, 0 for JPY, 3 for KWD
  roundingPolicy  : string
  minorUnitFactor : int        // 100 for USD, 1 for JPY
}

High-Level Architecture

External Rate Providers (ECB, Open Exchange Rates, Fixer.io)
  |
  v
RateIngestionWorker (scheduled, per provider)
  |
  v
RateValidator --> RateStore (Postgres + Redis)
  |
  v
ConversionService
  |-- RateLookup
  |-- CrossRateCalculator
  |-- RoundingService
  |
  v
ConversionResult
  |
  v
AuditLog (Kafka -> S3/ClickHouse)

Rate Ingestion

Each provider has a dedicated ingestion adapter implementing a common interface:

interface RateProvider {
  fetchRates(baseCurrency: string): ExchangeRate[]
}

The ingestion worker runs on a cron (e.g., every 60 seconds for major pairs, every 10 minutes for exotic pairs). It:

  1. Calls the provider adapter.
  2. Passes rates through RateValidator (bounds check, spike detection, completeness check).
  3. Inserts valid rates into the exchange_rates table.
  4. Updates the Redis rate cache with the latest value per (base, quote) pair.
  5. Publishes a rate_updated event for downstream consumers (e.g., the price engine).

Providers are prioritized. If provider A returns a rate for EUR/USD and provider B also does, provider A wins unless its rate is flagged as anomalous.

Rate Storage

The persistent store uses a time-series-friendly schema:

exchange_rates (
  id              UUID DEFAULT gen_random_uuid(),
  base_currency   CHAR(3)        NOT NULL,
  quote_currency  CHAR(3)        NOT NULL,
  rate            NUMERIC(20,10) NOT NULL,
  provider        VARCHAR(64)    NOT NULL,
  effective_at    TIMESTAMPTZ    NOT NULL,
  ingested_at     TIMESTAMPTZ    NOT NULL DEFAULT now(),
  PRIMARY KEY (id)
)

An index on (base_currency, quote_currency, effective_at DESC) supports the common query pattern: latest rate for a pair and point-in-time lookups. Older rows are archived to cold storage (e.g., S3 Parquet via pg_partman range partitioning by month) after 90 days, but remain queryable for historical conversion audits.

Redis Cache Layout

rate:{base}:{quote}  ->  { rate, effectiveAt, provider }   TTL: 120s
rate:config:{code}   ->  CurrencyConfig                    TTL: 3600s

Cache misses fall back to Postgres. The TTL is intentionally short so stale rates are evicted quickly; the ingestion worker refreshes the cache on every successful ingest cycle.

Conversion Logic

Direct Conversion

function convert(request):
  rate = rateLookup(request.fromCurrency, request.toCurrency, request.asOf)
  if rate not found:
    return crossConvert(request)
  raw = request.amount * rate.rate
  rounded = roundingService.round(raw, request.toCurrency, request.roundingPolicy)
  return ConversionResult(raw, rounded, rate, [from, to])

Cross-Rate (Triangulation)

When a direct pair is unavailable, the service routes through a pivot currency (configurable, defaulting to USD):

function crossConvert(request):
  pivotCurrency = config.pivotCurrency  // USD
  rateFromToPivot = rateLookup(request.fromCurrency, pivotCurrency, request.asOf)
  ratePivotToTarget = rateLookup(pivotCurrency, request.toCurrency, request.asOf)
  if either not found:
    throw CurrencyPairUnavailableException
  crossRate = rateFromToPivot.rate * ratePivotToTarget.rate
  raw = request.amount * crossRate
  rounded = roundingService.round(raw, request.toCurrency, request.roundingPolicy)
  return ConversionResult(raw, rounded, crossRate, [from, pivot, to])

Rounding Policy Enforcement

Each currency has a canonical decimal place count and default rounding policy stored in CurrencyConfig. The caller may override the rounding policy per request, but the decimal place count is always enforced from the currency config (a caller cannot request 5 decimal places for USD).

function round(amount, toCurrency, policyOverride):
  config = currencyConfigCache.get(toCurrency)
  policy = policyOverride ?? config.roundingPolicy
  factor = 10 ^ config.decimalPlaces
  switch policy:
    HALF_UP   : return floor(amount * factor + 0.5) / factor
    HALF_EVEN : return bankersRound(amount, config.decimalPlaces)
    CEILING   : return ceil(amount * factor) / factor
    FLOOR     : return floor(amount * factor) / factor
    TRUNCATE  : return trunc(amount * factor) / factor

HALF_EVEN (banker's rounding) is the default for financial applications as it eliminates systematic bias over large populations of transactions.

Historical Rate Lookup

The asOf parameter on ConversionRequest enables point-in-time conversion. The query selects the rate row with the largest effective_at that is still <= asOf for the given pair and provider priority order.

SELECT rate, effective_at, provider
FROM exchange_rates
WHERE base_currency = $1
  AND quote_currency = $2
  AND effective_at <= $3
ORDER BY effective_at DESC, provider_priority ASC
LIMIT 1

Results are not cached (historical asOf values are unique and rarely re-queried), so this path always hits Postgres. For audit replay scenarios, the service additionally returns the exact rate row ID so auditors can reference the canonical stored value.

Staleness Detection and Alerting

A background monitor checks that each major currency pair was updated within its expected freshness window (e.g., EUR/USD within 2 minutes, exotic pairs within 20 minutes). If a pair is stale:

  • A metric counter is incremented (stale_rate_pairs_total).
  • An alert fires to PagerDuty if staleness exceeds 2x the expected window.
  • The ConversionService adds a rateIsStale: true flag to the ConversionResult so callers can decide how to handle it (reject, warn, or accept).

Anomaly Detection

RateValidator checks each incoming rate against the last known value for the pair:

  • Reject if rate deviates by more than a configurable spike threshold (e.g., 10% change in one tick).
  • Reject if rate is zero or negative.
  • Reject if the provider timestamp is older than the current stored rate (out-of-order delivery).

Rejected rates are written to a quarantine table for manual review, not silently dropped.

API Design

POST /v1/currency/convert
Body: ConversionRequest
Response: ConversionResult

GET /v1/currency/rate/{base}/{quote}
Query: asOf (optional timestamp)
Response: ExchangeRate

GET /v1/currency/rates/{base}
Query: asOf (optional)
Response: ExchangeRate[]

GET /v1/currency/config/{code}
Response: CurrencyConfig

GET /v1/currency/health
Response: { stalePairs: string[], lastIngestAt: map }

Performance Characteristics

  • Latest-rate conversions: served from Redis, sub-millisecond read latency, no DB hit.
  • Historical conversions: one indexed Postgres query, typically 2–5 ms.
  • Cross-rate conversions: two Redis lookups plus one rounding call, still < 5 ms end-to-end.
  • Ingestion workers are independently deployable and can be scaled per provider or pair group.

Consistency Guarantees

The service provides read-your-writes consistency within a single Redis node. For multi-region deployments, callers that need a consistent rate across a distributed transaction should pass an explicit asOf timestamp derived from the first conversion call, ensuring all subsequent conversions in the same transaction use the same effective rate, even if ingestion has produced a newer rate in the interim.

Interview Discussion Points

  • How would you handle a provider outage? Fall back to another provider or serve stale from cache?
  • What are the tradeoffs between storing rates as (base, quote) pairs versus storing only base-to-USD and computing all pairs cross-rate?
  • How would you test rounding correctness across all supported currencies?
  • How do you prevent the cache from serving a rate from the future (provider clock skew)?
  • How would you design the ingestion pipeline to handle a provider that batches rate updates versus one that streams them?

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is a currency conversion service and how does it store exchange rates?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A currency conversion service provides real-time and historical exchange rates and converts monetary amounts between currencies. Rates are fetched from one or more external providers (ECB, OpenExchangeRates, Bloomberg) and stored as (base_currency, quote_currency, rate, timestamp) rows in a time-series table or a Redis hash keyed by currency pair. The service maintains a rate cache with a TTL matching the provider’s update frequency (typically 1–60 minutes). Historical rates are retained indefinitely for audit, accounting restatement, and dispute resolution use cases.”
}
},
{
“@type”: “Question”,
“name”: “How does a currency service handle rate staleness and provider failover?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Each rate record carries a fetched_at timestamp and a staleness threshold (e.g., 5 minutes for forex, 1 minute for crypto). A background health-check loop measures the age of the most recent rate for each pair; if a pair exceeds the threshold a stale-rate alert is raised and the service falls back to the secondary provider. Provider failover is implemented as a priority-ordered provider chain: the service tries provider 1, and on timeout or error retries with provider 2, then provider 3. Circuit breakers prevent hammering a degraded upstream. If all providers fail, the last known rate is served with a staleness flag so callers can decide whether to proceed or block the transaction.”
}
},
{
“@type”: “Question”,
“name”: “How is cross-rate conversion calculated when a direct rate is not available?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When a direct (A→B) rate is absent, the service computes a cross-rate via a common pivot currency, usually USD or EUR: rate(A→B) = rate(A→USD) / rate(B→USD). For exotic pairs a two-hop path may not exist; the service then performs a shortest-path search on the rate graph (currencies as nodes, available direct pairs as weighted edges) using Dijkstra’s algorithm, minimizing accumulated spread. The resulting chain of conversions is logged so the caller can reproduce the math. Cross-rate computation introduces additional spread error, so the service applies a configurable tolerance check and rejects conversions whose path length exceeds a maximum hop count.”
}
},
{
“@type”: “Question”,
“name”: “How does a currency service enforce rounding policies for different currencies?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “ISO 4217 defines the minor unit (decimal places) for each currency: USD uses 2, JPY uses 0, KWD uses 3. The service loads this metadata at startup and applies it during conversion. All intermediate arithmetic is performed in a high-precision decimal type; rounding to the target currency’s minor unit is the last operation. The rounding mode (HALF_UP, HALF_EVEN / banker’s rounding) is configurable per use case: retail-facing conversions typically use HALF_UP, while financial settlement uses HALF_EVEN to eliminate systematic bias over large volumes. The service also supports a display precision separate from the settlement precision for UI presentation.”
}
}
]
}

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: Coinbase Interview Guide

Scroll to Top