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:
- Calls the provider adapter.
- Passes rates through RateValidator (bounds check, spike detection, completeness check).
- Inserts valid rates into the exchange_rates table.
- Updates the Redis rate cache with the latest value per (base, quote) pair.
- 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: trueflag 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