Clean Architecture mit .NET: Praktischer Leitfaden
Clean Architecture in .NET mit C# beherrschen. SOLID-Prinzipien, Schichtentrennung und Implementierungsmuster für wartbare Anwendungen kennenlernen.

Clean Architecture, popularisiert durch Robert C. Martin (Uncle Bob), organisiert den Code so, dass die Geschäftslogik im Zentrum der Anwendung steht – unabhängig von Frameworks und Implementierungsdetails. Dieser architektonische Ansatz sichert Testbarkeit, Wartbarkeit und Skalierbarkeit von .NET-Anwendungen. Dieser Leitfaden zeigt eine praktische Umsetzung mit ASP.NET Core.
Anwendungen, die Geschäftslogik mit Infrastrukturcode vermischen, werden schnell schwer zu warten. Clean Architecture erzwingt eine strikte Trennung, die es ermöglicht, technische Details ohne Auswirkung auf den fachlichen Kern zu ändern.
Grundprinzipien der Clean Architecture
Clean Architecture stützt sich auf die Dependency Inversion: Innere Schichten kennen die äußeren Schichten nicht. Die Geschäftsdomäne bleibt isoliert und kann sich unabhängig von technischen Entscheidungen wie dem Webframework oder der Datenbank weiterentwickeln.
┌─────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────┘Abhängigkeiten zeigen stets nach innen: Presentation → Infrastructure → Application → Domain. Die Domain referenziert keine anderen Projekte.
Projektstruktur in .NET für Clean Architecture
Die Projektorganisation spiegelt die verschiedenen Schichten wider. Jede Schicht entspricht einem eigenen Projekt in der Visual-Studio-Solution und sorgt so für eine physische Trennung der Verantwortlichkeiten.
# terminal
# Solution-Struktur erstellen
dotnet new sln -n CleanArchitecture
# Projekte je Schicht erstellen
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
# Projekte zur Solution hinzufügen
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api
# Projektreferenzen konfigurieren
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain
cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application
cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.InfrastructureDiese Struktur garantiert, dass die Domain unabhängig bleibt und die Abhängigkeiten der Dependency Rule der Clean Architecture folgen.
Die Domain-Schicht: der fachliche Kern
Die Domain-Schicht enthält die Geschäftsentitäten, Value Objects und Repository-Schnittstellen. In dieser Schicht sind keine externen Abhängigkeiten erlaubt.
namespace CleanArchitecture.Domain.Entities;
// Entität mit Identität und fachlichem Lebenszyklus
public class Order
{
// Eindeutiger Bestell-Identifier
public Guid Id { get; private set; }
// Kundenreferenz (Value Object für die E-Mail)
public string CustomerEmail { get; private set; }
// Item-Sammlung (One-to-Many-Beziehung)
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
// Bestellstatus (fachliches Enum)
public OrderStatus Status { get; private set; }
// Berechneter Gesamtbetrag
public decimal TotalAmount => _items.Sum(i => i.TotalPrice);
// Tracking-Daten
public DateTime CreatedAt { get; private set; }
public DateTime? ShippedAt { get; private set; }
// Privater Konstruktor erzwingt die Nutzung der Factory-Methode
private Order() { }
// Factory-Methode zum Erstellen einer gültigen Bestellung
public static Order Create(string customerEmail)
{
// Validierung der Geschäftsregeln bei der Erstellung
if (string.IsNullOrWhiteSpace(customerEmail))
throw new DomainException("Die Kunden-E-Mail ist erforderlich.");
if (!IsValidEmail(customerEmail))
throw new DomainException("Ungültiges E-Mail-Format.");
return new Order
{
Id = Guid.NewGuid(),
CustomerEmail = customerEmail,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
}
// Geschäftsmethode: ein Item hinzufügen
public void AddItem(Product product, int quantity)
{
// Geschäftsregel: eine versendete Bestellung darf nicht geändert werden
if (Status == OrderStatus.Shipped)
throw new DomainException("Eine versendete Bestellung kann nicht geändert werden.");
if (quantity <= 0)
throw new DomainException("Die Menge muss positiv sein.");
// Prüfen, ob das Produkt bereits enthalten ist
var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(OrderItem.Create(this, product, quantity));
}
}
// Geschäftsmethode: Bestellung bestätigen
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new DomainException("Nur ausstehende Bestellungen können bestätigt werden.");
if (!_items.Any())
throw new DomainException("Eine Bestellung muss mindestens ein Item enthalten.");
Status = OrderStatus.Confirmed;
}
// Geschäftsmethode: Bestellung versenden
public void Ship()
{
if (Status != OrderStatus.Confirmed)
throw new DomainException("Die Bestellung muss vor dem Versand bestätigt werden.");
Status = OrderStatus.Shipped;
ShippedAt = DateTime.UtcNow;
}
private static bool IsValidEmail(string email) =>
email.Contains('@') && email.Contains('.');
}Die Order-Entität kapselt ihre Geschäftsregeln und schützt ihren internen Zustand. Änderungen müssen über fachliche Methoden erfolgen, die die Invarianten validieren.
namespace CleanArchitecture.Domain.Entities;
// Kindentität mit eigener Identität
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; }
// Berechnung des Zeilengesamtpreises
public decimal TotalPrice => UnitPrice * Quantity;
private OrderItem() { }
// Factory-Methode mit Validierung
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 zur Mengenerhöhung
public void IncreaseQuantity(int additionalQuantity)
{
if (additionalQuantity <= 0)
throw new DomainException("Die zusätzliche Menge muss positiv sein.");
Quantity += additionalQuantity;
}
}namespace CleanArchitecture.Domain.Enums;
// Aufzählung der Bestellstatus
public enum OrderStatus
{
Pending = 0, // Wartet auf Bestätigung
Confirmed = 1, // Bestätigt, bereit für Versand
Shipped = 2, // Versendet
Delivered = 3, // Zugestellt
Cancelled = 4 // Storniert
}Value Objects wie Money, Address oder Email kapseln Geschäftskonzepte ohne eigene Identität. Ihre Gleichheit basiert auf Werten, nicht auf Referenzen. Dieser Ansatz stärkt die Ausdruckskraft der Domain.
Repository-Schnittstellen in der Domain
Die Repository-Schnittstellen werden in der Domain definiert, ihre Implementierungen liegen jedoch in der Infrastructure. Dieses Muster respektiert das Prinzip der Dependency Inversion.
namespace CleanArchitecture.Domain.Interfaces;
// Schnittstelle des Order-Repositorys
public interface IOrderRepository
{
// Abruf nach Identifier
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
// Abruf inklusive Items
Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);
// Abruf nach Kunden-E-Mail
Task<IEnumerable<Order>> GetByCustomerEmailAsync(
string email,
CancellationToken cancellationToken = default);
// Neue Bestellung hinzufügen
Task AddAsync(Order order, CancellationToken cancellationToken = default);
// Bestehende Bestellung aktualisieren
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
// Bestellung löschen
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}namespace CleanArchitecture.Domain.Interfaces;
// Unit-of-Work-Pattern für transaktionales Management
public interface IUnitOfWork : IDisposable
{
// Über UoW erreichbare Repositories
IOrderRepository Orders { get; }
IProductRepository Products { get; }
// Atomares Speichern aller Änderungen
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
// Explizites Transaktionsmanagement
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}Das Unit-of-Work-Pattern koordiniert Operationen über mehrere Repositories innerhalb einer einzigen Transaktion.
Bereit für deine .NET-Interviews?
Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.
Die Application-Schicht: Orchestrierung der Use Cases
Die Application-Schicht enthält die Anwendungslogik (Use Cases), DTOs und Schnittstellen externer Dienste. Sie orchestriert die Interaktionen zwischen der Domain und der Außenwelt.
using MediatR;
namespace CleanArchitecture.Application.Orders.Commands;
// Command, das die Absicht abbildet, eine Bestellung zu erstellen
public record CreateOrderCommand(
string CustomerEmail,
List<OrderItemDto> Items
) : IRequest<Guid>;
// DTO für die Bestellpositionen
public record OrderItemDto(
Guid ProductId,
int Quantity
);using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Application.Orders.Commands;
// Handler, der den Use Case implementiert
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-Entität über Factory-Methode erzeugen
var order = Order.Create(request.CustomerEmail);
// Items zur Bestellung hinzufügen
foreach (var item in request.Items)
{
// Produkt aus dem Repository abrufen
var product = await _unitOfWork.Products
.GetByIdAsync(item.ProductId, cancellationToken);
if (product == null)
throw new NotFoundException($"Produkt {item.ProductId} nicht gefunden.");
// Geschäftsmethode der Entität verwenden
order.AddItem(product, item.Quantity);
}
// Persistieren über das Repository
await _unitOfWork.Orders.AddAsync(order, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return order.Id;
}
}MediatR setzt das Mediator-Pattern um, um Handler von Controllern zu entkoppeln. Jedes Command hat genau einen Handler, der für seine Verarbeitung verantwortlich ist.
using MediatR;
namespace CleanArchitecture.Application.Orders.Queries;
// Query zum Abrufen einer Bestellung anhand der ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;
// Antwort-DTO für eine Bestellung
public record OrderDto(
Guid Id,
string CustomerEmail,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
DateTime? ShippedAt,
List<OrderItemResponseDto> Items
);
// DTO für Items in der Antwort
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)
{
// Mit Items abrufen
var order = await _orderRepository
.GetByIdWithItemsAsync(request.OrderId, cancellationToken);
if (order == null)
return null;
// Mapping auf DTO (manuelle Projektion)
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()
);
}
}Die Trennung von Commands und Queries folgt dem CQRS-Muster (Command Query Responsibility Segregation) und optimiert Lese- und Schreiboperationen unabhängig voneinander.
Validierung mit FluentValidation
Die Validierung der Commands erfolgt in der Application-Schicht, vor der Ausführung des Handlers.
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;
namespace CleanArchitecture.Application.Orders.Validators;
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
// E-Mail erforderlich und gültiges Format
RuleFor(x => x.CustomerEmail)
.NotEmpty().WithMessage("Die Kunden-E-Mail ist erforderlich.")
.EmailAddress().WithMessage("Ungültiges E-Mail-Format.");
// Mindestens ein Item erforderlich
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Die Bestellung muss mindestens ein Item enthalten.");
// Jedes Item validieren
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId)
.NotEmpty().WithMessage("Der Produkt-Identifier ist erforderlich.");
item.RuleFor(i => i.Quantity)
.GreaterThan(0).WithMessage("Die Menge muss positiv sein.")
.LessThanOrEqualTo(100).WithMessage("Maximale Menge: 100.");
});
}
}using FluentValidation;
using MediatR;
namespace CleanArchitecture.Application.Common.Behaviors;
// Pipeline-Behavior für automatische Validierung
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)
{
// Wenn keine Validatoren vorhanden, fortfahren
if (!_validators.Any())
return await next();
// Alle Validatoren ausführen
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
// Fehler aggregieren
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
// Exception werfen, falls Fehler vorliegen
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}Das ValidationBehavior wird automatisch vor jedem Handler ausgeführt und stellt sicher, dass nur gültige Commands die Geschäftslogik erreichen.
Die Infrastructure-Schicht: technische Implementierung
Die Infrastructure-Schicht liefert die konkreten Implementierungen der in Domain und Application definierten Schnittstellen.
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)
{
// Konfigurationen aus dem Assembly anwenden
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)
{
// Tabelle und Primärschlüssel
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
// Eigenschaften
builder.Property(o => o.CustomerEmail)
.IsRequired()
.HasMaxLength(256);
builder.Property(o => o.Status)
.IsRequired()
.HasConversion<string>(); // Als lesbarer String speichern
// Beziehung zu OrderItems
builder.HasMany(o => o.Items)
.WithOne()
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
// Index für die E-Mail-Suche
builder.HasIndex(o => o.CustomerEmail);
// Zugriff auf das private Feld _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 der 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 verfolgt Änderungen automatisch
_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 sollten IQueryable nicht direkt nach außen reichen, da dadurch eine Abhängigkeit zu EF Core in den oberen Schichten entstünde. Spezifische Methoden mit klaren Parametern sind vorzuziehen.
Implementierung des 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 der Repositories
private IOrderRepository? _orderRepository;
private IProductRepository? _productRepository;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
// Erzeugung bei Bedarf (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("Keine aktive Transaktion.");
await _transaction.CommitAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
public async Task RollbackTransactionAsync(
CancellationToken cancellationToken = default)
{
if (_transaction == null)
throw new InvalidOperationException("Keine aktive Transaktion.");
await _transaction.RollbackAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}Konfiguration der Dependency Injection
Die Service-Registrierung erfolgt in jeder Schicht über Extension-Methoden und wird anschließend in Program.cs orchestriert.
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)
{
// Entity-Framework-Konfiguration
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
// Unit of Work registrieren (Scoped)
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Einzelne Repositories bei Bedarf
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 mit Handlern registrieren
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
// FluentValidation-Validatoren registrieren
services.AddValidatorsFromAssembly(assembly);
// Pipeline-Behaviors (Reihenfolge ist relevant)
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}
}using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Schichten registrieren
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// API-Konfiguration
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();Die Presentation-Schicht: API-Controller
Controller sind schlanke Adapter, die die Arbeit an MediatR delegieren.
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)
{
// Vollständige Delegation an 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();
}
}Controller bleiben schlank und enthalten keine Geschäftslogik, sondern lediglich das HTTP-Mapping.
Unit-Tests der Application-Schicht
Clean Architecture erleichtert Unit-Tests durch die Isolation der Abhängigkeiten.
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));
}
}Moq-Mocks ermöglichen es, den Handler von seinen Abhängigkeiten zu isolieren und ausschließlich die Orchestrierungslogik zu testen.
Fazit
Clean Architecture mit .NET liefert eine robuste Struktur für Enterprise-Anwendungen. Die strikte Trennung der Verantwortlichkeiten zwischen Domain-, Application-, Infrastructure- und Presentation-Schicht sorgt langfristig für wartbaren und testbaren Code.
Checkliste Clean Architecture .NET
- ✅ Isolierte Domain ohne externe Abhängigkeiten
- ✅ Entitäten mit gekapselter Geschäftslogik
- ✅ Repository-Schnittstellen in der Domain
- ✅ Use Cases via Commands/Queries (CQRS)
- ✅ Validierung mit FluentValidation und Pipeline-Behaviors
- ✅ Infrastructure implementiert die Domain-Schnittstellen
- ✅ Schlanke Controller, die an MediatR delegieren
- ✅ Isolierte Unit-Tests mit Mocks
Fang an zu üben!
Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.
Die anfängliche Investition in diese Architektur amortisiert sich bei mittleren bis großen Projekten schnell: Änderungen technischer Anforderungen (Datenbankmigration, Framework-Wechsel) betreffen nur die äußeren Schichten und lassen den fachlichen Kern unangetastet.
Tags
Teilen
Verwandte Artikel

C# und .NET Interview-Fragen: Vollstaendiger Leitfaden 2026
Die 17 haeufigsten C#- und .NET-Interviewfragen. LINQ, async/await, Dependency Injection, Entity Framework Core und Best Practices mit ausfuehrlichen Antworten.

.NET 8: Eine API mit ASP.NET Core erstellen
Vollstaendiger Leitfaden zur Erstellung einer professionellen REST-API mit .NET 8 und ASP.NET Core. Controller, Entity Framework Core, Validierung und Best Practices erklaert.

Entity Framework Core: Performance-Optimierung und Best Practices 2026
EF Core 10 Performance-Optimierung mit AsNoTracking, Split Queries, Massenoperationen, dem neuen LeftJoin-Operator und benannten Query-Filtern. Praxisleitfaden fuer .NET-10-Anwendungen in Produktion.