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 Leitfaden mit .NET und C#

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.

Warum Clean Architecture?

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.

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)          │
└─────────────────────────────────────────────────────────┘

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.

bash
# 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.Infrastructure

Diese 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.

Domain/Entities/Order.cscsharp
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.

Domain/Entities/OrderItem.cscsharp
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;
    }
}
Domain/Enums/OrderStatus.cscsharp
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

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.

Domain/Interfaces/IOrderRepository.cscsharp
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);
}
Domain/Interfaces/IUnitOfWork.cscsharp
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.

Application/Orders/Commands/CreateOrderCommand.cscsharp
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
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
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.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
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
);
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)
    {
        // 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.

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 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.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
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.

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)
    {
        // Konfigurationen aus dem Assembly anwenden
        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)
    {
        // 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);
    }
}
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 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);
    }
}
Vorsicht vor Leaky Abstractions

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

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 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.

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)
    {
        // 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;
    }
}
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 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;
    }
}
Api/Program.cscsharp
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.

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)
    {
        // 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.

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));
    }
}

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

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

Teilen

Verwandte Artikel