Skip to main content

Clean Architecture Overview

The Currency Exchange API follows clean architecture principles to ensure maintainability, testability, and separation of concerns. The project is structured in four distinct layers, each with specific responsibilities and dependencies flowing inward toward the core domain.
┌─────────────────────────────────────────────┐
│          API Layer (Presentation)           │
│   Controllers, DTOs, Middleware, Startup    │
└──────────────────┬──────────────────────────┘
                   │ depends on

┌─────────────────────────────────────────────┐
│         Application Layer (Use Cases)       │
│       Business Logic, Services              │
└──────────────────┬──────────────────────────┘
                   │ depends on

┌─────────────────────────────────────────────┐
│       Core Layer (Domain/Entities)          │
│    Models, Interfaces, Business Rules       │
└──────────────────▲──────────────────────────┘
                   │ implements

┌──────────────────┴──────────────────────────┐
│      Data Layer (Infrastructure)            │
│  Repositories, EF Core, External Services   │
└─────────────────────────────────────────────┘
Dependencies flow inward: outer layers depend on inner layers, but inner layers never depend on outer layers. This creates a stable, testable architecture.

Layer Descriptions

API Layer (CurrencyExchange)

The outermost layer handles HTTP requests and responses. It contains no business logic, only coordination. Location: CurrencyExchange/ Key Components:
REST API endpoints that handle HTTP requests and return responses.CurrencyController (Controllers/CurrencyController.cs:10)
  • GET /api/Currency - Get all currencies
  • GET /api/Currency/{searchString} - Search currencies by code or name
  • POST /api/Currency - Add a new currency
Validates input using Currency.Validate() with regex patterns for codes (3-5 uppercase letters), names (3-60 characters), and symbols.ExchangeRateController (Controllers/ExchangeRateController.cs:10)
  • GET /api/ExchangeRate - Get all exchange rates
  • GET /api/ExchangeRate/{searchString} - Find rates by currency
  • POST /api/ExchangeRate - Create new exchange rate
  • PATCH /api/ExchangeRate/{baseCurrencyCode}&{targetCurrencyCode} - Update rate
Enforces rate validation: must be between 0 and 99,999,999 (ExchangeRate.cs:13-14).ExchangeController (Controllers/ExchangeController.cs:10)
  • GET /api/Exchange/from={baseCurrencyCode}&to={targetCurrencyCode}&amount={amount} - Convert currency
This controller implements intelligent rate discovery:
  1. Checks for direct rate (USD→RUB)
  2. Falls back to reverse rate (RUB→USD inverted)
  3. Calculates cross-rate through intermediate currency
  4. Throws exception if no rate can be determined
Contracts that define the shape of API requests and responses.CurrencyDTO (Contracts/CurrencyDTO.cs)
public record CurrencyDTO(int ID, string Code, string FullName, string Sign);
ExchangeRateDTO (Contracts/ExchangeRateDTO.cs)
public record ExchangeRateDTO(
  int Id, 
  Currency BaseCurrency, 
  Currency TargetCurrency, 
  float Rate
);
ExchangeDTO (Contracts/ExchangeDTO.cs)
public record ExchangeDTO(
  Currency BaseCurrency,
  Currency TargetCurrency,
  float Rate,
  float Amount,
  float RecalculateAmount
);
AddExchangeRateDTO (Contracts/AddExchangeRateDTO.cs)
public record AddExchangeRateDTO(
  string BaseCurrencyCode,
  string TargetCurrencyCode,
  float Rate
);
ExceptionHandlerMiddleware (Extensions/ExceptionHandlerMiddleware.cs)Global exception handling that catches all unhandled exceptions and returns appropriate HTTP status codes:
  • ArgumentException, InvalidOperationException → 400 Bad Request
  • ArgumentNullException, NullReferenceException → 404 Not Found
  • All others → 500 Internal Server Error
Registered in the pipeline via app.UseExceptionHandlerMiddleware() in Program.cs:48.
Configuration (Program.cs)Key registrations:
// Database context with PostgreSQL
builder.Services.AddDbContext<CurrencyExchangeDBContext>(options =>
    options.UseNpgsql(
      builder.Configuration.GetConnectionString("CurrencyExchangeDBContext")
    )
);

// Repository pattern
builder.Services.AddScoped<ICurrencyRepository<Currency>, CurrenciesRepository>();
builder.Services.AddScoped<IExchangeRateRepository<ExchangeRate>, ExchangeRatesRepository>();

// Business logic
builder.Services.AddScoped<ICurrencyExchangeService<ExchangeRate>, ExchangeService>();

// Background service for automatic rate updates
builder.Services.AddHostedService(provider =>
{
    var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
    var period = builder.Configuration.GetSection("CurrencyReceiptPeriod:Period").Get<int>();
    return new CBRExchangeRate(scopeFactory, period);
});

Application Layer (CurrencyExchange.Application)

Contains business logic and orchestrates operations between controllers and repositories. Location: CurrencyExchange.Application/ Key Component:

ExchangeService

Location: Application/ExchangeService.cs:6Interface: ICurrencyExchangeService<ExchangeRate> (from Core layer)Responsibilities:
  • Validates conversion amounts (must be ≥ 0)
  • Calculates converted amounts using exchange rates
  • Rounds results to 2 decimal places using MidpointRounding.ToZero
Key Methods:
public void Calculate(ExchangeRate exchangeRate, float amount)
{
    // Validates exchangeRate is not null
    // Validates amount >= 0
    // Calculates: amount * rate
    // Rounds to 2 decimal places
    RecalculateAmount = Convert(ExchangeRate.Rate, Amount);
}
This service is consumed by ExchangeController to perform the actual currency conversion after the rate is determined.

Core Layer (CurrencyExchange.Core)

The heart of the application containing domain models, business rules, and abstractions. Location: CurrencyExchange.Core/ Components:
Currency (Models/Currency.cs:5)Immutable domain model with built-in validation:
public class Currency
{
    public int Id { get; }
    public string Code { get; }        // 3-5 uppercase letters
    public string FullName { get; }    // 3-60 characters
    public string Sign { get; }        // 1-3 characters
    
    public static Currency Create(int id, string code, string fullName, string sign)
    {
        code = code.ToUpperInvariant();  // Normalize to uppercase
        Validate(code, 3, 5, @"[^A-Z]");
        Validate(fullName, 3, 60, @"[^A-Za-zА-Яа-яЁё() ]");
        Validate(sign, 1, 3, @"[ \n]");
        return new Currency(id, code, fullName, sign);
    }
}
ExchangeRate (Models/ExchangeRate.cs:3)Represents the exchange rate between two currencies:
public class ExchangeRate
{
    public const int MIN_RATE = 0;
    public const int MAX_RATE = 99999999;
    
    public int Id { get; }
    public Currency BaseCurrency { get; }
    public Currency TargetCurrency { get; }
    public float Rate { get; }
    
    public static ExchangeRate Create(
        int id, 
        Currency baseCurrency, 
        Currency targetCurrency, 
        float rate)
    {
        Validate(rate);  // Ensures 0 < rate <= 99999999
        return new ExchangeRate(id, baseCurrency, targetCurrency, rate);
    }
}
Both models use factory methods (Create) to ensure all instances are valid.

Data Layer (CurrencyExchange.Data)

Implements data access and external service integrations. Location: CurrencyExchange.Data/ Components:
CurrenciesRepository (Repositories/CurrenciesRepository.cs:8)Implements ICurrencyRepository<Currency> with Entity Framework Core:
  • GetAll(): Returns all currencies from database
  • Get(string code): Finds currency by code (case-insensitive)
  • Find(string findText): Searches currencies using EF.Functions.ILike for case-insensitive partial matching on code or full name
  • Insert(Currency currency): Adds new currency after checking for duplicates
  • CheckExist(Currency currency): Verifies if currency code already exists
All methods map between CurrencyEntity (database) and Currency (domain model).ExchangeRatesRepository (Repositories/ExchangeRatesRepository.cs:8)Implements IExchangeRateRepository<ExchangeRate> with advanced rate logic:
  • Get(Currency baseCurrency, Currency targetCurrency): Direct rate lookup
  • GetAndSaveRevers(): Calculates inverse rate (if USD→RUB = 82.25, then RUB→USD = 1/82.25 ≈ 0.012) and saves it
  • GetAndSaveCross(): Finds intermediate currency and calculates cross-rate:
    If USD→RUB and EUR→RUB exist, calculate USD→EUR:
    USD→EUR = USD→RUB / EUR→RUB
    
    This method iterates through all existing rates to find a valid path (lines 210-244).
The repository uses LINQ queries with joins to load currencies alongside rates, avoiding N+1 query problems.
CurrencyExchangeDBContext (CurrencyExchangeDBContext.cs)Entity Framework Core context with two DbSets:
  • DbSet<CurrencyEntity> Currencies
  • DbSet<ExchangeRateEntity> ExchangeRates
Configurations are applied via CurrencyConfiguration and ExchangeRateConfiguration classes using Fluent API.Database Schema:
-- Currencies table
CREATE TABLE currencies (
  id SERIAL PRIMARY KEY,
  code VARCHAR(400),
  fullname VARCHAR(400),
  sign VARCHAR(400)
);
CREATE UNIQUE INDEX ix_code ON currencies (code);

-- Exchange rates table with foreign keys
CREATE TABLE exchangerates (
  id SERIAL PRIMARY KEY,
  basecurrencyid INT REFERENCES currencies(id),
  targetcurrencyid INT REFERENCES currencies(id),
  rate REAL
);
CREATE UNIQUE INDEX ix_currencyids ON exchangerates (basecurrencyid, targetcurrencyid);
CBRExchangeRate (ExternalServices/Clients/CBRExchangeRate.cs:13)Background hosted service implementing IHostedService that:
  1. Fetches rates from Central Bank of Russia using SOAP client (DailyInfoSoapClient)
  2. Parses XML response to extract currency codes, names, and rates
  3. Updates database with new rates on a timer (configurable interval)
Key Methods:
private async Task GetFromCBR()
{
    // Calls CBR SOAP service: GetCursOnDateAsync(DateTime.Now)
    // Parses XML nodes: ValuteData -> ValuteCursOnDate
    // Extracts: VchCode, Vname, VunitRate
    // Stores in CBRExchangeRates list
}

private async void Update(object? state)
{
    await GetFromCBR();
    foreach (var rate in CBRExchangeRates)
    {
        if (await exchangeRateRepository.CheckExist(rate.Code, "RUB"))
        {
            await exchangeRateRepository.Update(rate.Code, "RUB", rate.Rate);
        }
    }
}
Configuration:
  • Timer interval set via CurrencyReceiptPeriod:Period in appsettings.json (default: 300 seconds)
  • Registered in Program.cs:29-33 as a hosted service
  • Uses IServiceScopeFactory to create scopes for database access
The service only updates existing currency pairs. New currencies from CBR must be manually added to the database first.
CurrencyEntity (Entities/CurrencyEntity.cs)
public class CurrencyEntity
{
    public int Id { get; set; }
    public string Code { get; set; }
    public string FullName { get; set; }
    public string Sign { get; set; }
}
ExchangeRateEntity (Entities/ExchangeRateEntity.cs)
public class ExchangeRateEntity
{
    public int Id { get; set; }
    public int BaseCurrencyId { get; set; }
    public CurrencyEntity BaseCurrency { get; set; }  // Navigation property
    public int TargetCurrencyId { get; set; }
    public CurrencyEntity TargetCurrency { get; set; }  // Navigation property
    public float Rate { get; set; }
}
These entities are mapped to domain models in repository implementations.

Data Flow

Let’s trace a typical currency conversion request through all layers:
1

HTTP Request Arrives

GET /api/Exchange/from=USD&to=EUR&amount=100
The request is routed to ExchangeController.GetConverted() method at line 16.
2

Controller Validates and Fetches Data

The controller:
  1. Fetches base currency (USD) via ICurrencyRepository.Get("USD")
  2. Fetches target currency (EUR) via ICurrencyRepository.Get("EUR")
  3. Attempts to find exchange rate using intelligent fallback:
    • Direct: IExchangeRateRepository.Get(USD, EUR)
    • Reverse: IExchangeRateRepository.GetAndSaveRevers(USD, EUR)
    • Cross: IExchangeRateRepository.GetAndSaveCross(USD, EUR)
See implementation at ExchangeController.cs:20-32.
3

Repository Queries Database

ExchangeRatesRepository executes optimized LINQ query with joins:
await (from rate in dbContext.ExchangeRates
       join baseCurrency in dbContext.Currencies on rate.BaseCurrencyId equals baseCurrency.Id
       join targetCurrency in dbContext.Currencies on rate.TargetCurrencyId equals targetCurrency.Id
       where baseCurrency.Id == baseCurrencyId && targetCurrency.Id == targetCurrencyId
       select ExchangeRate.Create(...))
    .FirstOrDefaultAsync();
If not found, calculates and saves the rate for future use.
4

Application Service Calculates Conversion

ExchangeService.Calculate() is called:
exchange.Calculate(exchangeRate, amount);
// Validates amount >= 0
// Calculates: 100 * 1.08 = 108.00 (rounded to 2 decimals)
Implementation at ExchangeService.cs:16-26.
5

Response is Serialized and Returned

Controller creates ExchangeDTO and returns OK response:
{
  "baseCurrency": { "id": 1, "code": "USD", ... },
  "targetCurrency": { "id": 3, "code": "EUR", ... },
  "rate": 1.08,
  "amount": 100,
  "recalculateAmount": 108.00
}

Design Patterns

Repository Pattern

All data access goes through repository interfaces (ICurrencyRepository, IExchangeRateRepository) defined in the Core layer and implemented in the Data layer. This abstraction:
  • Decouples business logic from data access technology
  • Enables easy unit testing with mock repositories
  • Allows switching databases without affecting upper layers

Factory Method Pattern

Domain models use static factory methods (Currency.Create(), ExchangeRate.Create()) instead of public constructors to:
  • Enforce validation rules at creation time
  • Prevent invalid objects from existing
  • Provide clear creation semantics

Dependency Injection

All dependencies are injected through constructors:
public class ExchangeController(
    ICurrencyExchangeService<ExchangeRate> exchange,
    ICurrencyRepository<Currency> currency,
    IExchangeRateRepository<ExchangeRate> rateRepository)
This enables:
  • Loose coupling between components
  • Easy unit testing with mocks
  • Centralized configuration in Program.cs

Hosted Service Pattern

CBRExchangeRate implements IHostedService for background rate updates:
  • Starts automatically with the application
  • Runs independently of HTTP requests
  • Uses dependency injection scopes for database access

Key Architectural Decisions

Immutable Domain Models

Currency and ExchangeRate have private constructors and readonly properties, preventing modification after creation. This ensures data consistency and thread safety.

Intelligent Rate Discovery

The ExchangeRatesRepository implements three fallback strategies (direct, reverse, cross) to maximize rate availability without manual data entry.

Generic Repositories

Using ICurrencyRepository&lt;T&gt; enables type-safe repository operations while allowing specialized implementations like IExchangeRateRepository&lt;T&gt;.

Automatic Code Normalization

Currency codes are automatically converted to uppercase in Currency.Create() (line 23), ensuring consistent querying and avoiding duplicates.

Testing Considerations

The architecture supports testing at multiple levels:

Unit Testing

  • Test domain models in isolation (validation logic)
  • Mock repository interfaces to test controllers
  • Test ExchangeService business logic without database

Integration Testing

  • Test repositories against real database using test container
  • Verify EF Core queries and mappings
  • Test CBRExchangeRate with mock SOAP client

End-to-End Testing

  • Test complete request flow through all layers
  • Verify error handling middleware
  • Test rate fallback strategies with various data scenarios

Performance Optimizations

Repository queries use explicit joins to load related currencies with rates in a single query, avoiding N+1 problems:
from rate in dbContext.ExchangeRates
join baseCurrency in dbContext.Currencies on rate.BaseCurrencyId equals baseCurrency.Id
join targetCurrency in dbContext.Currencies on rate.TargetCurrencyId equals targetCurrency.Id
Unique indexes on currencies.code and exchangerates.(basecurrencyid, targetcurrencyid) ensure fast lookups and prevent duplicates.
Reverse and cross rates are calculated once and saved to the database for reuse, avoiding repeated calculations.
EF Core automatically uses connection pooling for PostgreSQL, configured with CommandTimeout=1200 in connection string.

Summary

The Currency Exchange API demonstrates clean architecture principles with:
  • Clear separation of concerns across four distinct layers
  • Dependency inversion with interfaces in Core layer
  • Domain-driven design with rich models and validation
  • Repository pattern for data access abstraction
  • Intelligent rate discovery with automatic fallbacks
  • Background services for automatic external updates
  • Comprehensive error handling via middleware
This architecture ensures the codebase is maintainable, testable, and scalable for future enhancements.

Build docs developers (and LLMs) love