Clean Architecture

25 min read
Rapid overview

Clean Architecture -- Comprehensive Study Guide

"The center of your application is not the database. It is not one or more of the frameworks you may be using. The center of your application is the use cases of your application." -- Robert C. Martin

This guide covers Clean Architecture in depth for interview preparation, with practical C# and ASP.NET Core examples throughout.


The Dependency Rule and Layer Boundaries

The single most important rule of Clean Architecture is the Dependency Rule:

Source code dependencies must point only inward, toward higher-level policies.

Nothing in an inner circle can know anything about something in an outer circle. This includes concrete classes, functions, variable names, or any software entity declared in an outer circle.

The Concentric Circles

+-----------------------------------------------------------+
|                    Frameworks & Drivers                    |
|   +---------------------------------------------------+   |
|   |                Interface Adapters                  |   |
|   |   +-------------------------------------------+   |   |
|   |   |              Application                   |   |   |
|   |   |   +-----------------------------------+   |   |   |
|   |   |   |            Entities               |   |   |   |
|   |   |   |          (Domain Core)            |   |   |   |
|   |   |   +-----------------------------------+   |   |   |
|   |   +-------------------------------------------+   |   |
|   +---------------------------------------------------+   |
+-----------------------------------------------------------+
graph TD A[Presentation Layer] --> B[Application Layer] B --> C[Domain Layer] A --> D[Infrastructure Layer] D --> B style C fill:#4CAF50,color:#fff style B fill:#2196F3,color:#fff style A fill:#FF9800,color:#fff style D fill:#9C27B0,color:#fff

How the Rule Works in Practice

  • Domain has zero outward dependencies. It defines interfaces that outer layers implement.
  • Application depends on Domain. It defines use-case interfaces (ports) that Infrastructure implements.
  • Infrastructure depends on Application and Domain. It implements the interfaces.
  • Presentation depends on Application (and transitively Domain). It dispatches commands/queries.

Crossing a boundary always requires Dependency Inversion: the inner layer declares an abstraction, the outer layer provides the implementation, and a DI container wires them at startup.

// Domain layer -- declares the contract
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task SaveChangesAsync(CancellationToken ct = default);
}

// Infrastructure layer -- provides the implementation
public sealed class EfOrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;
    public EfOrderRepository(AppDbContext db) => _db = db;

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct)
        => await _db.Orders.FindAsync(new object[] { id }, ct);

    public async Task AddAsync(Order order, CancellationToken ct)
        => await _db.Orders.AddAsync(order, ct);

    public async Task SaveChangesAsync(CancellationToken ct)
        => await _db.SaveChangesAsync(ct);
}

The Application layer uses IOrderRepository without ever knowing about EF Core.


Entities (Domain Layer)

Entities encapsulate enterprise-wide business rules. They are the least likely to change when something external changes. They have no dependency on frameworks, databases, or UI concerns.

Characteristics of a Well-Designed Entity

  • Protects its invariants via private setters and factory methods.
  • Contains domain logic, not just data (rich domain model).
  • Raises domain events when meaningful state transitions occur.
  • Has no [JsonProperty], [Column], or framework-specific attributes.
public sealed class Trade
{
    public Guid Id { get; private set; }
    public string Symbol { get; private set; } = default!;
    public decimal Quantity { get; private set; }
    public decimal EntryPrice { get; private set; }
    public TradeStatus Status { get; private set; }
    public DateTime OpenedAtUtc { get; private set; }
    public DateTime? ClosedAtUtc { get; private set; }

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    private Trade() { } // EF Core / serialization

    public static Trade Open(string symbol, decimal quantity, decimal entryPrice)
    {
        if (string.IsNullOrWhiteSpace(symbol))
            throw new DomainException("Symbol is required.");
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive.");
        if (entryPrice <= 0)
            throw new DomainException("Entry price must be positive.");

        var trade = new Trade
        {
            Id = Guid.NewGuid(),
            Symbol = symbol.ToUpperInvariant(),
            Quantity = quantity,
            EntryPrice = entryPrice,
            Status = TradeStatus.Open,
            OpenedAtUtc = DateTime.UtcNow
        };

        trade._domainEvents.Add(new TradeOpenedEvent(trade.Id, trade.Symbol));
        return trade;
    }

    public void Close(decimal exitPrice)
    {
        if (Status != TradeStatus.Open)
            throw new DomainException("Only open trades can be closed.");

        Status = TradeStatus.Closed;
        ClosedAtUtc = DateTime.UtcNow;
        _domainEvents.Add(new TradeClosedEvent(Id, exitPrice));
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}

public enum TradeStatus { Open, Closed, Cancelled }

Value Objects

Value objects are immutable, compared by structural equality, and have no identity.

public sealed record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException("Cannot add different currencies.");
        return new Money(Amount + other.Amount, Currency);
    }
}

Domain Events

public interface IDomainEvent
{
    DateTime OccurredOnUtc { get; }
}

public sealed record TradeOpenedEvent(Guid TradeId, string Symbol) : IDomainEvent
{
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

public sealed record TradeClosedEvent(Guid TradeId, decimal ExitPrice) : IDomainEvent
{
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

Use Cases (Application Layer)

Use cases orchestrate the flow of data to and from entities, and direct those entities to use their enterprise-wide business rules to achieve the goals of the use case. This layer contains application-specific business rules.

What Lives Here

  • Command and query handlers (CQRS)
  • Application services
  • Interfaces (ports) for infrastructure
  • DTOs for input/output across boundaries
  • Validators (e.g., FluentValidation)
  • Mapping profiles (AutoMapper, Mapster)
// Command
public sealed record OpenTradeCommand(
    string Symbol,
    decimal Quantity,
    decimal EntryPrice) : IRequest<Guid>;

// Handler
public sealed class OpenTradeHandler : IRequestHandler<OpenTradeCommand, Guid>
{
    private readonly ITradeRepository _trades;
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<OpenTradeHandler> _logger;

    public OpenTradeHandler(
        ITradeRepository trades,
        IUnitOfWork unitOfWork,
        ILogger<OpenTradeHandler> logger)
    {
        _trades = trades;
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    public async Task<Guid> Handle(OpenTradeCommand request, CancellationToken ct)
    {
        var trade = Trade.Open(request.Symbol, request.Quantity, request.EntryPrice);

        await _trades.AddAsync(trade, ct);
        await _unitOfWork.CommitAsync(ct);

        _logger.LogInformation("Trade {TradeId} opened for {Symbol}", trade.Id, trade.Symbol);

        return trade.Id;
    }
}

Interface Adapters

This layer converts data between the format most convenient for use cases/entities and the format most convenient for external agencies (database, web, etc.). Controllers, presenters, gateways, and repository implementations all live here conceptually.

In .NET solutions, this maps to both the Infrastructure project and the Presentation (API) project.


Frameworks & Drivers

The outermost layer. This is where all the details go: the web framework, the database driver, the message broker client, etc. You keep this layer thin -- it is glue code that connects to the next circle inward.


Project Structure in .NET

A canonical Clean Architecture solution in .NET looks like this:

MySolution/
  src/
    MyApp.Domain/               <-- Innermost: entities, value objects, domain events, interfaces
      Common/
        IDomainEvent.cs
        DomainException.cs
      Entities/
        Trade.cs
        Portfolio.cs
      ValueObjects/
        Money.cs
      Enums/
        TradeStatus.cs

    MyApp.Application/          <-- Use cases, CQRS handlers, validators, DTOs
      Common/
        Interfaces/
          IUnitOfWork.cs
          IDateTimeProvider.cs
        Behaviors/
          ValidationBehavior.cs
          LoggingBehavior.cs
        Mappings/
          MappingProfile.cs
      Trades/
        Commands/
          OpenTrade/
            OpenTradeCommand.cs
            OpenTradeHandler.cs
            OpenTradeValidator.cs
        Queries/
          GetTradeById/
            GetTradeByIdQuery.cs
            GetTradeByIdHandler.cs
            TradeDto.cs

    MyApp.Infrastructure/       <-- EF Core, external services, file system, messaging
      Persistence/
        AppDbContext.cs
        Configurations/
          TradeConfiguration.cs
        Repositories/
          TradeRepository.cs
        UnitOfWork.cs
      Services/
        DateTimeProvider.cs
        EmailService.cs
      DependencyInjection.cs    <-- Extension method to register infra services

    MyApp.Api/                  <-- ASP.NET Core, controllers, middleware, DI composition root
      Controllers/
        TradesController.cs
      Middleware/
        ExceptionHandlingMiddleware.cs
      Program.cs

  tests/
    MyApp.Domain.Tests/
    MyApp.Application.Tests/
    MyApp.Infrastructure.Tests/
    MyApp.Api.Tests/

Project References (Enforcing the Dependency Rule)

MyApp.Api           --> MyApp.Application, MyApp.Infrastructure
MyApp.Infrastructure --> MyApp.Application
MyApp.Application    --> MyApp.Domain
MyApp.Domain         --> (nothing)

The API project references Infrastructure only to register services at the composition root. At runtime, Application code never calls Infrastructure types directly -- it always goes through abstractions.

Enforcing with Architecture Tests

using NetArchTest.Rules;

[Fact]
public void Domain_Should_Not_Depend_On_Application()
{
    var result = Types
        .InAssembly(typeof(Trade).Assembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Application")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
    var result = Types
        .InAssembly(typeof(Trade).Assembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

[Fact]
public void Application_Should_Not_Depend_On_Infrastructure()
{
    var result = Types
        .InAssembly(typeof(OpenTradeCommand).Assembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Dependency Injection in Clean Architecture

DI is the mechanism that makes the Dependency Rule work at runtime. Without it, inner layers would have to new up outer-layer implementations, violating the rule.

Composition Root

The composition root is the single place in your application where all dependencies are wired. In ASP.NET Core, this is Program.cs (or Startup.cs).

// Program.cs -- Composition Root
var builder = WebApplication.CreateBuilder(args);

// Application layer registrations
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(OpenTradeCommand).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(OpenTradeCommand).Assembly);
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

// Infrastructure layer registrations (via extension method)
builder.Services.AddInfrastructure(builder.Configuration);

builder.Services.AddControllers();

var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapControllers();
app.Run();

Infrastructure Registration Extension

// MyApp.Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("Default")));

        services.AddScoped<ITradeRepository, TradeRepository>();
        services.AddScoped<IPortfolioRepository, PortfolioRepository>();
        services.AddScoped<IUnitOfWork, UnitOfWork>();
        services.AddSingleton<IDateTimeProvider, DateTimeProvider>();

        return services;
    }
}

Lifetime Guidance

Service TypeLifetimeExample
DbContextScopedAppDbContext
RepositoriesScopedTradeRepository
Unit of WorkScopedUnitOfWork
Stateless servicesTransientValidationBehavior<,>
Configuration/clocksSingletonDateTimeProvider

CQRS with Clean Architecture

Command Query Responsibility Segregation separates read and write operations into distinct models. This maps naturally to Clean Architecture because commands and queries are use cases.

Why CQRS Fits

  • Commands change state (write side) and go through the full domain model with validation and invariants.
  • Queries read state (read side) and can bypass the domain model entirely for performance.

Command Example

// Command + Handler (write side -- goes through the domain)
public sealed record CloseTradeCommand(Guid TradeId, decimal ExitPrice) : IRequest<Unit>;

public sealed class CloseTradeHandler : IRequestHandler<CloseTradeCommand, Unit>
{
    private readonly ITradeRepository _trades;
    private readonly IUnitOfWork _unitOfWork;

    public CloseTradeHandler(ITradeRepository trades, IUnitOfWork unitOfWork)
    {
        _trades = trades;
        _unitOfWork = unitOfWork;
    }

    public async Task<Unit> Handle(CloseTradeCommand request, CancellationToken ct)
    {
        var trade = await _trades.GetByIdAsync(request.TradeId, ct)
            ?? throw new NotFoundException(nameof(Trade), request.TradeId);

        trade.Close(request.ExitPrice); // domain logic enforces invariants

        await _unitOfWork.CommitAsync(ct);
        return Unit.Value;
    }
}

Query Example

// Query + Handler (read side -- can use lightweight read models)
public sealed record GetTradeByIdQuery(Guid TradeId) : IRequest<TradeDto?>;

public sealed class GetTradeByIdHandler : IRequestHandler<GetTradeByIdQuery, TradeDto?>
{
    private readonly ITradeReadRepository _readRepo;

    public GetTradeByIdHandler(ITradeReadRepository readRepo)
        => _readRepo = readRepo;

    public async Task<TradeDto?> Handle(GetTradeByIdQuery request, CancellationToken ct)
        => await _readRepo.GetTradeDtoByIdAsync(request.TradeId, ct);
}

Pipeline Behaviors (Cross-Cutting Concerns)

MediatR pipeline behaviors wrap every command/query, providing a clean way to handle validation, logging, and transactions.

public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);
            var failures = (await Task.WhenAll(
                    _validators.Select(v => v.ValidateAsync(context, ct))))
                .SelectMany(r => r.Errors)
                .Where(f => f is not null)
                .ToList();

            if (failures.Count > 0)
                throw new ValidationException(failures);
        }

        return await next();
    }
}

Validators with FluentValidation

public sealed class OpenTradeValidator : AbstractValidator<OpenTradeCommand>
{
    public OpenTradeValidator()
    {
        RuleFor(x => x.Symbol)
            .NotEmpty().WithMessage("Symbol is required.")
            .MaximumLength(10).WithMessage("Symbol must be 10 characters or fewer.");

        RuleFor(x => x.Quantity)
            .GreaterThan(0).WithMessage("Quantity must be positive.");

        RuleFor(x => x.EntryPrice)
            .GreaterThan(0).WithMessage("Entry price must be positive.");
    }
}

Repository Pattern and Unit of Work

Repository Pattern

Repositories provide a collection-like interface for accessing domain entities, hiding persistence details from the Application layer.

// Domain or Application layer -- the abstraction
public interface ITradeRepository
{
    Task<Trade?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<Trade>> GetOpenTradesAsync(CancellationToken ct = default);
    Task AddAsync(Trade trade, CancellationToken ct = default);
    void Remove(Trade trade);
}

// Infrastructure layer -- the implementation
public sealed class TradeRepository : ITradeRepository
{
    private readonly AppDbContext _db;

    public TradeRepository(AppDbContext db) => _db = db;

    public async Task<Trade?> GetByIdAsync(Guid id, CancellationToken ct)
        => await _db.Trades.FirstOrDefaultAsync(t => t.Id == id, ct);

    public async Task<IReadOnlyList<Trade>> GetOpenTradesAsync(CancellationToken ct)
        => await _db.Trades
            .Where(t => t.Status == TradeStatus.Open)
            .ToListAsync(ct);

    public async Task AddAsync(Trade trade, CancellationToken ct)
        => await _db.Trades.AddAsync(trade, ct);

    public void Remove(Trade trade)
        => _db.Trades.Remove(trade);
}

Unit of Work

The Unit of Work coordinates writes across multiple repositories in a single transaction.

// Application layer -- abstraction
public interface IUnitOfWork
{
    Task<int> CommitAsync(CancellationToken ct = default);
}

// Infrastructure layer -- implementation wrapping DbContext.SaveChanges
public sealed class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _db;
    private readonly IMediator _mediator;

    public UnitOfWork(AppDbContext db, IMediator mediator)
    {
        _db = db;
        _mediator = mediator;
    }

    public async Task<int> CommitAsync(CancellationToken ct)
    {
        // Dispatch domain events before saving
        await DispatchDomainEventsAsync(ct);

        return await _db.SaveChangesAsync(ct);
    }

    private async Task DispatchDomainEventsAsync(CancellationToken ct)
    {
        var entities = _db.ChangeTracker
            .Entries<Trade>()
            .Where(e => e.Entity.DomainEvents.Any())
            .Select(e => e.Entity)
            .ToList();

        var events = entities.SelectMany(e => e.DomainEvents).ToList();

        foreach (var entity in entities)
            entity.ClearDomainEvents();

        foreach (var domainEvent in events)
            await _mediator.Publish(domainEvent, ct);
    }
}

When to Skip Repository (Direct DbContext Access for Reads)

For read-heavy query handlers where you need projections and do not care about domain invariants, injecting AppDbContext or a thin read-only interface is acceptable. This avoids bloating repository interfaces with query-specific methods.

// Lightweight read repository for the query side
public interface ITradeReadRepository
{
    Task<TradeDto?> GetTradeDtoByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<TradeListItemDto>> GetOpenTradeListAsync(CancellationToken ct = default);
}

public sealed class TradeReadRepository : ITradeReadRepository
{
    private readonly AppDbContext _db;

    public TradeReadRepository(AppDbContext db) => _db = db;

    public async Task<TradeDto?> GetTradeDtoByIdAsync(Guid id, CancellationToken ct)
        => await _db.Trades
            .Where(t => t.Id == id)
            .Select(t => new TradeDto(t.Id, t.Symbol, t.Quantity, t.EntryPrice, t.Status.ToString()))
            .FirstOrDefaultAsync(ct);

    public async Task<IReadOnlyList<TradeListItemDto>> GetOpenTradeListAsync(CancellationToken ct)
        => await _db.Trades
            .Where(t => t.Status == TradeStatus.Open)
            .Select(t => new TradeListItemDto(t.Id, t.Symbol, t.Quantity))
            .ToListAsync(ct);
}

Mapping Between Layers (DTOs, ViewModels, Domain Models)

Each boundary crossing should have its own data-transfer model. Do not let domain entities leak into API responses or database schemas leak into use cases.

The Models

LayerModel TypePurpose
DomainEntity / Value ObjectEnforces invariants, contains behavior
ApplicationDTO (Data Transfer Object)Carries data across use case boundary
PresentationViewModel / ApiResponseShapes data for the consumer (API, UI)
InfrastructureEF Entity / DB modelMaps to database schema

When Domain Entity = EF Entity

In many .NET projects, the domain entity is also mapped directly by EF Core using IEntityTypeConfiguration<T>. This is acceptable as long as:

  • EF configuration is in the Infrastructure layer (not attributes on the entity).
  • The entity has no EF-specific attributes or navigation property compromises.
  • Private setters and constructors are used (EF Core supports this).

Mapping with Manual Projection

// Application layer DTO
public sealed record TradeDto(
    Guid Id,
    string Symbol,
    decimal Quantity,
    decimal EntryPrice,
    string Status);

// Presentation layer ViewModel (API response)
public sealed record TradeResponse(
    Guid Id,
    string Symbol,
    decimal Quantity,
    decimal EntryPrice,
    string Status,
    string DisplayName);

// In the controller -- map DTO to response
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
    var dto = await _mediator.Send(new GetTradeByIdQuery(id), ct);

    if (dto is null)
        return NotFound();

    var response = new TradeResponse(
        dto.Id,
        dto.Symbol,
        dto.Quantity,
        dto.EntryPrice,
        dto.Status,
        $"{dto.Symbol} x {dto.Quantity}");

    return Ok(response);
}

Mapping with AutoMapper / Mapster

// AutoMapper profile (Application layer)
public sealed class TradeMappingProfile : Profile
{
    public TradeMappingProfile()
    {
        CreateMap<Trade, TradeDto>()
            .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString()));
    }
}

// Usage in handler
public sealed class GetTradeByIdHandler : IRequestHandler<GetTradeByIdQuery, TradeDto?>
{
    private readonly ITradeRepository _trades;
    private readonly IMapper _mapper;

    public GetTradeByIdHandler(ITradeRepository trades, IMapper mapper)
    {
        _trades = trades;
        _mapper = mapper;
    }

    public async Task<TradeDto?> Handle(GetTradeByIdQuery request, CancellationToken ct)
    {
        var trade = await _trades.GetByIdAsync(request.TradeId, ct);
        return trade is null ? null : _mapper.Map<TradeDto>(trade);
    }
}

Anti-Pattern: Returning Domain Entities from the API

// BAD -- domain entity exposed to the outside world
[HttpGet("{id:guid}")]
public async Task<Trade> GetById(Guid id)
{
    return await _trades.GetByIdAsync(id);
    // Exposes internal state, domain events, private methods via serialization.
    // Any change to the entity breaks the API contract.
}

Testing Strategies Per Layer

Domain Layer Tests

Domain tests are pure unit tests. No mocking frameworks needed -- just instantiate entities and assert invariants.

public class TradeTests
{
    [Fact]
    public void Open_WithValidInputs_CreatesTradeWithOpenStatus()
    {
        var trade = Trade.Open("EURUSD", 1.5m, 1.1050m);

        Assert.Equal("EURUSD", trade.Symbol);
        Assert.Equal(1.5m, trade.Quantity);
        Assert.Equal(TradeStatus.Open, trade.Status);
    }

    [Fact]
    public void Open_WithEmptySymbol_ThrowsDomainException()
    {
        Assert.Throws<DomainException>(() => Trade.Open("", 1.0m, 1.0m));
    }

    [Fact]
    public void Close_WhenAlreadyClosed_ThrowsDomainException()
    {
        var trade = Trade.Open("GBPUSD", 1.0m, 1.2500m);
        trade.Close(1.2600m);

        Assert.Throws<DomainException>(() => trade.Close(1.2700m));
    }

    [Fact]
    public void Open_RaisesTradeOpenedEvent()
    {
        var trade = Trade.Open("USDJPY", 2.0m, 110.50m);

        Assert.Single(trade.DomainEvents);
        Assert.IsType<TradeOpenedEvent>(trade.DomainEvents.First());
    }
}

Application Layer Tests

Mock infrastructure interfaces. Test that handlers call the right repository methods and coordinate correctly.

public class OpenTradeHandlerTests
{
    private readonly Mock<ITradeRepository> _tradeRepoMock = new();
    private readonly Mock<IUnitOfWork> _unitOfWorkMock = new();
    private readonly Mock<ILogger<OpenTradeHandler>> _loggerMock = new();

    [Fact]
    public async Task Handle_ValidCommand_AddsTradeAndCommits()
    {
        // Arrange
        _unitOfWorkMock.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var handler = new OpenTradeHandler(
            _tradeRepoMock.Object,
            _unitOfWorkMock.Object,
            _loggerMock.Object);

        var command = new OpenTradeCommand("EURUSD", 1.0m, 1.1050m);

        // Act
        var tradeId = await handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotEqual(Guid.Empty, tradeId);
        _tradeRepoMock.Verify(r => r.AddAsync(
            It.Is<Trade>(t => t.Symbol == "EURUSD"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_InvalidSymbol_ThrowsDomainException()
    {
        var handler = new OpenTradeHandler(
            _tradeRepoMock.Object,
            _unitOfWorkMock.Object,
            _loggerMock.Object);

        var command = new OpenTradeCommand("", 1.0m, 1.1050m);

        await Assert.ThrowsAsync<DomainException>(
            () => handler.Handle(command, CancellationToken.None));
    }
}

Infrastructure Layer Tests

Use integration tests with a real (or in-memory/container) database.

public class TradeRepositoryTests : IAsyncLifetime
{
    private AppDbContext _db = default!;
    private TradeRepository _sut = default!;

    public async Task InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        _db = new AppDbContext(options);
        _sut = new TradeRepository(_db);
        await Task.CompletedTask;
    }

    public async Task DisposeAsync() => await _db.DisposeAsync();

    [Fact]
    public async Task AddAsync_And_GetByIdAsync_RoundTrips()
    {
        var trade = Trade.Open("EURUSD", 1.0m, 1.1050m);

        await _sut.AddAsync(trade);
        await _db.SaveChangesAsync();

        var retrieved = await _sut.GetByIdAsync(trade.Id);

        Assert.NotNull(retrieved);
        Assert.Equal("EURUSD", retrieved!.Symbol);
    }
}

Presentation / API Layer Tests

Use WebApplicationFactory<T> for end-to-end integration tests.

public class TradesControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public TradesControllerTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real DB with in-memory for testing
                services.RemoveAll<DbContextOptions<AppDbContext>>();
                services.AddDbContext<AppDbContext>(opts =>
                    opts.UseInMemoryDatabase("TestDb"));
            });
        }).CreateClient();
    }

    [Fact]
    public async Task Post_ValidTrade_ReturnsOkWithId()
    {
        var payload = new { Symbol = "EURUSD", Quantity = 1.0, EntryPrice = 1.1050 };
        var content = new StringContent(
            JsonSerializer.Serialize(payload),
            Encoding.UTF8,
            "application/json");

        var response = await _client.PostAsync("/api/trades", content);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var body = await response.Content.ReadAsStringAsync();
        Assert.Contains("tradeId", body);
    }
}

Testing Strategy Summary

LayerTest TypeDependenciesSpeed
DomainPure unit testsNoneFastest
ApplicationUnit tests with mocksMocked interfacesFast
InfrastructureIntegration testsReal/in-memory DBMedium
PresentationIntegration / E2EWebApplicationFactorySlowest

Real-World Example with ASP.NET Core

Below is a complete vertical slice showing how a "Close Trade" feature flows through every layer.

Domain

// Trade.cs (entity method shown earlier)
public void Close(decimal exitPrice)
{
    if (Status != TradeStatus.Open)
        throw new DomainException("Only open trades can be closed.");

    Status = TradeStatus.Closed;
    ClosedAtUtc = DateTime.UtcNow;
    _domainEvents.Add(new TradeClosedEvent(Id, exitPrice));
}

Application -- Command, Validator, Handler

// CloseTradeCommand.cs
public sealed record CloseTradeCommand(Guid TradeId, decimal ExitPrice) : IRequest<Unit>;

// CloseTradeValidator.cs
public sealed class CloseTradeValidator : AbstractValidator<CloseTradeCommand>
{
    public CloseTradeValidator()
    {
        RuleFor(x => x.TradeId).NotEmpty();
        RuleFor(x => x.ExitPrice).GreaterThan(0);
    }
}

// CloseTradeHandler.cs
public sealed class CloseTradeHandler : IRequestHandler<CloseTradeCommand, Unit>
{
    private readonly ITradeRepository _trades;
    private readonly IUnitOfWork _unitOfWork;

    public CloseTradeHandler(ITradeRepository trades, IUnitOfWork unitOfWork)
    {
        _trades = trades;
        _unitOfWork = unitOfWork;
    }

    public async Task<Unit> Handle(CloseTradeCommand request, CancellationToken ct)
    {
        var trade = await _trades.GetByIdAsync(request.TradeId, ct)
            ?? throw new NotFoundException(nameof(Trade), request.TradeId);

        trade.Close(request.ExitPrice);

        await _unitOfWork.CommitAsync(ct);
        return Unit.Value;
    }
}

Application -- Domain Event Handler

// TradeClosedEventHandler.cs
public sealed class TradeClosedEventHandler : INotificationHandler<TradeClosedEvent>
{
    private readonly INotificationService _notifications;
    private readonly ILogger<TradeClosedEventHandler> _logger;

    public TradeClosedEventHandler(
        INotificationService notifications,
        ILogger<TradeClosedEventHandler> logger)
    {
        _notifications = notifications;
        _logger = logger;
    }

    public async Task Handle(TradeClosedEvent notification, CancellationToken ct)
    {
        _logger.LogInformation("Trade {TradeId} closed at {ExitPrice}",
            notification.TradeId, notification.ExitPrice);

        await _notifications.SendAsync(
            $"Trade {notification.TradeId} closed at {notification.ExitPrice}", ct);
    }
}

Infrastructure -- EF Configuration

// TradeConfiguration.cs
public sealed class TradeConfiguration : IEntityTypeConfiguration<Trade>
{
    public void Configure(EntityTypeBuilder<Trade> builder)
    {
        builder.ToTable("Trades");

        builder.HasKey(t => t.Id);

        builder.Property(t => t.Symbol)
            .HasMaxLength(10)
            .IsRequired();

        builder.Property(t => t.Quantity)
            .HasPrecision(18, 8);

        builder.Property(t => t.EntryPrice)
            .HasPrecision(18, 8);

        builder.Property(t => t.Status)
            .HasConversion<string>()
            .HasMaxLength(20);

        // Ignore domain events -- they are not persisted
        builder.Ignore(t => t.DomainEvents);
    }
}

Presentation -- Controller

[ApiController]
[Route("api/trades")]
public sealed class TradesController : ControllerBase
{
    private readonly IMediator _mediator;

    public TradesController(IMediator mediator) => _mediator = mediator;

    [HttpPost]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> OpenTrade(
        [FromBody] OpenTradeCommand command,
        CancellationToken ct)
    {
        var tradeId = await _mediator.Send(command, ct);
        return Ok(new { tradeId });
    }

    [HttpPut("{id:guid}/close")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> CloseTrade(
        Guid id,
        [FromBody] CloseTradeRequest request,
        CancellationToken ct)
    {
        await _mediator.Send(new CloseTradeCommand(id, request.ExitPrice), ct);
        return NoContent();
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(TradeDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var trade = await _mediator.Send(new GetTradeByIdQuery(id), ct);
        return trade is null ? NotFound() : Ok(trade);
    }
}

public sealed record CloseTradeRequest(decimal ExitPrice);

Presentation -- Global Exception Handling Middleware

public sealed class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new
            {
                errors = ex.Errors.Select(e => e.ErrorMessage)
            });
        }
        catch (NotFoundException ex)
        {
            context.Response.StatusCode = StatusCodes.Status404NotFound;
            await context.Response.WriteAsJsonAsync(new { error = ex.Message });
        }
        catch (DomainException ex)
        {
            context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
            await context.Response.WriteAsJsonAsync(new { error = ex.Message });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred." });
        }
    }
}

Common Mistakes and Anti-Patterns

1. Anemic Domain Model

Mistake: Entities are just property bags with no behavior. All logic lives in services.

// BAD -- anemic entity
public class Trade
{
    public Guid Id { get; set; }
    public string Symbol { get; set; }
    public TradeStatus Status { get; set; } // public setter!
}

// BAD -- service does what the entity should do
public class TradeService
{
    public void CloseTrade(Trade trade, decimal exitPrice)
    {
        if (trade.Status != TradeStatus.Open)
            throw new Exception("Cannot close");
        trade.Status = TradeStatus.Closed;
    }
}

Fix: Move behavior into the entity. The entity enforces its own invariants.

2. Leaking Infrastructure into the Domain

Mistake: Domain entities have EF Core attributes, JSON attributes, or references to HttpContext.

// BAD -- infrastructure concern in the domain
[Table("trades")]
public class Trade
{
    [Key]
    public Guid Id { get; set; }

    [Column("sym")]
    [JsonPropertyName("symbol")]
    public string Symbol { get; set; }
}

Fix: Use IEntityTypeConfiguration<T> in Infrastructure for EF mapping. Use DTOs for serialization.

3. Application Layer Depends on Infrastructure Types

Mistake: Handlers import AppDbContext, SqlConnection, or vendor-specific types.

// BAD -- handler depends on EF directly
public class OpenTradeHandler
{
    private readonly AppDbContext _db; // Infrastructure type!

    public async Task Handle(OpenTradeCommand cmd, CancellationToken ct)
    {
        _db.Trades.Add(Trade.Open(cmd.Symbol, cmd.Quantity, cmd.EntryPrice));
        await _db.SaveChangesAsync(ct);
    }
}

Fix: Depend on ITradeRepository and IUnitOfWork defined in the Application layer.

4. God Controller

Mistake: The controller contains business logic, validation, and data access.

// BAD -- controller does everything
[HttpPost]
public async Task<IActionResult> Post([FromBody] TradeRequest request)
{
    if (string.IsNullOrEmpty(request.Symbol))
        return BadRequest("Symbol required");

    var trade = new Trade { Symbol = request.Symbol, Quantity = request.Quantity };
    _db.Trades.Add(trade);
    await _db.SaveChangesAsync();

    await _emailService.SendAsync($"Trade {trade.Id} created");
    return Ok(trade);
}

Fix: Controllers should only deserialize input, dispatch to MediatR, and return the response.

5. Shared Models Across Boundaries

Mistake: Using the same class as the API request, the command, the domain entity, and the database row.

Fix: Maintain separate models per boundary. The cost of a few extra record/class declarations is far less than the cost of coupling everything together.

6. Over-Engineering from Day One

Mistake: Implementing full CQRS with separate read/write databases, event sourcing, and message queues before you have a second user.

Fix: Start with a single database, MediatR for in-process CQRS, and grow into separate read stores only when metrics demand it.

7. Circular Dependencies Between Layers

Mistake: Infrastructure references Presentation, or Domain references Application.

Fix: Strict project reference rules enforced by architecture tests (NetArchTest).

8. Not Using CancellationToken

Mistake: Ignoring CancellationToken in async chains, making the app unresponsive to client disconnects.

Fix: Pass CancellationToken through every async method from the controller down to the repository.


Interview Questions & Answers

Q: What is the Dependency Rule in Clean Architecture?

A: The Dependency Rule states that source code dependencies must point only inward. Outer layers (frameworks, UI, database) can depend on inner layers (use cases, entities), but never the reverse. This ensures that business rules are isolated from external concerns and can be tested, deployed, and evolved independently.


Q: How does Clean Architecture differ from traditional N-tier (3-layer) architecture?

A: In N-tier, the data layer is typically at the bottom and everything depends on it. In Clean Architecture, the domain is at the center and infrastructure depends on the domain (via interfaces). The dependency direction is inverted. This means you can swap databases or frameworks without touching business logic.


Q: Where should interfaces for repositories be defined?

A: In the Application layer (or Domain layer if they represent a core domain concept). The implementations live in Infrastructure. This ensures Application code depends only on abstractions, and Infrastructure details can be swapped out without modifying use cases.


Q: Explain how Dependency Injection supports Clean Architecture.

A: DI is the runtime mechanism that connects abstractions (defined in inner layers) with their concrete implementations (defined in outer layers). At the composition root (typically Program.cs), you register ITradeRepository to be resolved as TradeRepository. This allows inner layers to code against interfaces while the container provides the right implementation at runtime.


Q: What is the role of the Application layer?

A: The Application layer contains application-specific business rules. It orchestrates the flow of data between the Presentation and Domain layers. It holds use case handlers (CQRS commands/queries), validators, DTOs, and interface definitions for infrastructure services. It depends on Domain but never on Infrastructure or Presentation.


Q: Why separate Commands and Queries (CQRS)?

A: Separating reads and writes lets you optimize each independently. Commands go through the full domain model with validation and invariant enforcement. Queries can use lightweight read models or projections that skip the domain entirely, improving performance. It also clarifies intent -- a developer reading the code immediately knows whether a handler modifies state.


Q: How do you handle cross-cutting concerns like validation and logging in Clean Architecture?

A: Use MediatR pipeline behaviors. A ValidationBehavior<TRequest, TResponse> runs all registered IValidator<TRequest> implementations before the handler executes. A LoggingBehavior can log request/response details. This keeps handlers focused on business logic and avoids duplicating cross-cutting code.


Q: What is the Unit of Work pattern and why use it with repositories?

A: Unit of Work tracks all changes made during a business transaction and commits them atomically. In EF Core, DbContext already implements this pattern. An explicit IUnitOfWork interface in the Application layer gives handlers control over when changes are saved and allows dispatching domain events before or after the commit.


Q: Should domain entities have public setters?

A: No. Domain entities should protect their invariants using private setters, factory methods, and behavior methods. Public setters allow any code to put the entity into an invalid state, bypassing business rules. Use methods like trade.Close(exitPrice) that encapsulate the state transition and enforce invariants.


Q: How do you test a use case handler without a real database?

A: Mock the repository and unit of work interfaces using a framework like Moq or NSubstitute. Pass the mocks into the handler's constructor. Assert that the handler calls the expected repository methods with the correct arguments and that it calls CommitAsync on the unit of work. Domain logic is tested separately in domain unit tests.


Q: How do you prevent domain entities from leaking into API responses?

A: Use DTOs (Data Transfer Objects) as the return type from handlers. Map domain entities to DTOs in the handler or via AutoMapper/Mapster. Controllers receive DTOs and optionally map them to ViewModels or API response objects. Never serialize domain entities directly -- they contain internal state (like domain events) that should not be exposed.


Q: When would you relax strict Clean Architecture layering?

A: When profiling proves that the indirection causes a measurable performance bottleneck (rare in practice), or for simple CRUD operations that have no meaningful business logic. Even then, document the decision and limit the scope. For read-only queries, skipping the domain model in favor of direct projections is a common and accepted optimization.


Q: How does Clean Architecture relate to Domain-Driven Design (DDD)?

A: They complement each other. DDD shapes the Domain layer -- it provides patterns like entities, value objects, aggregates, domain events, and bounded contexts. Clean Architecture provides the surrounding structure -- it defines how dependencies flow, where interfaces live, and how layers interact. You can use Clean Architecture without DDD, but DDD enriches the domain model.


Q: How do you handle exceptions across layers?

A: Define custom exception types per layer. The Domain layer throws DomainException for invariant violations. The Application layer throws NotFoundException or ValidationException. A global exception-handling middleware in the Presentation layer catches these and maps them to appropriate HTTP status codes (422 for domain errors, 404 for not found, 400 for validation, 500 for unexpected errors).


Q: What is the composition root and where does it belong?

A: The composition root is the single place where all DI bindings are configured. In ASP.NET Core, it is in Program.cs (or a set of Add* extension methods called from there). It belongs in the outermost layer (Presentation/API) because it must know about all layers to wire them together. This is the only place where the Presentation project directly references Infrastructure.


Q: How do you enforce architectural rules in CI/CD?

A: Use NetArchTest to write architecture tests that verify dependency rules at build time. For example, assert that MyApp.Domain does not reference MyApp.Infrastructure. Run these tests as part of the CI pipeline. If a developer adds a forbidden reference, the test fails and the build breaks.


Q: What are the trade-offs of Clean Architecture?

A: Benefits include testability, flexibility, and independence from frameworks. Trade-offs include more files and indirection (more projects, more interfaces, more mapping). For small CRUD applications, it can feel like over-engineering. The architecture pays off as the application grows, the team scales, and requirements change frequently. Start simple and grow into full Clean Architecture as complexity demands it.


Q: How do MediatR notifications relate to domain events in Clean Architecture?

A: Domain events are raised by entities when meaningful state changes occur. In the Unit of Work, before or after SaveChangesAsync, you collect these events and publish them as MediatR INotification messages. Event handlers (in the Application layer) can then trigger side effects like sending emails, updating read models, or publishing integration events to a message broker -- all without the domain entity knowing about these concerns.


Q: Should every project use Clean Architecture?

A: No. Clean Architecture is best suited for applications with complex or evolving business logic, multiple teams, or long lifespans. For simple APIs, prototypes, or microservices with minimal logic, a simpler layered or vertical-slice approach may be more pragmatic. Choose the architecture that fits the complexity of the problem.


Q: How do you organize code within the Application layer -- by feature or by type?

A: Organize by feature (vertical slices). Group the command, handler, validator, and DTO for a use case together in a folder:

Trades/
  Commands/
    OpenTrade/
      OpenTradeCommand.cs
      OpenTradeHandler.cs
      OpenTradeValidator.cs
    CloseTrade/
      CloseTradeCommand.cs
      CloseTradeHandler.cs
      CloseTradeValidator.cs
  Queries/
    GetTradeById/
      GetTradeByIdQuery.cs
      GetTradeByIdHandler.cs
      TradeDto.cs

This keeps related code together, reduces navigation, and makes it easy to find everything related to a use case. Avoid grouping all commands in one folder, all handlers in another, and all validators in a third -- that scatters related code across the project.