Clean Architecture in .NET: Building Maintainable Applications
Backend17 min read

Clean Architecture in .NET: Building Maintainable Applications

Implement Clean Architecture with CQRS and MediatR in .NET. Domain-driven design, dependency inversion, and testable code patterns.

Taha Kocal

Taha Kocal

Full Stack Developer

Feb 5, 2025
#Clean Architecture#C##.NET#CQRS#MediatR#DDD

Clean Architecture is a software design approach that organizes a .NET application into concentric layers, Domain, Application, Infrastructure, and Presentation, where all dependencies point inward toward the business rules. The Domain layer holds entities and domain events with no external dependencies; the Application layer defines use cases; Infrastructure implements persistence and external services; and the API layer stays thin. Combined with CQRS (Command Query Responsibility Segregation) and MediatR, every operation becomes an explicit command or query handled by a dedicated handler, which makes business logic isolated, highly testable, and easy to extend. Cross-cutting concerns such as validation and logging live in MediatR pipeline behaviors instead of controllers. This guide builds a production-ready .NET application step by step: project structure, domain modeling, CQRS handlers, pipeline behaviors, the Result pattern, domain event handling, and controller integration.

Architecture Overview

text
┌─────────────────────────────────────────────────────────┐
│                      Presentation                        │
│                    (API Controllers)                     │
└─────────────────────────┬───────────────────────────────┘
                          │
┌─────────────────────────▼───────────────────────────────┐
│                      Application                         │
│              (Use Cases, CQRS Handlers)                  │
└─────────────────────────┬───────────────────────────────┘
                          │
┌─────────────────────────▼───────────────────────────────┐
│                        Domain                            │
│            (Entities, Value Objects, Events)             │
└─────────────────────────────────────────────────────────┘
                          ▲
┌─────────────────────────┴───────────────────────────────┐
│                    Infrastructure                        │
│         (Database, External Services, Caching)           │
└─────────────────────────────────────────────────────────┘

Project Structure

text
src/
├── MyApp.Domain/                  # Enterprise business rules
│   ├── Entities/
│   ├── ValueObjects/
│   ├── Events/
│   ├── Exceptions/
│   ├── Enums/
│   └── Interfaces/
│
├── MyApp.Application/             # Application business rules
│   ├── Common/
│   │   ├── Behaviors/
│   │   ├── Interfaces/
│   │   └── Models/
│   ├── Features/
│   │   ├── Users/
│   │   │   ├── Commands/
│   │   │   ├── Queries/
│   │   │   └── EventHandlers/
│   │   └── Orders/
│   └── DependencyInjection.cs
│
├── MyApp.Infrastructure/          # External concerns
│   ├── Data/
│   ├── Services/
│   ├── Identity/
│   └── DependencyInjection.cs
│
└── MyApp.API/                     # Presentation layer
    ├── Controllers/
    ├── Filters/
    └── Program.cs

What Belongs in the Domain Layer?

Base Entity

csharp
// Domain/Common/BaseEntity.cs
public abstract class BaseEntity
{
    public Guid Id { get; protected set; }

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

    protected void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

// Domain/Common/AuditableEntity.cs
public abstract class AuditableEntity : BaseEntity
{
    public DateTime CreatedAt { get; set; }
    public string? CreatedBy { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public string? UpdatedBy { get; set; }
}

Entity with Business Logic

csharp
// Domain/Entities/Order.cs
public class Order : AuditableEntity
{
    public Guid UserId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money TotalAmount { get; private set; }
    public Address ShippingAddress { get; private set; }

    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    private Order() { } // EF Core

    public static Order Create(Guid userId, Address shippingAddress)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            Status = OrderStatus.Pending,
            ShippingAddress = shippingAddress,
            TotalAmount = Money.Zero("USD")
        };

        order.AddDomainEvent(new OrderCreatedEvent(order.Id, userId));
        return order;
    }

    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Cannot add items to a non-pending order");

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

        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(Id, product, quantity));
        }

        RecalculateTotal();
    }

    public void RemoveItem(Guid productId)
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Cannot modify a non-pending order");

        var item = _items.FirstOrDefault(i => i.ProductId == productId)
            ?? throw new DomainException("Item not found in order");

        _items.Remove(item);
        RecalculateTotal();
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Only pending orders can be confirmed");

        if (!_items.Any())
            throw new DomainException("Cannot confirm an empty order");

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmedEvent(Id));
    }

    public void Ship(string trackingNumber)
    {
        if (Status != OrderStatus.Confirmed)
            throw new DomainException("Only confirmed orders can be shipped");

        Status = OrderStatus.Shipped;
        AddDomainEvent(new OrderShippedEvent(Id, trackingNumber));
    }

    public void Cancel(string reason)
    {
        if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
            throw new DomainException("Cannot cancel shipped or delivered orders");

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

    private void RecalculateTotal()
    {
        TotalAmount = Money.FromDecimal(
            _items.Sum(i => i.UnitPrice.Amount * i.Quantity),
            TotalAmount.Currency
        );
    }
}

Value Objects

csharp
// Domain/ValueObjects/Money.cs
public record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    private Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new DomainException("Money amount cannot be negative");

        Amount = amount;
        Currency = currency;
    }

    public static Money FromDecimal(decimal amount, string currency) => new(amount, currency);
    public static Money Zero(string currency) => new(0, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException("Cannot add money with different currencies");

        return new Money(Amount + other.Amount, Currency);
    }

    public Money Multiply(int factor) => new(Amount * factor, Currency);
}

// Domain/ValueObjects/Address.cs
public record Address
{
    public string Street { get; }
    public string City { get; }
    public string PostalCode { get; }
    public string Country { get; }

    public Address(string street, string city, string postalCode, string country)
    {
        if (string.IsNullOrWhiteSpace(street))
            throw new DomainException("Street is required");

        Street = street;
        City = city;
        PostalCode = postalCode;
        Country = country;
    }
}

// Domain/ValueObjects/Email.cs
public record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new DomainException("Email is required");

        if (!IsValidEmail(value))
            throw new DomainException("Invalid email format");

        Value = value.ToLowerInvariant();
    }

    private static bool IsValidEmail(string email) =>
        Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$");

    public static implicit operator string(Email email) => email.Value;
}

Domain Events

csharp
// Domain/Events/IDomainEvent.cs
public interface IDomainEvent
{
    DateTime OccurredOn { get; }
}

// Domain/Events/OrderCreatedEvent.cs
public record OrderCreatedEvent(Guid OrderId, Guid UserId) : IDomainEvent
{
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

// Domain/Events/OrderConfirmedEvent.cs
public record OrderConfirmedEvent(Guid OrderId) : IDomainEvent
{
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

How Does the Application Layer Use CQRS?

MediatR Setup

bash
dotnet add package MediatR
dotnet add package FluentValidation.DependencyInjectionExtensions
csharp
// Application/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        services.AddMediatR(cfg => {
            cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
        });

        services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);

        // Pipeline behaviors (order matters!)
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>));

        return services;
    }
}

Command Example

csharp
// Application/Features/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand : IRequest<Result<Guid>>
{
    public Guid UserId { get; init; }
    public AddressDto ShippingAddress { get; init; } = null!;
    public List<OrderItemDto> Items { get; init; } = new();
}

// Application/Features/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.UserId)
            .NotEmpty().WithMessage("User ID is required");

        RuleFor(x => x.ShippingAddress)
            .NotNull().WithMessage("Shipping address is required");

        RuleFor(x => x.ShippingAddress.Street)
            .NotEmpty().When(x => x.ShippingAddress != null);

        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("At least one item is required");

        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(x => x.ProductId).NotEmpty();
            item.RuleFor(x => x.Quantity).GreaterThan(0);
        });
    }
}

// Application/Features/Orders/Commands/CreateOrder/CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly IUserRepository _userRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        IUserRepository userRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _userRepository = userRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        // Validate user exists
        var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
        if (user is null)
            return Result.Failure<Guid>(DomainErrors.User.NotFound);

        // Create order
        var address = new Address(
            request.ShippingAddress.Street,
            request.ShippingAddress.City,
            request.ShippingAddress.PostalCode,
            request.ShippingAddress.Country
        );

        var order = Order.Create(request.UserId, address);

        // Add items
        foreach (var item in request.Items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId, cancellationToken);
            if (product is null)
                return Result.Failure<Guid>(DomainErrors.Product.NotFound(item.ProductId));

            if (product.Stock < item.Quantity)
                return Result.Failure<Guid>(DomainErrors.Product.InsufficientStock(product.Name));

            order.AddItem(product, item.Quantity);
        }

        await _orderRepository.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success(order.Id);
    }
}

Query Example

csharp
// Application/Features/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs
public record GetOrderByIdQuery(Guid OrderId) : IRequest<Result<OrderDto>>;

// Application/Features/Orders/Queries/GetOrderById/GetOrderByIdQueryHandler.cs
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Result<OrderDto>>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IMapper _mapper;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository, IMapper mapper)
    {
        _orderRepository = orderRepository;
        _mapper = mapper;
    }

    public async Task<Result<OrderDto>> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
    {
        var order = await _orderRepository.GetByIdWithItemsAsync(request.OrderId, cancellationToken);

        if (order is null)
            return Result.Failure<OrderDto>(DomainErrors.Order.NotFound);

        return Result.Success(_mapper.Map<OrderDto>(order));
    }
}

// Application/Features/Orders/Queries/GetUserOrders/GetUserOrdersQuery.cs
public record GetUserOrdersQuery : IRequest<Result<PagedList<OrderSummaryDto>>>
{
    public Guid UserId { get; init; }
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 10;
    public OrderStatus? StatusFilter { get; init; }
}

Pipeline Behaviors

Validation Behavior

csharp
// Application/Common/Behaviors/ValidationBehavior.cs
public 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 cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

Logging Behavior

csharp
// Application/Common/Behaviors/LoggingBehavior.cs
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
    private readonly ICurrentUserService _currentUserService;

    public LoggingBehavior(
        ILogger<LoggingBehavior<TRequest, TResponse>> logger,
        ICurrentUserService currentUserService)
    {
        _logger = logger;
        _currentUserService = currentUserService;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        var userId = _currentUserService.UserId;

        _logger.LogInformation(
            "Handling {RequestName} for user {UserId} with {@Request}",
            requestName, userId, request);

        var response = await next();

        _logger.LogInformation(
            "Handled {RequestName} for user {UserId}",
            requestName, userId);

        return response;
    }
}

Performance Behavior

csharp
// Application/Common/Behaviors/PerformanceBehavior.cs
public class PerformanceBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly Stopwatch _timer;
    private readonly ILogger<PerformanceBehavior<TRequest, TResponse>> _logger;

    public PerformanceBehavior(ILogger<PerformanceBehavior<TRequest, TResponse>> logger)
    {
        _timer = new Stopwatch();
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        _timer.Start();

        var response = await next();

        _timer.Stop();

        var elapsedMilliseconds = _timer.ElapsedMilliseconds;

        if (elapsedMilliseconds > 500)
        {
            var requestName = typeof(TRequest).Name;

            _logger.LogWarning(
                "Long running request: {RequestName} ({ElapsedMilliseconds} ms) with {@Request}",
                requestName, elapsedMilliseconds, request);
        }

        return response;
    }
}

Why Use the Result Pattern?

csharp
// Application/Common/Models/Result.cs
public class Result
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public Error Error { get; }

    protected Result(bool isSuccess, Error error)
    {
        IsSuccess = isSuccess;
        Error = error;
    }

    public static Result Success() => new(true, Error.None);
    public static Result Failure(Error error) => new(false, error);
    public static Result<T> Success<T>(T value) => new(value, true, Error.None);
    public static Result<T> Failure<T>(Error error) => new(default!, false, error);
}

public class Result<T> : Result
{
    public T Value { get; }

    protected internal Result(T value, bool isSuccess, Error error)
        : base(isSuccess, error)
    {
        Value = value;
    }

    public static implicit operator Result<T>(T value) => Success(value);
}

// Application/Common/Models/Error.cs
public record Error(string Code, string Message)
{
    public static readonly Error None = new(string.Empty, string.Empty);
}

// Domain/Errors/DomainErrors.cs
public static class DomainErrors
{
    public static class User
    {
        public static readonly Error NotFound = new("User.NotFound", "User was not found");
        public static readonly Error EmailTaken = new("User.EmailTaken", "Email is already taken");
    }

    public static class Order
    {
        public static readonly Error NotFound = new("Order.NotFound", "Order was not found");
        public static readonly Error AlreadyShipped = new("Order.AlreadyShipped", "Order has already been shipped");
    }

    public static class Product
    {
        public static Error NotFound(Guid id) => new("Product.NotFound", $"Product {id} was not found");
        public static Error InsufficientStock(string name) => new("Product.InsufficientStock", $"Insufficient stock for {name}");
    }
}

How Do You Handle Domain Events?

csharp
// Application/Features/Orders/EventHandlers/OrderCreatedEventHandler.cs
public class OrderCreatedEventHandler : INotificationHandler<DomainEventNotification<OrderCreatedEvent>>
{
    private readonly IEmailService _emailService;
    private readonly IUserRepository _userRepository;

    public OrderCreatedEventHandler(IEmailService emailService, IUserRepository userRepository)
    {
        _emailService = emailService;
        _userRepository = userRepository;
    }

    public async Task Handle(
        DomainEventNotification<OrderCreatedEvent> notification,
        CancellationToken cancellationToken)
    {
        var domainEvent = notification.DomainEvent;

        var user = await _userRepository.GetByIdAsync(domainEvent.UserId, cancellationToken);
        if (user is null) return;

        await _emailService.SendOrderConfirmationAsync(
            user.Email,
            domainEvent.OrderId,
            cancellationToken);
    }
}

// Infrastructure/Data/Interceptors/DomainEventDispatcher.cs
public class DomainEventDispatcher : SaveChangesInterceptor
{
    private readonly IMediator _mediator;

    public DomainEventDispatcher(IMediator mediator)
    {
        _mediator = mediator;
    }

    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context is null) return result;

        var entities = context.ChangeTracker
            .Entries<BaseEntity>()
            .Where(e => e.Entity.DomainEvents.Any())
            .Select(e => e.Entity)
            .ToList();

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

        entities.ForEach(e => e.ClearDomainEvents());

        foreach (var domainEvent in domainEvents)
        {
            await _mediator.Publish(
                new DomainEventNotification<IDomainEvent>(domainEvent),
                cancellationToken);
        }

        return result;
    }
}

Controller Integration

csharp
// API/Controllers/OrdersController.cs
[ApiController]
[Route("api/[controller]")]
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> CreateOrder(
        [FromBody] CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(command, cancellationToken);

        if (result.IsFailure)
            return BadRequest(CreateProblemDetails(result.Error));

        return CreatedAtAction(nameof(GetOrder), new { id = result.Value }, result.Value);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetOrder(Guid id, CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(new GetOrderByIdQuery(id), cancellationToken);

        if (result.IsFailure)
            return NotFound();

        return Ok(result.Value);
    }

    [HttpPost("{id:guid}/confirm")]
    public async Task<IActionResult> ConfirmOrder(Guid id, CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);

        if (result.IsFailure)
            return BadRequest(CreateProblemDetails(result.Error));

        return NoContent();
    }

    private static ProblemDetails CreateProblemDetails(Error error) => new()
    {
        Title = error.Code,
        Detail = error.Message,
        Status = StatusCodes.Status400BadRequest
    };
}

Clean Architecture with CQRS creates a clear separation of concerns. Commands mutate state, queries read state, and the domain layer remains pure. This structure makes testing straightforward and allows the application to evolve independently of infrastructure concerns.

This architecture may seem like overkill for simple applications, but it pays dividends as complexity grows. Start simple and adopt these patterns when your application demands it.

Share this article

Related Articles