Skip to main content
The Currency Converter API is built with a clean, maintainable architecture that separates concerns across four distinct layers. This design ensures reliability, testability, and flexibility.

System overview

The API aggregates exchange rates from three external providers (Fixer.io, OpenExchangeRates, and CurrencyAPI), averages them for accuracy, caches results in Redis, and persists history in PostgreSQL.

4-layer architecture

The project follows a strict layered architecture where each layer only depends on the layer directly below it:
api/              ← Layer 1: HTTP interface
application/      ← Layer 2: Business logic & orchestration
domain/           ← Layer 3: Core entities & exceptions
infrastructure/   ← Layer 4: External systems (DB, cache, APIs)
This means changing how Redis stores data never touches business logic. Swapping a currency provider only requires changes in infrastructure/providers/.

Layer 1: API layer

Handles HTTP concerns only—no business logic lives here.
FileResponsibility
main.pyFastAPI app creation, lifespan startup/shutdown
routes/currency.pyHTTP endpoints, path parameter parsing
schemas/requests.pyPydantic input validation
schemas/responses.pyPydantic response shaping
dependencies.pyDependency injection wiring
error_handlers.pyMaps domain exceptions to HTTP status codes
@router.get(
    '/convert/{from_currency}/{to_currency}/{amount}',
    response_model=ConversionResponse,
    status_code=status.HTTP_200_OK,
)
async def convert_currency(
    from_currency: Annotated[str, Path(min_length=3, max_length=5)],
    to_currency: Annotated[str, Path(min_length=3, max_length=5)],
    amount: Annotated[Decimal, Path(gt=0, decimal_places=2)],
    service: Annotated[ConversionService, Depends(get_conversion_service)],
) -> ConversionResponse:
    from_currency = from_currency.upper()
    to_currency = to_currency.upper()
    result = await service.convert(amount, from_currency, to_currency)
    return ConversionResponse(**result)

Layer 2: Application layer

Orchestrates business logic by coordinating between domain models and infrastructure.
ServiceResponsibility
CurrencyServiceManaging supported currencies, validating currency codes
RateServiceFetching, aggregating, and caching exchange rates
ConversionServiceOrchestrating end-to-end currency conversion
application/services/conversion_service.py
class ConversionService:
    def __init__(self, rate_service: RateService, currency_service: CurrencyService):
        self.rate_service = rate_service
        self.currency_service = currency_service

    async def convert(self, amount: Decimal, from_currency: str, to_currency: str) -> dict:
        await self.currency_service.validate_currency(from_currency)
        await self.currency_service.validate_currency(to_currency)
        
        rate = await self.rate_service.get_rate(from_currency, to_currency)
        converted_amount = amount * rate.rate
        
        return {
            'from_currency': from_currency,
            'to_currency': to_currency,
            'original_amount': amount,
            'converted_amount': converted_amount,
            'exchange_rate': rate.rate,
            'timestamp': rate.timestamp,
            'source': rate.source,
        }

Layer 3: Domain layer

Completely framework-free—just pure Python dataclasses and exceptions.
ModuleResponsibility
models/currency.pyFrozen dataclasses: ExchangeRate, SupportedCurrency, AggregatedRate
exceptions/currency.pyTyped exceptions: InvalidCurrencyError, ProviderError, CacheError
No FastAPI, SQLAlchemy, or Redis imports here. This makes domain logic trivially testable.

Layer 4: Infrastructure layer

Handles all external dependencies and I/O operations.
ModuleResponsibility
providers/HTTP clients for each exchange rate API
cache/redis_cache.pyRedis read/write with TTL management
persistence/database.pySQLAlchemy async engine and session factory
persistence/models/ORM table definitions
persistence/repositories/All database and cache queries

Request flow

Here’s what happens when you request a currency conversion:
1

Request validation

Pydantic validates path parameters in the API layer
GET /api/convert/USD/EUR/100
2

Currency validation

CurrencyService validates both currency codes against the supported list (cached in Redis)
await currency_service.validate_currency("USD")
await currency_service.validate_currency("EUR")
3

Cache check

RateService checks Redis for a recent rate (within 5 minutes)
rate = await redis.get("rate:USD:EUR")
If cache HIT → skip to step 6
4

Parallel provider fetch

If cache MISS → fetch from all three providers simultaneously
results = await asyncio.gather(
    fixerio.fetch_rate("USD", "EUR"),
    openexchange.fetch_rate("USD", "EUR"),
    currencyapi.fetch_rate("USD", "EUR"),
    return_exceptions=True
)
# Example results:
# [Decimal('0.9250'), Decimal('0.9260'), ProviderError(...)]
5

Rate aggregation

Average successful responses, tolerating partial failures
valid_rates = [r for r in results if isinstance(r, Decimal)]
averaged_rate = sum(valid_rates) / len(valid_rates)
# (0.9250 + 0.9260) / 2 = 0.9255
6

Cache and persist

Store the rate in both Redis (temporary) and PostgreSQL (permanent)
await redis.set("rate:USD:EUR", json_data, ex=300)  # 5 min TTL
await db.insert_rate_history(rate_data)
7

Calculate and return

Perform the conversion and return the response
converted_amount = 100 × 0.9255 = 92.55
return ConversionResponse(...)
Subsequent requests for USD/EUR within 5 minutes skip steps 4-5 entirely, requiring zero external API calls.

Infrastructure components

Redis caching

Redis stores rates and supported currencies with different TTLs:
# Key schema
rate:{from}:{to}       → ExchangeRate as JSON  (TTL: 5 minutes)
currencies:supported   → list[str] as JSON     (TTL: 24 hours)
# Storing a rate
data = {
    "from_currency": "USD",
    "to_currency": "EUR",
    "rate": "0.9255",  # Decimal serialized as string
    "timestamp": "2026-03-04T14:30:00Z",
    "source": "averaged"
}
await redis.set("rate:USD:EUR", json.dumps(data), ex=300)
Decimal values are always serialized as strings to preserve precision. Never serialize floats directly.

PostgreSQL schema

Two tables store currency metadata and historical rates:
CREATE TABLE supported_currencies (
    code  VARCHAR(5)   PRIMARY KEY,
    name  VARCHAR(100) NULLABLE
);

CREATE TABLE rate_history (
    id            SERIAL PRIMARY KEY,
    from_currency VARCHAR(5)    NOT NULL,
    to_currency   VARCHAR(5)    NOT NULL,
    rate          DECIMAL(18,6) NOT NULL,
    timestamp     TIMESTAMP     NOT NULL,
    source        VARCHAR(50)   NOT NULL,
    UNIQUE(from_currency, to_currency, timestamp)
);

CREATE INDEX idx_rate_history_from ON rate_history(from_currency);
CREATE INDEX idx_rate_history_to ON rate_history(to_currency);
CREATE INDEX idx_rate_history_timestamp ON rate_history(timestamp);

Provider interface

All providers implement a common protocol (interface):
infrastructure/providers/base.py
class ExchangeRateProvider(Protocol):
    @property
    def name(self) -> str:
        """Provider identifier (e.g., 'fixerio')"""
        ...
    
    async def fetch_rate(self, from_currency: str, to_currency: str) -> Decimal:
        """Fetch a single exchange rate"""
        ...
    
    async def fetch_supported_currencies(self) -> list[dict]:
        """Return list of supported currencies"""
        ...
    
    async def close(self) -> None:
        """Close HTTP client connections"""
        ...
Each provider owns its own httpx.AsyncClient and translates provider-specific errors into ProviderError.

Provider aggregation strategy

Parallel fetching

All providers are queried simultaneously using asyncio.gather():
┌─────────────┐
│   Request   │
└──────┬──────┘

       ├────────► FixerIO        → 0.9250
       ├────────► OpenExchange   → 0.9260
       └────────► CurrencyAPI    → FAIL


            avg = (0.9250 + 0.9260) / 2 = 0.9255

Failure tolerance

The system gracefully handles partial failures:
ScenarioOutcome
1-2 providers failAverage remaining successful responses
All 3 providers failRaise ProviderError → HTTP 503
Cache availableReturn cached value, skip providers entirely

Retry logic

Providers use tenacity for exponential backoff on transient errors:
@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, max=10),
    retry=retry_if_exception_type((ConnectionError, TimeoutError)),
)
async def fetch_rate(self, from_currency: str, to_currency: str) -> Decimal:
    # 3 attempts: 1s → 2s → 4s (max 10s)
    ...
Only connection/timeout errors are retried. API-level errors like invalid keys are NOT retried.

Dependency injection

The application uses FastAPI’s dependency injection system:
# Created once at startup, live for app lifetime
deps = AppDependencies()

def init_dependencies():
    deps.db = Database(settings.DATABASE_URL)
    deps.redis_cache = RedisCacheService(redis_client)
    deps.providers = {
        'fixerio': FixerIOProvider(settings.FIXERIO_API_KEY),
        'openexchange': OpenExchangeProvider(settings.OPENEXCHANGE_APP_ID),
        'currencyapi': CurrencyAPIProvider(settings.CURRENCYAPI_KEY),
    }
FastAPI resolves the full dependency graph automatically. Endpoints only declare their immediate dependency.

Error handling

Domain exceptions are mapped to HTTP status codes:
api/error_handlers.py
@app.exception_handler(InvalidCurrencyError)
async def invalid_currency_handler(request: Request, exc: InvalidCurrencyError):
    return JSONResponse(
        status_code=400,
        content={"detail": str(exc)}
    )

@app.exception_handler(ProviderError)
async def provider_error_handler(request: Request, exc: ProviderError):
    return JSONResponse(
        status_code=503,
        content={"detail": "Exchange rate service unavailable"}
    )
Domain ExceptionHTTP StatusClient Message
InvalidCurrencyError400 Bad Request”Currency XYZ not supported”
ProviderError503 Service Unavailable”Exchange rate service unavailable”
Exception (catch-all)500 Internal Server Error”Internal server error”
ProviderError details are hidden from clients to avoid exposing internal API information.

Startup sequence

The application initializes in a specific order:
If all providers fail during bootstrap, the app raises ProviderError and exits—preventing startup with no valid currency data.

Key design decisions

Why average multiple providers?

Aggregating rates from three sources provides:
  • Reliability: If one provider is down, others continue serving
  • Accuracy: Averaging reduces impact of outliers from any single provider
  • Redundancy: No single point of failure

Why intersection of supported currencies?

Only currencies supported by all three providers are allowed. This ensures:
  • Consistent results across all requests
  • No partial failures due to unsupported currency pairs
  • Simpler error handling logic

Why 5-minute cache TTL?

Exchange rates don’t change drastically within minutes:
  • Reduces external API calls by ~95% for popular pairs
  • Stays within free tier limits of provider APIs
  • Still fresh enough for most use cases
  • Can be adjusted via configuration if needed

Why PostgreSQL for history?

Storing all fetched rates enables:
  • Historical analysis and trending
  • Auditing of rate sources
  • Debugging provider discrepancies
  • Potential future features (rate charts, alerts)

Next steps

Now that you understand the architecture:

Build docs developers (and LLMs) love