Clean Architecture met .NET: Praktische Gids

Beheers Clean Architecture in .NET met C#. Leer de SOLID-principes, laagscheiding en implementatiepatronen voor onderhoudbare applicaties.

Clean Architecture gids met .NET en C#

Clean Architecture, populair gemaakt door Robert C. Martin (Uncle Bob), organiseert de code zo dat de bedrijfslogica in het hart van de applicatie staat, onafhankelijk van frameworks en implementatiedetails. Deze architectuuraanpak garandeert testbaarheid, onderhoudbaarheid en schaalbaarheid voor .NET-applicaties. Deze gids toont een praktische implementatie met ASP.NET Core.

Waarom Clean Architecture?

Applicaties die bedrijfslogica vermengen met infrastructuurcode worden snel moeilijk te onderhouden. Clean Architecture dwingt een strikte scheiding af waardoor technische details gewijzigd kunnen worden zonder de bedrijfskern te raken.

Kernprincipes van Clean Architecture

Clean Architecture leunt op dependency inversion: interne lagen weten niets van externe lagen. Het bedrijfsdomein blijft geïsoleerd en kan onafhankelijk evolueren van technische keuzes zoals het webframework of de database.

text
┌─────────────────────────────────────────────────────────┐
│                     Presentation                        │
│  (Controllers, Razor Pages, Blazor, API Endpoints)     │
├─────────────────────────────────────────────────────────┤
│                    Infrastructure                       │
│    (EF Core, External APIs, File System, Email)        │
├─────────────────────────────────────────────────────────┤
│                      Application                        │
│       (Use Cases, Commands, Queries, DTOs)             │
├─────────────────────────────────────────────────────────┤
│                        Domain                           │
│     (Entities, Value Objects, Domain Services)          │
└─────────────────────────────────────────────────────────┘

Afhankelijkheden wijzen altijd naar binnen: Presentation → Infrastructure → Application → Domain. De Domain refereert aan geen enkel ander project.

.NET-projectstructuur voor Clean Architecture

De projectorganisatie weerspiegelt de verschillende lagen. Elke laag komt overeen met een apart project in de Visual Studio-solution, wat zorgt voor een fysieke scheiding van verantwoordelijkheden.

bash
# terminal
# Solution-structuur aanmaken
dotnet new sln -n CleanArchitecture

# Projecten per laag aanmaken
dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain
dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application
dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure
dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api

# Projecten aan de solution toevoegen
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api

# Projectreferenties configureren
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain

cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application

cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.Infrastructure

Deze structuur garandeert dat de Domain onafhankelijk blijft en dat afhankelijkheden de Dependency Rule van Clean Architecture volgen.

De Domain-laag: de bedrijfskern

De Domain-laag bevat de bedrijfsentiteiten, Value Objects en repository-interfaces. In deze laag zijn geen externe afhankelijkheden toegestaan.

Domain/Entities/Order.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Entiteit met identiteit en bedrijfslevenscyclus
public class Order
{
    // Unieke order-identifier
    public Guid Id { get; private set; }

    // Klantreferentie (Value Object voor het e-mailadres)
    public string CustomerEmail { get; private set; }

    // Item-collectie (one-to-many-relatie)
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // Orderstatus (bedrijfs-enum)
    public OrderStatus Status { get; private set; }

    // Berekend totaalbedrag
    public decimal TotalAmount => _items.Sum(i => i.TotalPrice);

    // Tracking-data
    public DateTime CreatedAt { get; private set; }
    public DateTime? ShippedAt { get; private set; }

    // Privé-constructor dwingt het gebruik van de factory method af
    private Order() { }

    // Factory method om een geldige order aan te maken
    public static Order Create(string customerEmail)
    {
        // Validatie van bedrijfsregels bij aanmaak
        if (string.IsNullOrWhiteSpace(customerEmail))
            throw new DomainException("Het e-mailadres van de klant is verplicht.");

        if (!IsValidEmail(customerEmail))
            throw new DomainException("Ongeldig e-mailformaat.");

        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerEmail = customerEmail,
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
    }

    // Bedrijfsmethode: een item toevoegen
    public void AddItem(Product product, int quantity)
    {
        // Bedrijfsregel: een verzonden order kan niet worden gewijzigd
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Een reeds verzonden order kan niet worden gewijzigd.");

        if (quantity <= 0)
            throw new DomainException("De hoeveelheid moet positief zijn.");

        // Controleren of het product al bestaat
        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(this, product, quantity));
        }
    }

    // Bedrijfsmethode: de order bevestigen
    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Alleen openstaande orders kunnen worden bevestigd.");

        if (!_items.Any())
            throw new DomainException("Een order moet ten minste één item bevatten.");

        Status = OrderStatus.Confirmed;
    }

    // Bedrijfsmethode: de order verzenden
    public void Ship()
    {
        if (Status != OrderStatus.Confirmed)
            throw new DomainException("De order moet worden bevestigd voordat hij wordt verzonden.");

        Status = OrderStatus.Shipped;
        ShippedAt = DateTime.UtcNow;
    }

    private static bool IsValidEmail(string email) =>
        email.Contains('@') && email.Contains('.');
}

De Order-entiteit kapselt zijn bedrijfsregels in en beschermt zijn interne staat. Wijzigingen moeten verlopen via bedrijfsmethoden die de invarianten valideren.

Domain/Entities/OrderItem.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Kindentiteit met eigen identiteit
public class OrderItem
{
    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; }
    public decimal UnitPrice { get; private set; }
    public int Quantity { get; private set; }

    // Berekening van de regeltotaalprijs
    public decimal TotalPrice => UnitPrice * Quantity;

    private OrderItem() { }

    // Factory method met validatie
    public static OrderItem Create(Order order, Product product, int quantity)
    {
        return new OrderItem
        {
            Id = Guid.NewGuid(),
            OrderId = order.Id,
            ProductId = product.Id,
            ProductName = product.Name,
            UnitPrice = product.Price,
            Quantity = quantity
        };
    }

    // Methode om de hoeveelheid te verhogen
    public void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
            throw new DomainException("De extra hoeveelheid moet positief zijn.");

        Quantity += additionalQuantity;
    }
}
Domain/Enums/OrderStatus.cscsharp
namespace CleanArchitecture.Domain.Enums;

// Opsomming van orderstatussen
public enum OrderStatus
{
    Pending = 0,      // Wacht op bevestiging
    Confirmed = 1,    // Bevestigd, klaar voor verzending
    Shipped = 2,      // Verzonden
    Delivered = 3,    // Geleverd
    Cancelled = 4     // Geannuleerd
}
Value Objects

Value Objects zoals Money, Address of Email kapselen bedrijfsbegrippen in zonder eigen identiteit. Hun gelijkheid is gebaseerd op waarden, niet op referenties. Deze aanpak versterkt de uitdrukkingskracht van het domein.

Repository-interfaces in de Domain

De repository-interfaces worden gedefinieerd in de Domain, maar hun implementaties bevinden zich in Infrastructure. Dit patroon respecteert het principe van dependency inversion.

Domain/Interfaces/IOrderRepository.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Interface van de Order-repository
public interface IOrderRepository
{
    // Opvragen op identifier
    Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    // Opvragen inclusief items
    Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);

    // Opvragen op klant-e-mail
    Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default);

    // Een nieuwe order toevoegen
    Task AddAsync(Order order, CancellationToken cancellationToken = default);

    // Een bestaande order bijwerken
    Task UpdateAsync(Order order, CancellationToken cancellationToken = default);

    // Een order verwijderen
    Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
Domain/Interfaces/IUnitOfWork.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Unit-of-Work-patroon voor transactioneel beheer
public interface IUnitOfWork : IDisposable
{
    // Repositories bereikbaar via UoW
    IOrderRepository Orders { get; }
    IProductRepository Products { get; }

    // Atomair opslaan van alle wijzigingen
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);

    // Expliciet transactiebeheer
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

Het Unit-of-Work-patroon coördineert bewerkingen over meerdere repositories binnen één transactie.

Klaar om je .NET gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

De Application-laag: orkestratie van Use Cases

De Application-laag bevat de applicatielogica (use cases), DTO's en interfaces van externe diensten. Deze laag orkestreert de interacties tussen het domein en de buitenwereld.

Application/Orders/Commands/CreateOrderCommand.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Commands;

// Command dat de intentie uitdrukt om een order aan te maken
public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderItemDto> Items
) : IRequest<Guid>;

// DTO voor de orderitems
public record OrderItemDto(
    Guid ProductId,
    int Quantity
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Commands;

// Handler die de use case implementeert
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Order-entiteit aanmaken via factory method
        var order = Order.Create(request.CustomerEmail);

        // Items aan de order toevoegen
        foreach (var item in request.Items)
        {
            // Product ophalen uit het repository
            var product = await _unitOfWork.Products
                .GetByIdAsync(item.ProductId, cancellationToken);

            if (product == null)
                throw new NotFoundException($"Product {item.ProductId} niet gevonden.");

            // Bedrijfsmethode van de entiteit gebruiken
            order.AddItem(product, item.Quantity);
        }

        // Persisteren via repository
        await _unitOfWork.Orders.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

MediatR implementeert het Mediator-patroon om handlers los te koppelen van controllers. Elk command heeft één handler die verantwoordelijk is voor de verwerking.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Queries;

// Query om een order op ID op te vragen
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// Antwoord-DTO voor een order
public record OrderDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    DateTime CreatedAt,
    DateTime? ShippedAt,
    List<OrderItemResponseDto> Items
);

// DTO voor items in het antwoord
public record OrderItemResponseDto(
    Guid Id,
    string ProductName,
    decimal UnitPrice,
    int Quantity,
    decimal TotalPrice
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Queries;

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

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

        if (order == null)
            return null;

        // Mappen naar DTO (handmatige projectie)
        return new OrderDto(
            Id: order.Id,
            CustomerEmail: order.CustomerEmail,
            Status: order.Status.ToString(),
            TotalAmount: order.TotalAmount,
            CreatedAt: order.CreatedAt,
            ShippedAt: order.ShippedAt,
            Items: order.Items.Select(i => new OrderItemResponseDto(
                Id: i.Id,
                ProductName: i.ProductName,
                UnitPrice: i.UnitPrice,
                Quantity: i.Quantity,
                TotalPrice: i.TotalPrice
            )).ToList()
        );
    }
}

De scheiding tussen Commands en Queries volgt het CQRS-patroon (Command Query Responsibility Segregation) en optimaliseert lees- en schrijfacties onafhankelijk van elkaar.

Validatie met FluentValidation

De validatie van commands gebeurt in de Application-laag, vóór de uitvoering van de handler.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;

namespace CleanArchitecture.Application.Orders.Validators;

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        // E-mail verplicht en geldig formaat
        RuleFor(x => x.CustomerEmail)
            .NotEmpty().WithMessage("Het e-mailadres van de klant is verplicht.")
            .EmailAddress().WithMessage("Ongeldig e-mailformaat.");

        // Minimaal één item verplicht
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("De order moet minstens één item bevatten.");

        // Elk item valideren
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId)
                .NotEmpty().WithMessage("De product-identifier is verplicht.");

            item.RuleFor(i => i.Quantity)
                .GreaterThan(0).WithMessage("De hoeveelheid moet positief zijn.")
                .LessThanOrEqualTo(100).WithMessage("Maximale hoeveelheid: 100.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
using FluentValidation;
using MediatR;

namespace CleanArchitecture.Application.Common.Behaviors;

// Pipeline behavior voor automatische validatie
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    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)
    {
        // Als er geen validators zijn, doorgaan
        if (!_validators.Any())
            return await next();

        // Alle validators uitvoeren
        var context = new ValidationContext<TRequest>(request);

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

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

        // Exception gooien als er fouten zijn
        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

Het ValidationBehavior wordt automatisch uitgevoerd vóór elke handler en zorgt ervoor dat alleen geldige commands de bedrijfslogica bereiken.

De Infrastructure-laag: technische implementatie

De Infrastructure-laag levert de concrete implementaties van de in Domain en Application gedefinieerde interfaces.

Infrastructure/Persistence/AppDbContext.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configuraties uit het assembly toepassen
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence.Configurations;

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        // Tabel en primary key
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        // Eigenschappen
        builder.Property(o => o.CustomerEmail)
            .IsRequired()
            .HasMaxLength(256);

        builder.Property(o => o.Status)
            .IsRequired()
            .HasConversion<string>();  // Opslag als leesbare string

        // Relatie met OrderItems
        builder.HasMany(o => o.Items)
            .WithOne()
            .HasForeignKey(i => i.OrderId)
            .OnDelete(DeleteBehavior.Cascade);

        // Index voor zoeken op e-mail
        builder.HasIndex(o => o.CustomerEmail);

        // Toegang tot het privéveld _items
        builder.Navigation(o => o.Items)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;

namespace CleanArchitecture.Infrastructure.Repositories;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<Order?> GetByIdWithItemsAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        // Eager loading van de items
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
    }

    public Task UpdateAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        // EF Core houdt wijzigingen automatisch bij
        _context.Orders.Update(order);
        return Task.CompletedTask;
    }

    public async Task DeleteAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders
            .Where(o => o.Id == id)
            .ExecuteDeleteAsync(cancellationToken);
    }
}
Pas op voor leaky abstractions

Repositories mogen IQueryable niet rechtstreeks blootstellen, omdat dat een afhankelijkheid van EF Core in de bovenliggende lagen creëert. Specifieke methoden met duidelijke parameters verdienen de voorkeur.

Implementatie van Unit of Work

Infrastructure/Persistence/UnitOfWork.cscsharp
using Microsoft.EntityFrameworkCore.Storage;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure.Persistence;

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    private IDbContextTransaction? _transaction;

    // Lazy loading van repositories
    private IOrderRepository? _orderRepository;
    private IProductRepository? _productRepository;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
    }

    // Aanmaak op aanvraag (lazy initialization)
    public IOrderRepository Orders =>
        _orderRepository ??= new OrderRepository(_context);

    public IProductRepository Products =>
        _productRepository ??= new ProductRepository(_context);

    public async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }

    public async Task BeginTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        _transaction = await _context.Database
            .BeginTransactionAsync(cancellationToken);
    }

    public async Task CommitTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Geen actieve transactie.");

        await _transaction.CommitAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public async Task RollbackTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Geen actieve transactie.");

        await _transaction.RollbackAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

Configuratie van Dependency Injection

De registratie van services gebeurt in elke laag via extension methods en wordt vervolgens georchestreerd in Program.cs.

Infrastructure/DependencyInjection.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Configuratie van Entity Framework
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection"),
                b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));

        // Unit of Work registreren (Scoped)
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        // Individuele repositories indien nodig
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
Application/DependencyInjection.cscsharp
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Application.Common.Behaviors;

namespace CleanArchitecture.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        var assembly = typeof(DependencyInjection).Assembly;

        // MediatR registreren met handlers
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));

        // FluentValidation-validators registreren
        services.AddValidatorsFromAssembly(assembly);

        // Pipeline behaviors (volgorde van uitvoering is belangrijk)
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

        return services;
    }
}
Api/Program.cscsharp
using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Lagen registreren
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

// API-configuratie
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

De Presentation-laag: API-controllers

Controllers zijn eenvoudige adapters die het werk delegeren aan MediatR.

Api/Controllers/OrdersController.cscsharp
using MediatR;
using Microsoft.AspNetCore.Mvc;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Application.Orders.Queries;

namespace CleanArchitecture.Api.Controllers;

[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(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        // Volledige delegatie aan MediatR
        var orderId = await _mediator.Send(command, cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = orderId },
            orderId);
    }

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

        if (order == null)
            return NotFound();

        return Ok(order);
    }

    [HttpPost("{id:guid}/confirm")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Confirm(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);
        return NoContent();
    }

    [HttpPost("{id:guid}/ship")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Ship(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ShipOrderCommand(id), cancellationToken);
        return NoContent();
    }
}

Controllers blijven dun en bevatten geen bedrijfslogica, enkel HTTP-mapping.

Unit tests van de Application-laag

Clean Architecture vergemakkelijkt unit tests dankzij de isolatie van afhankelijkheden.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
using Moq;
using Xunit;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Tests.Application;

public class CreateOrderCommandHandlerTests
{
    private readonly Mock<IUnitOfWork> _unitOfWorkMock;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _unitOfWorkMock = new Mock<IUnitOfWork>();
        _handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
    {
        // Arrange
        var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 };

        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        _unitOfWorkMock.Setup(x => x.Orders.AddAsync(
            It.IsAny<Order>(),
            It.IsAny<CancellationToken>()))
            .Returns(Task.CompletedTask);

        _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: product.Id, Quantity: 2)
            });

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotEqual(Guid.Empty, result);
        _unitOfWorkMock.Verify(x => x.Orders.AddAsync(
            It.Is<Order>(o => o.CustomerEmail == "test@example.com"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(x => x.SaveChangesAsync(
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_ProductNotFound_ThrowsNotFoundException()
    {
        // Arrange
        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: Guid.NewGuid(), Quantity: 1)
            });

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => _handler.Handle(command, CancellationToken.None));
    }
}

Met Moq-mocks kan de handler worden geïsoleerd van zijn afhankelijkheden, zodat enkel de orkestratielogica wordt getest.

Conclusie

Clean Architecture met .NET levert een robuuste structuur voor enterprise-applicaties. De strikte scheiding van verantwoordelijkheden tussen de Domain-, Application-, Infrastructure- en Presentation-lagen zorgt op lange termijn voor onderhoudbare en testbare code.

Checklist Clean Architecture .NET

  • ✅ Geïsoleerde Domain zonder externe afhankelijkheden
  • ✅ Entiteiten met ingekapselde bedrijfslogica
  • ✅ Repository-interfaces in de Domain
  • ✅ Use Cases via Commands/Queries (CQRS)
  • ✅ Validatie met FluentValidation en Pipeline Behaviors
  • ✅ Infrastructure implementeert de Domain-interfaces
  • ✅ Dunne controllers die delegeren aan MediatR
  • ✅ Geïsoleerde unit tests met mocks

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

De initiële investering in deze architectuur betaalt zich snel terug in middelgrote en grote projecten, waar wijzigingen in technische vereisten (databasemigratie, frameworkwissel) enkel de buitenste lagen raken en de bedrijfskern intact laten.

Tags

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Delen

Gerelateerde artikelen