Learnixo
Back to blog
Backend Systemsintermediate

Clean Architecture in .NET: A Complete Guide with Real Code

Master Clean Architecture in .NET. Covers the dependency rule, domain layer with value objects and domain events, application layer with CQRS and pipeline behaviours, infrastructure, testing strategy, and when not to use it.

LearnixoJune 4, 202615 min read
.NETC#Clean ArchitectureCQRSMediatRDDDTesting
Share:๐•

Why Clean Architecture?

Most .NET projects start clean. Six months in, the controller calls EF Core directly. The service layer imports HttpClient. Business rules are scattered across three layers. A simple requirement change touches eight files.

Clean Architecture enforces one rule that prevents all of this: dependencies point inward.

Outer layers depend on inner layers.
Inner layers know nothing about outer layers.

The domain โ€” your business rules โ€” never imports a NuGet package. It doesn't know about SQL Server, HTTP, or email. It's pure C#. Everything else adapts to it.


The Four Layers

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              Presentation                     โ”‚
โ”‚   Controllers ยท Minimal API ยท SignalR Hubs    โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚              Infrastructure                   โ”‚
โ”‚   EF Core ยท Redis ยท Email ยท Azure Blob        โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚              Application                      โ”‚
โ”‚   Commands ยท Queries ยท Handlers ยท DTOs        โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                 Domain                        โ”‚
โ”‚   Entities ยท Value Objects ยท Domain Events    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Dependencies flow downward only. Presentation knows about Application. Application knows about Domain. Domain knows about nothing.

Infrastructure also depends on Domain (it implements Domain interfaces like IOrderRepository). The dependency inversion principle makes this work: the Domain defines the interface; Infrastructure implements it.


Project Structure

src/
โ”œโ”€โ”€ OrderFlow.Domain/
โ”‚   โ”œโ”€โ”€ Entities/
โ”‚   โ”œโ”€โ”€ ValueObjects/
โ”‚   โ”œโ”€โ”€ Events/
โ”‚   โ”œโ”€โ”€ Interfaces/          โ† repository interfaces defined here
โ”‚   โ””โ”€โ”€ Exceptions/
โ”œโ”€โ”€ OrderFlow.Application/
โ”‚   โ”œโ”€โ”€ Orders/
โ”‚   โ”‚   โ”œโ”€โ”€ Commands/
โ”‚   โ”‚   โ””โ”€โ”€ Queries/
โ”‚   โ”œโ”€โ”€ Common/
โ”‚   โ”‚   โ”œโ”€โ”€ Behaviours/      โ† MediatR pipeline (validation, logging)
โ”‚   โ”‚   โ””โ”€โ”€ Interfaces/      โ† ICurrentUser, IEmailService (app-level)
โ”‚   โ””โ”€โ”€ DependencyInjection.cs
โ”œโ”€โ”€ OrderFlow.Infrastructure/
โ”‚   โ”œโ”€โ”€ Persistence/
โ”‚   โ”‚   โ”œโ”€โ”€ AppDbContext.cs
โ”‚   โ”‚   โ”œโ”€โ”€ Configurations/
โ”‚   โ”‚   โ””โ”€โ”€ Repositories/
โ”‚   โ”œโ”€โ”€ Services/            โ† email, blob storage implementations
โ”‚   โ””โ”€โ”€ DependencyInjection.cs
โ””โ”€โ”€ OrderFlow.API/
    โ”œโ”€โ”€ Controllers/
    โ”œโ”€โ”€ Middleware/
    โ””โ”€โ”€ Program.cs

Domain Layer

The domain layer is the heart of the system. It has zero external dependencies โ€” no EF Core, no MediatR, no NuGet packages. Only the .NET BCL.

Entities

Entities have identity. Two orders with the same ID are the same order, regardless of their properties.

C#
// OrderFlow.Domain/Entities/Order.cs
public class Order
{
    private readonly List<OrderLine> _lines = new();

    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money Total { get; private set; }
    public DateTime CreatedAt { get; private set; }

    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

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

    private Order() { } // EF Core constructor

    public static Order Create(CustomerId customerId, IEnumerable<CreateOrderLineDto> lines)
    {
        if (!lines.Any())
            throw new DomainException("An order must have at least one line.");

        var order = new Order
        {
            Id         = OrderId.New(),
            CustomerId = customerId,
            Status     = OrderStatus.Pending,
            CreatedAt  = DateTime.UtcNow
        };

        foreach (var line in lines)
            order.AddLine(line.ProductId, line.Quantity, line.UnitPrice);

        order._domainEvents.Add(new OrderCreatedEvent(order.Id, order.CustomerId, order.Total));
        return order;
    }

    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Cannot modify a submitted order.");

        if (quantity <= 0)
            throw new DomainException("Quantity must be positive.");

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
        RecalculateTotal();
    }

    public void Submit()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException($"Cannot submit an order in {Status} status.");

        if (!_lines.Any())
            throw new DomainException("Cannot submit an empty order.");

        Status = OrderStatus.Submitted;
        _domainEvents.Add(new OrderSubmittedEvent(Id, CustomerId, Total));
    }

    public void Cancel(string reason)
    {
        if (Status == OrderStatus.Delivered)
            throw new DomainException("Delivered orders cannot be cancelled.");

        Status = OrderStatus.Cancelled;
        _domainEvents.Add(new OrderCancelledEvent(Id, reason));
    }

    private void RecalculateTotal()
    {
        Total = _lines.Aggregate(Money.Zero, (sum, line) => sum + line.Subtotal);
    }

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

Key principles:

  • Private setters โ€” state changes only through methods
  • Business rules in the entity โ€” Submit() validates its own invariants
  • Domain events โ€” signal that something happened (dispatched after SaveChanges)

Value Objects

Value objects have no identity โ€” they're equal if their values are equal. Immutable. No setters.

C#
// OrderFlow.Domain/ValueObjects/Money.cs
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public static Money Zero => new(0, "GBP");

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new DomainException("Money amount cannot be negative.");
        if (string.IsNullOrEmpty(currency) || currency.Length != 3)
            throw new DomainException("Currency must be a 3-letter ISO code.");

        Amount   = amount;
        Currency = currency.ToUpperInvariant();
    }

    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new DomainException($"Cannot add {a.Currency} and {b.Currency}.");
        return new Money(a.Amount + b.Amount, a.Currency);
    }

    public static Money operator *(Money money, int multiplier)
        => new(money.Amount * multiplier, money.Currency);

    public override string ToString() => $"{Amount:F2} {Currency}";
}

// OrderFlow.Domain/ValueObjects/OrderId.cs
public sealed record OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.NewGuid());
    public static OrderId From(Guid value) => new(value);
    public override string ToString() => Value.ToString();
}

Using record gives us value equality automatically โ€” two Money(100, "GBP") instances are equal.

Domain Interfaces

The domain defines what it needs. It doesn't know how it's implemented.

C#
// OrderFlow.Domain/Interfaces/IOrderRepository.cs
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task UpdateAsync(Order order, CancellationToken ct = default);
}

Domain Events

Immutable records of something that happened. Used to trigger side effects in the same transaction (via MediatR) or asynchronously (via message queues).

C#
// OrderFlow.Domain/Events/
public interface IDomainEvent { }

public record OrderCreatedEvent(
    OrderId OrderId,
    CustomerId CustomerId,
    Money Total) : IDomainEvent;

public record OrderSubmittedEvent(
    OrderId OrderId,
    CustomerId CustomerId,
    Money Total) : IDomainEvent;

public record OrderCancelledEvent(
    OrderId OrderId,
    string Reason) : IDomainEvent;

Domain Exceptions

C#
// OrderFlow.Domain/Exceptions/
public class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
}

public class NotFoundException : Exception
{
    public NotFoundException(string entityName, object key)
        : base($"{entityName} with id '{key}' was not found.") { }
}

Application Layer

Orchestrates the domain to fulfil use cases. Knows about the domain. Knows nothing about HTTP or databases.

Commands (Write Operations)

C#
// OrderFlow.Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(
    Guid CustomerId,
    IReadOnlyList<OrderLineDto> Lines) : IRequest<Guid>;

public record OrderLineDto(Guid ProductId, int Quantity, decimal UnitPrice);
C#
// OrderFlow.Application/Orders/Commands/CreateOrder/CreateOrderHandler.cs
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orders;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderHandler(IOrderRepository orders, IUnitOfWork unitOfWork)
    {
        _orders     = orders;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var order = Order.Create(
            CustomerId.From(request.CustomerId),
            request.Lines.Select(l => new CreateOrderLineDto(
                ProductId.From(l.ProductId),
                l.Quantity,
                new Money(l.UnitPrice, "GBP"))));

        await _orders.AddAsync(order, ct);
        await _unitOfWork.SaveChangesAsync(ct);

        return order.Id.Value;
    }
}

Queries (Read Operations)

Queries can bypass the repository pattern and go directly to the database for performance. They don't need domain objects.

C#
// OrderFlow.Application/Orders/Queries/GetOrder/GetOrderQuery.cs
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDetailDto>;

public record OrderDetailDto(
    Guid Id,
    Guid CustomerId,
    string Status,
    decimal Total,
    string Currency,
    DateTime CreatedAt,
    IReadOnlyList<OrderLineDetailDto> Lines);

public record OrderLineDetailDto(Guid ProductId, int Quantity, decimal UnitPrice, decimal Subtotal);
C#
// OrderFlow.Application/Orders/Queries/GetOrder/GetOrderHandler.cs
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDetailDto>
{
    private readonly IOrderRepository _orders;

    public GetOrderHandler(IOrderRepository orders) => _orders = orders;

    public async Task<OrderDetailDto> Handle(GetOrderQuery request, CancellationToken ct)
    {
        var order = await _orders.GetByIdAsync(OrderId.From(request.OrderId), ct)
            ?? throw new NotFoundException("Order", request.OrderId);

        return new OrderDetailDto(
            order.Id.Value,
            order.CustomerId.Value,
            order.Status.ToString(),
            order.Total.Amount,
            order.Total.Currency,
            order.CreatedAt,
            order.Lines.Select(l => new OrderLineDetailDto(
                l.ProductId.Value,
                l.Quantity,
                l.UnitPrice.Amount,
                l.Subtotal.Amount)).ToList());
    }
}

Validation with FluentValidation

C#
// OrderFlow.Application/Orders/Commands/CreateOrder/CreateOrderValidator.cs
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty().WithMessage("CustomerId is required.");

        RuleFor(x => x.Lines)
            .NotEmpty().WithMessage("At least one order line is required.")
            .Must(lines => lines.Count <= 100).WithMessage("Maximum 100 lines per order.");

        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.ProductId).NotEmpty();
            line.RuleFor(l => l.Quantity).GreaterThan(0);
            line.RuleFor(l => l.UnitPrice).GreaterThan(0);
        });
    }
}

Pipeline Behaviours

MediatR pipeline behaviours run around every command/query โ€” like middleware for your use cases.

C#
// OrderFlow.Application/Common/Behaviours/ValidationBehaviour.cs
public class ValidationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

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

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}
C#
// OrderFlow.Application/Common/Behaviours/LoggingBehaviour.cs
public class LoggingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;

    public LoggingBehaviour(ILogger<LoggingBehaviour<TRequest, TResponse>> logger)
        => _logger = logger;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var name = typeof(TRequest).Name;
        _logger.LogInformation("Handling {RequestName}", name);

        var sw = Stopwatch.StartNew();
        var response = await next();
        sw.Stop();

        if (sw.ElapsedMilliseconds > 500)
            _logger.LogWarning("Slow request: {RequestName} took {Elapsed}ms", name, sw.ElapsedMilliseconds);

        return response;
    }
}

Domain Event Dispatcher

Dispatch domain events after SaveChanges:

C#
// OrderFlow.Application/Common/Behaviours/DomainEventDispatcher.cs
public static class DomainEventDispatcher
{
    public static async Task DispatchAsync(
        IReadOnlyList<Order> entitiesWithEvents,
        IPublisher publisher,
        CancellationToken ct)
    {
        foreach (var entity in entitiesWithEvents)
        {
            var events = entity.DomainEvents.ToList();
            entity.ClearDomainEvents();

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

Application DI Registration

C#
// OrderFlow.Application/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        var assembly = Assembly.GetExecutingAssembly();

        services.AddMediatR(cfg =>
        {
            cfg.RegisterServicesFromAssembly(assembly);
            cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
            cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
        });

        services.AddValidatorsFromAssembly(assembly);

        return services;
    }
}

Infrastructure Layer

Implements domain interfaces. This is the only place EF Core, Redis, email providers, etc. live.

DbContext

C#
// OrderFlow.Infrastructure/Persistence/AppDbContext.cs
public class AppDbContext : DbContext, IUnitOfWork
{
    private readonly IPublisher _publisher;

    public AppDbContext(DbContextOptions<AppDbContext> options, IPublisher publisher)
        : base(options)
    {
        _publisher = publisher;
    }

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
    }

    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        var result = await base.SaveChangesAsync(ct);

        // Dispatch domain events after commit
        var entitiesWithEvents = ChangeTracker
            .Entries<Order>()
            .Select(e => e.Entity)
            .Where(e => e.DomainEvents.Any())
            .ToList();

        await DomainEventDispatcher.DispatchAsync(entitiesWithEvents, _publisher, ct);

        return result;
    }
}

EF Core Configuration

C#
// OrderFlow.Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        // Value object mapping
        builder.Property(o => o.Id)
            .HasConversion(id => id.Value, value => OrderId.From(value));

        builder.Property(o => o.CustomerId)
            .HasConversion(id => id.Value, value => CustomerId.From(value));

        builder.ComplexProperty(o => o.Total, money =>
        {
            money.Property(m => m.Amount).HasColumnName("TotalAmount").HasPrecision(18, 2);
            money.Property(m => m.Currency).HasColumnName("TotalCurrency").HasMaxLength(3);
        });

        builder.Property(o => o.Status)
            .HasConversion<string>();

        builder.HasMany(o => o.Lines)
            .WithOne()
            .HasForeignKey("OrderId")
            .OnDelete(DeleteBehavior.Cascade);

        // Ignore domain events โ€” they're not persisted
        builder.Ignore(o => o.DomainEvents);
    }
}

Repository Implementation

C#
// OrderFlow.Infrastructure/Persistence/Repositories/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;

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

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
        => await _db.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task<IReadOnlyList<Order>> GetByCustomerAsync(
        CustomerId customerId, CancellationToken ct)
        => await _db.Orders
            .Include(o => o.Lines)
            .Where(o => o.CustomerId == customerId)
            .ToListAsync(ct);

    public Task AddAsync(Order order, CancellationToken ct)
    {
        _db.Orders.Add(order);
        return Task.CompletedTask; // SaveChanges called by IUnitOfWork
    }

    public Task UpdateAsync(Order order, CancellationToken ct)
    {
        _db.Orders.Update(order);
        return Task.CompletedTask;
    }
}

Infrastructure DI Registration

C#
// OrderFlow.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<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
        services.AddScoped<IOrderRepository, OrderRepository>();

        return services;
    }
}

Presentation Layer

Thin controllers โ€” they translate HTTP into application commands/queries and back.

C#
// OrderFlow.API/Controllers/OrdersController.cs
[ApiController]
[Route("api/orders")]
[Authorize]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

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

    [HttpPost]
    [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken ct)
    {
        var id = await _mediator.Send(command, ct);
        return CreatedAtAction(nameof(GetById), new { id }, id);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(OrderDetailDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var order = await _mediator.Send(new GetOrderQuery(id), ct);
        return Ok(order);
    }

    [HttpPost("{id:guid}/submit")]
    public async Task<IActionResult> Submit(Guid id, CancellationToken ct)
    {
        await _mediator.Send(new SubmitOrderCommand(id), ct);
        return NoContent();
    }
}

Global Exception Handling

C#
// OrderFlow.API/Middleware/ExceptionHandlingMiddleware.cs
public 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 = 422;
            await context.Response.WriteAsJsonAsync(new
            {
                type    = "validation-error",
                errors  = ex.Errors.GroupBy(e => e.PropertyName)
                    .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage))
            });
        }
        catch (NotFoundException ex)
        {
            context.Response.StatusCode = 404;
            await context.Response.WriteAsJsonAsync(new { message = ex.Message });
        }
        catch (DomainException ex)
        {
            context.Response.StatusCode = 422;
            await context.Response.WriteAsJsonAsync(new { message = ex.Message });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            context.Response.StatusCode = 500;
            await context.Response.WriteAsJsonAsync(new { message = "An unexpected error occurred." });
        }
    }
}

Program.cs

C#
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddApplication()
    .AddInfrastructure(builder.Configuration);

builder.Services.AddControllers();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(/* ... */);

var app = builder.Build();

app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

Testing Strategy

Clean Architecture is extremely testable because each layer is isolated.

Unit Tests โ€” Application Layer

Test handlers in isolation. No database, no HTTP.

C#
public class CreateOrderHandlerTests
{
    private readonly Mock<IOrderRepository> _orders = new();
    private readonly Mock<IUnitOfWork> _unitOfWork = new();

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
    {
        var handler = new CreateOrderHandler(_orders.Object, _unitOfWork.Object);

        var command = new CreateOrderCommand(
            CustomerId: Guid.NewGuid(),
            Lines: [new OrderLineDto(Guid.NewGuid(), Quantity: 2, UnitPrice: 15.00m)]);

        var id = await handler.Handle(command, CancellationToken.None);

        Assert.NotEqual(Guid.Empty, id);
        _orders.Verify(r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_EmptyLines_ThrowsDomainException()
    {
        var handler = new CreateOrderHandler(_orders.Object, _unitOfWork.Object);

        var command = new CreateOrderCommand(Guid.NewGuid(), Lines: []);

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

Unit Tests โ€” Domain Layer

Test business rules directly โ€” the fastest possible tests.

C#
public class OrderTests
{
    [Fact]
    public void Submit_PendingOrder_ChangesStatusToSubmitted()
    {
        var order = Order.Create(
            CustomerId.New(),
            [new CreateOrderLineDto(ProductId.New(), 1, new Money(10, "GBP"))]);

        order.Submit();

        Assert.Equal(OrderStatus.Submitted, order.Status);
    }

    [Fact]
    public void Submit_AlreadySubmitted_ThrowsDomainException()
    {
        var order = CreateSubmittedOrder();

        Assert.Throws<DomainException>(() => order.Submit());
    }

    [Fact]
    public void Submit_RaisesOrderSubmittedEvent()
    {
        var order = CreatePendingOrder();

        order.Submit();

        Assert.Contains(order.DomainEvents, e => e is OrderSubmittedEvent);
    }

    [Fact]
    public void Cancel_DeliveredOrder_ThrowsDomainException()
    {
        var order = CreateDeliveredOrder();

        Assert.Throws<DomainException>(() => order.Cancel("changed mind"));
    }
}

Integration Tests โ€” Infrastructure Layer

Test the real database with TestContainers.

C#
public class OrderRepositoryTests : IAsyncLifetime
{
    private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder().Build();
    private AppDbContext _db = null!;

    public async Task InitializeAsync()
    {
        await _sqlContainer.StartAsync();
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_sqlContainer.GetConnectionString())
            .Options;
        _db = new AppDbContext(options, Mock.Of<IPublisher>());
        await _db.Database.MigrateAsync();
    }

    [Fact]
    public async Task GetByIdAsync_ExistingOrder_ReturnsOrder()
    {
        var repo  = new OrderRepository(_db);
        var order = CreateTestOrder();

        _db.Orders.Add(order);
        await _db.SaveChangesAsync();

        var found = await repo.GetByIdAsync(order.Id, CancellationToken.None);

        Assert.NotNull(found);
        Assert.Equal(order.Id, found!.Id);
        Assert.Equal(order.Lines.Count, found.Lines.Count);
    }

    public async Task DisposeAsync()
    {
        await _db.DisposeAsync();
        await _sqlContainer.DisposeAsync();
    }
}

Common Mistakes

1. Business logic in the Application layer

Handlers should orchestrate, not decide. If you're writing if (order.Amount > 1000) in a handler, that rule belongs in the domain.

2. Anemic domain models

If your entities are just property bags with public setters and all logic lives in services, you've lost the domain model. Entities should enforce their own invariants.

3. Repository per entity by default

You don't need ICustomerRepository, IProductRepository, and IOrderRepository for every entity. Start with what you need. Add repositories when a clear access pattern emerges.

4. Returning domain objects from queries

Queries should return DTOs optimised for the read model โ€” not domain entities loaded into memory and then mapped. Use Select projections to fetch only what's needed.

5. Over-engineering small apps

Clean Architecture has a setup cost. For a 5-endpoint CRUD API with no business rules, it's overkill. Use it when: the domain is complex, the team is large, or the system will grow significantly.


When to Use Clean Architecture

| Situation | Verdict | |---|---| | Complex business rules that change frequently | โœ… Use it | | Multiple developers owning different layers | โœ… Use it | | Long-lived system (3+ years) | โœ… Use it | | Microservice that does one thing (file upload, proxy) | โŒ Overkill | | Simple CRUD with no rules | โŒ Overkill | | Prototype / MVP | โŒ Overkill | | Monolith migrating to microservices later | โœ… Modular Clean Architecture |


Clean Architecture vs Vertical Slice

Clean Architecture organises by layer (all orders together: domain, application, infrastructure). Vertical Slice organises by feature (everything for CreateOrder together: command, validator, handler, query, endpoint).

Both enforce the dependency rule. Vertical Slice trades cross-layer consistency for feature isolation. Neither is universally better โ€” choose based on your team and system.


Interview Questions

Q: What is the dependency rule in Clean Architecture? Dependencies point inward. Outer layers (presentation, infrastructure) depend on inner layers (application, domain). The domain depends on nothing outside itself. This means the business logic is never affected by changing databases, frameworks, or delivery mechanisms.

Q: Why does the domain layer define repository interfaces instead of the infrastructure layer? Because the domain needs to express what it requires without knowing how it's fulfilled. The interface belongs to whoever needs it. If the Application or Domain layer needs IOrderRepository, it defines the contract. Infrastructure implements it. This is the Dependency Inversion Principle applied.

Q: What is the difference between a domain service and an application service? A domain service encapsulates business logic that doesn't naturally belong to a single entity (e.g., a pricing engine that involves products and customer tiers). An application service (MediatR handler) orchestrates domain objects to fulfil a use case โ€” it coordinates, it doesn't decide.

Q: What is a domain event and when do you dispatch it? An immutable record of something that happened in the domain. Raised by the aggregate during state transitions (e.g., OrderSubmittedEvent). Dispatched after SaveChanges โ€” not before โ€” to ensure the event is only published if the transaction succeeded.

Q: What is the difference between a command and a query in CQRS? Commands mutate state and return minimal data (usually just an ID or void). Queries return data and have no side effects. Separating them allows different optimisations โ€” queries can bypass the domain model and project directly from the database for performance, while commands go through the full domain.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:๐•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.