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, 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.
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.
┌─────────────────────────────────────────────────────────┐
│ 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.
# 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.InfrastructureDeze 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.
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.
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;
}
}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 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.
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);
}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.
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
);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.
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
);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.
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.");
});
}
}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.
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);
}
}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);
}
}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);
}
}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
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.
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;
}
}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;
}
}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.
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.
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
Delen
Gerelateerde artikelen

C# en .NET Interviewvragen: Complete Gids 2026
De 25 meest voorkomende C# en .NET interviewvragen. LINQ, async/await, dependency injection, Entity Framework en best practices met gedetailleerde antwoorden.

.NET 8: Een API bouwen met ASP.NET Core
Volledige gids voor het bouwen van een professionele REST API met .NET 8 en ASP.NET Core. Controllers, Entity Framework Core, validatie en best practices uitgelegd.

Entity Framework Core: Prestatieoptimalisatie en Best Practices in 2026
Optimaliseer EF Core 10-prestaties met AsNoTracking, split queries, bulkbewerkingen, de nieuwe LeftJoin-operator en benoemde queryfilters. Praktische gids met C#-voorbeelden voor .NET 10-applicaties in productie.