Enterprise Architektur

Moderne Architektur mit .NET und Domain-Driven Design

DDD in .NET pragmatisch umgesetzt: Bounded Contexts, Clean Architecture und CQRS als Werkzeug gegen wuchernde Legacy-Logik – mit Code-Beispielen für Enterprise-Projekte.

📖 16 Min. Lesezeit
.NET Domain-Driven Design Architecture Clean Architecture DDD CQRS Enterprise .NET Bounded Context Aggregate

Moderne Architektur mit .NET und Domain-Driven Design

Ein gewachsenes .NET-System, in dem die Business-Logik über Controller, Services und Stored Procedures verstreut liegt – und jede kleine fachliche Änderung drei Teams blockiert. Genau dort setzt die Kombination aus .NET und Domain-Driven Design (DDD) an: Sie trennt Fachlogik sauber von Infrastruktur, macht Grenzen explizit und holt Fachexperten zurück in den Code. Dieser Artikel zeigt, wie Sie DDD pragmatisch in .NET umsetzen – Kernprinzipien, Projektstruktur, Bibliotheken und realistische Grenzen.

Für wen dieser Artikel relevant ist: CTOs, Tech-Leads und Software-Architekten, die ein gewachsenes .NET-System modernisieren oder eine fachlich komplexe Neuentwicklung sauber aufsetzen wollen. Weniger relevant für reine CRUD-Anwendungen oder frühe MVPs.

Warum Domain-Driven Design – und wann nicht

Legacy-.NET-Systeme altern meist nicht an der Technik, sondern an der Struktur: verstreute Business-Regeln, anämische Datenmodelle, unklare Fachsprache, wachsende technische Schulden. DDD ist kein Framework, sondern ein methodischer Ansatz, der die fachliche Domäne ins Zentrum rückt und damit genau diese Probleme adressiert.

Typische Schmerzpunkte in gewachsenen .NET-Systemen

Verstreute Geschäftslogik: Business-Regeln liegen in Controllern, Services und Datenzugriffsschichten – oft inkonsistent dupliziert. Eine fachliche Änderung erfordert Anpassungen an mehreren Stellen.

Anämische Modelle: Datenbankzentriertes Design macht Domänenobjekte zu reinen Datencontainern. Die Logik wandert in Service-Klassen, die zu "God Objects" wachsen.

Fehlende gemeinsame Sprache: Der Fachbereich spricht von "Bestellung", im Code stehen "Order", "Transaction", "Purchase" – mit unterschiedlichen Bedeutungen. Jede fachliche Diskussion braucht Übersetzung.

Technische Schulden: Ohne klare architektonische Leitlinien wachsen technische Schulden, bis jede Änderung teuer und riskant wird.

Was DDD konkret beiträgt

Domain-Driven Design – 2003 von Eric Evans systematisiert – bietet vier Hebel, die direkt auf diese Schmerzpunkte einzahlen:

  1. Fachliche Domäne im Zentrum: Die Software spiegelt die Geschäftsdomäne, nicht technische Infrastruktur.
  2. Ubiquitous Language: Entwickler und Fachexperten verwenden identische Begriffe – im Meeting, in der Doku, im Code.
  3. Strategisches + taktisches Design: Makro-Patterns (Bounded Contexts, Context Mapping) für Systemgrenzen, Mikro-Patterns (Entities, Value Objects, Aggregates) für die Implementierung.
  4. Explizite Verantwortlichkeiten: Jedes Element hat eine klare Rolle – Voraussetzung für Testbarkeit und Wartbarkeit.

Modernes .NET passt gut dazu: Records für Value Objects, Pattern Matching für Business Rules, Nullable Reference Types für sicherere Domain-Modelle, dazu ein ausgereiftes NuGet-Ökosystem.

Kernprinzipien von Domain-Driven Design

Domain-Driven Design basiert auf mehreren fundamentalen Konzepten, die zusammen eine kohärente Architektur bilden. Diese Prinzipien sind nicht nur theoretische Konstrukte, sondern praktische Werkzeuge zur Bewältigung von Komplexität.

Ubiquitous Language: Die allgegenwärtige Sprache

Die Ubiquitous Language ist das Fundament von DDD. Sie bezeichnet eine gemeinsame Fachsprache, die von allen Projektbeteiligten – Entwicklern, Domänenexperten, Product Ownern – konsistent verwendet wird.

Praktische Umsetzung:

// Schlechtes Beispiel: Technische Begriffe ohne Domänenbezug
public class OrderProcessor
{
    public void ProcessData(Dictionary<string, object> data)
    {
        // Unklare Geschäftslogik
    }
}

// Gutes Beispiel: Ubiquitous Language im Code
public class OrderPlacementService
{
    public OrderConfirmation PlaceOrder(
        CustomerId customerId,
        ShoppingCart cart,
        ShippingAddress deliveryAddress,
        PaymentMethod paymentMethod)
    {
        // Die Methode spricht die Sprache der Fachdomäne
        var order = Order.CreateNew(customerId, deliveryAddress);

        foreach (var item in cart.Items)
        {
            order.AddLineItem(item.Product, item.Quantity);
        }

        order.ApplyPricing(_pricingService);
        order.SetPaymentMethod(paymentMethod);

        return order.Confirm();
    }
}

Die Begriffe "Order", "Customer", "ShoppingCart", "ShippingAddress" stammen direkt aus der Fachdomäne und werden in Meetings, Dokumentation und Code identisch verwendet.

Bounded Context: Kontextgrenzen definieren

Ein Bounded Context ist ein konzeptioneller Rahmen, innerhalb dessen ein bestimmtes Domänenmodell gültig ist. Große Systeme werden in mehrere Bounded Contexts aufgeteilt, die jeweils ihre eigene Ubiquitous Language haben können.

Warum Bounded Contexts wichtig sind:

In einem E-Commerce-System hat das Wort "Produkt" unterschiedliche Bedeutungen:

  • Im Katalog-Context: Ein Produkt hat Beschreibungen, Bilder, SEO-Informationen
  • Im Lager-Context: Ein Produkt hat Lagerbestände, Lagerplätze, Nachbestellmengen
  • Im Bestellungs-Context: Ein Produkt hat einen Preis, Rabatte, Verfügbarkeit

Statt zu versuchen, ein "Universalprodukt" zu modellieren, das alle Aspekte abdeckt, definiert DDD separate Modelle in verschiedenen Contexts:

// Katalog Bounded Context
namespace Catalog.Domain
{
    public class Product
    {
        public ProductId Id { get; private set; }
        public ProductName Name { get; private set; }
        public ProductDescription Description { get; private set; }
        public List<ProductImage> Images { get; private set; }
        public SEOMetadata SEO { get; private set; }

        // Katalog-spezifisches Verhalten
        public void UpdateDescription(ProductDescription newDescription) { }
        public void AddImage(ProductImage image) { }
    }
}

// Lager Bounded Context
namespace Warehouse.Domain
{
    public class Product
    {
        public ProductId Id { get; private set; }
        public WarehouseLocation Location { get; private set; }
        public StockLevel CurrentStock { get; private set; }
        public ReorderThreshold ReorderPoint { get; private set; }

        // Lager-spezifisches Verhalten
        public void AdjustStock(int quantity, StockMovementReason reason) { }
        public bool IsRestockNeeded() => CurrentStock.Quantity < ReorderPoint.Value;
    }
}

// Bestellungs Bounded Context
namespace Orders.Domain
{
    public class OrderItem
    {
        public ProductId ProductId { get; private set; }
        public ProductSnapshot ProductSnapshot { get; private set; } // Snapshot zum Bestellzeitpunkt
        public Money UnitPrice { get; private set; }
        public Quantity Quantity { get; private set; }

        public Money TotalPrice => UnitPrice.Multiply(Quantity.Value);
    }
}

Jeder Context hat sein eigenes Modell, optimiert für seinen spezifischen Anwendungsfall. Die Kommunikation zwischen Contexts erfolgt über definierte Schnittstellen (Context Maps).

Entities: Objekte mit Identität

Entities sind Objekte, die über eine eindeutige Identität verfügen und über ihren gesamten Lebenszyklus hinweg verfolgt werden, selbst wenn sich ihre Attribute ändern.

Charakteristika von Entities:

  • Besitzen eine eindeutige ID
  • Ihre Identität bleibt über die Zeit konstant
  • Gleichheit basiert auf der ID, nicht auf Attributwerten
  • Haben einen Lebenszyklus mit verschiedenen Zuständen

Implementierung in .NET:

public abstract class Entity<TId> where TId : notnull
{
    public TId Id { get; protected set; }

    protected Entity(TId id)
    {
        Id = id;
    }

    public override bool Equals(object? obj)
    {
        if (obj is not Entity<TId> other)
            return false;

        if (ReferenceEquals(this, other))
            return true;

        return Id.Equals(other.Id);
    }

    public override int GetHashCode() => Id.GetHashCode();

    public static bool operator ==(Entity<TId>? left, Entity<TId>? right)
        => left?.Equals(right) ?? right is null;

    public static bool operator !=(Entity<TId>? left, Entity<TId>? right)
        => !(left == right);
}

// Konkrete Entity
public class Order : Entity<OrderId>
{
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money TotalAmount { get; private set; }
    public DateTime OrderDate { get; private set; }

    private readonly List<OrderLine> _orderLines = new();
    public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();

    private Order(OrderId id, CustomerId customerId) : base(id)
    {
        CustomerId = customerId;
        Status = OrderStatus.Draft;
        OrderDate = DateTime.UtcNow;
    }

    public static Order CreateNew(CustomerId customerId)
    {
        var orderId = OrderId.CreateNew();
        return new Order(orderId, customerId);
    }

    public void AddLineItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify confirmed order");

        var lineItem = OrderLine.Create(product, quantity);
        _orderLines.Add(lineItem);
        RecalculateTotal();
    }

    public OrderConfirmation Confirm()
    {
        if (!_orderLines.Any())
            throw new InvalidOperationException("Cannot confirm empty order");

        Status = OrderStatus.Confirmed;
        return new OrderConfirmation(Id, DateTime.UtcNow);
    }

    private void RecalculateTotal()
    {
        TotalAmount = _orderLines
            .Select(line => line.TotalPrice)
            .Aggregate(Money.Zero, (sum, price) => sum.Add(price));
    }
}

Value Objects: Wertobjekte ohne Identität

Value Objects sind unveränderliche Objekte, die durch ihre Attribute definiert werden. Sie besitzen keine eigene Identität – zwei Value Objects mit identischen Attributen sind austauschbar.

Charakteristika von Value Objects:

  • Unveränderlich (immutable)
  • Gleichheit basiert auf Attributwerten
  • Keine eigene Identität
  • Können Geschäftslogik kapseln

Implementierung mit C# Records:

// Basis-Record für Value Objects
public abstract record ValueObject;

// Money Value Object
public record Money : ValueObject
{
    public decimal Amount { get; init; }
    public Currency Currency { get; init; }

    public static readonly Money Zero = new(0, Currency.EUR);

    public Money(decimal amount, Currency currency)
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative", nameof(amount));

        Amount = amount;
        Currency = currency;
    }

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

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

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

    public static Money operator +(Money left, Money right)
        => left.Add(right);

    public static Money operator *(Money money, decimal factor)
        => money.Multiply(factor);
}

// Email Value Object mit Validierung
public record Email : ValueObject
{
    private static readonly Regex EmailRegex = new(
        @"^[^@\s]+@[^@\s]+\.[^@\s]+$",
        RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public string Value { get; init; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Email cannot be empty", nameof(value));

        if (!EmailRegex.IsMatch(value))
            throw new ArgumentException("Invalid email format", nameof(value));

        Value = value.ToLowerInvariant();
    }

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

// Address Value Object
public record Address : ValueObject
{
    public string Street { get; init; }
    public string City { get; init; }
    public string PostalCode { get; init; }
    public Country Country { get; init; }

    public Address(string street, string city, string postalCode, Country country)
    {
        Street = street ?? throw new ArgumentNullException(nameof(street));
        City = city ?? throw new ArgumentNullException(nameof(city));
        PostalCode = postalCode ?? throw new ArgumentNullException(nameof(postalCode));
        Country = country ?? throw new ArgumentNullException(nameof(country));
    }

    public string GetFormattedAddress()
        => $"{Street}, {PostalCode} {City}, {Country.Name}";
}

Die Verwendung von Records in C# macht die Implementierung von Value Objects besonders elegant, da Immutability und wertbasierte Gleichheit automatisch implementiert werden.

Aggregates: Konsistenzgrenzen definieren

Ein Aggregate ist ein Cluster von Entities und Value Objects, die als eine Einheit behandelt werden. Das Aggregate definiert eine Konsistenzgrenze und wird über eine Aggregate Root (eine spezielle Entity) nach außen zugänglich gemacht.

Regeln für Aggregates:

  1. Externe Zugriffe erfolgen nur über die Aggregate Root
  2. Interne Objekte haben keine direkten Referenzen von außen
  3. Transaktionen ändern jeweils nur ein Aggregate
  4. Zwischen Aggregates werden nur IDs referenziert

Implementierung eines Order-Aggregates:

// Die Order ist die Aggregate Root
public class Order : Entity<OrderId>, IAggregateRoot
{
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money TotalAmount { get; private set; }
    public ShippingAddress ShippingAddress { get; private set; }
    public PaymentMethod PaymentMethod { get; private set; }

    // OrderLines sind Teil des Aggregates und nicht direkt zugänglich
    private readonly List<OrderLine> _orderLines = new();
    public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();

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

    private Order(OrderId id, CustomerId customerId, ShippingAddress shippingAddress)
        : base(id)
    {
        CustomerId = customerId;
        ShippingAddress = shippingAddress;
        Status = OrderStatus.Draft;
        OrderDate = DateTime.UtcNow;
        TotalAmount = Money.Zero;
    }

    public static Order CreateNew(CustomerId customerId, ShippingAddress shippingAddress)
    {
        var order = new Order(OrderId.CreateNew(), customerId, shippingAddress);
        order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId));
        return order;
    }

    // Alle Änderungen am Aggregate erfolgen über Methoden der Root
    public void AddLineItem(ProductId productId, string productName, Money unitPrice, int quantity)
    {
        GuardAgainstConfirmedOrder();

        var existingLine = _orderLines.FirstOrDefault(l => l.ProductId == productId);
        if (existingLine != null)
        {
            existingLine.IncreaseQuantity(quantity);
        }
        else
        {
            var lineItem = new OrderLine(
                OrderLineId.CreateNew(),
                productId,
                productName,
                unitPrice,
                quantity);
            _orderLines.Add(lineItem);
        }

        RecalculateTotal();
        AddDomainEvent(new OrderLineAddedEvent(Id, productId, quantity));
    }

    public void RemoveLineItem(OrderLineId lineItemId)
    {
        GuardAgainstConfirmedOrder();

        var lineItem = _orderLines.FirstOrDefault(l => l.Id == lineItemId);
        if (lineItem != null)
        {
            _orderLines.Remove(lineItem);
            RecalculateTotal();
            AddDomainEvent(new OrderLineRemovedEvent(Id, lineItemId));
        }
    }

    public void SetPaymentMethod(PaymentMethod paymentMethod)
    {
        GuardAgainstConfirmedOrder();
        PaymentMethod = paymentMethod;
    }

    public Result<OrderConfirmation> Confirm()
    {
        if (!_orderLines.Any())
            return Result<OrderConfirmation>.Failure("Cannot confirm empty order");

        if (PaymentMethod == null)
            return Result<OrderConfirmation>.Failure("Payment method must be set");

        Status = OrderStatus.Confirmed;
        var confirmation = new OrderConfirmation(Id, DateTime.UtcNow, TotalAmount);

        AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, TotalAmount));

        return Result<OrderConfirmation>.Success(confirmation);
    }

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

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

    private void RecalculateTotal()
    {
        TotalAmount = _orderLines
            .Select(line => line.TotalPrice)
            .Aggregate(Money.Zero, (sum, price) => sum + price);
    }

    private void GuardAgainstConfirmedOrder()
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException(
                $"Cannot modify order in status {Status}");
    }

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

    public void ClearDomainEvents() => _domainEvents.Clear();
}

// OrderLine ist Teil des Order-Aggregates und hat package-private Zugriff
internal class OrderLine : Entity<OrderLineId>
{
    public ProductId ProductId { get; private set; }
    public string ProductName { get; private set; }
    public Money UnitPrice { get; private set; }
    public int Quantity { get; private set; }
    public Money TotalPrice => UnitPrice * Quantity;

    internal OrderLine(
        OrderLineId id,
        ProductId productId,
        string productName,
        Money unitPrice,
        int quantity) : base(id)
    {
        ProductId = productId;
        ProductName = productName;
        UnitPrice = unitPrice;
        SetQuantity(quantity);
    }

    internal void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
            throw new ArgumentException("Additional quantity must be positive");

        Quantity += additionalQuantity;
    }

    private void SetQuantity(int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");

        Quantity = quantity;
    }
}

Domain Events: Ereignisse in der Domäne

Domain Events sind Ereignisse, die etwas Bedeutsames in der Domäne repräsentieren. Sie ermöglichen lose Kopplung zwischen Aggregates und unterstützen ereignisgesteuerte Architekturen.

// Basis-Interface für Domain Events
public interface IDomainEvent
{
    Guid EventId { get; }
    DateTime OccurredOn { get; }
}

// Konkrete Domain Events
public record OrderConfirmedEvent : IDomainEvent
{
    public Guid EventId { get; init; } = Guid.NewGuid();
    public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
    public OrderId OrderId { get; init; }
    public CustomerId CustomerId { get; init; }
    public Money TotalAmount { get; init; }

    public OrderConfirmedEvent(OrderId orderId, CustomerId customerId, Money totalAmount)
    {
        OrderId = orderId;
        CustomerId = customerId;
        TotalAmount = totalAmount;
    }
}

public record OrderShippedEvent : IDomainEvent
{
    public Guid EventId { get; init; } = Guid.NewGuid();
    public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
    public OrderId OrderId { get; init; }
    public TrackingNumber TrackingNumber { get; init; }

    public OrderShippedEvent(OrderId orderId, TrackingNumber trackingNumber)
    {
        OrderId = orderId;
        TrackingNumber = trackingNumber;
    }
}

Architektur mit .NET und DDD

Die Umsetzung von Domain-Driven Design erfordert eine passende Architektur, die die Trennung von Domänenlogik, Anwendungslogik und Infrastruktur unterstützt. In der .NET-Welt haben sich mehrere architektonische Muster etabliert.

Clean Architecture / Onion Architecture

Die Clean Architecture (auch als Onion Architecture oder Hexagonal Architecture bekannt) organisiert den Code in konzentrische Schichten, wobei die Domäne im Zentrum steht und alle Abhängigkeiten nach innen zeigen.

Schichten der Clean Architecture:

┌─────────────────────────────────────────────────┐
│           Presentation Layer (API)              │
│  - Controllers, ViewModels, DTOs                │
└────────────────┬────────────────────────────────┘
                 │ depends on ↓
┌─────────────────────────────────────────────────┐
│          Application Layer                      │
│  - Use Cases, Commands, Queries                 │
│  - Application Services, Validators             │
│  - Interfaces (Ports)                           │
└────────────────┬────────────────────────────────┘
                 │ depends on ↓
┌─────────────────────────────────────────────────┐
│           Domain Layer (Core)                   │
│  - Entities, Value Objects, Aggregates          │
│  - Domain Services, Domain Events               │
│  - Domain Exceptions                            │
└─────────────────────────────────────────────────┘
                 ↑ depended on by
┌─────────────────────────────────────────────────┐
│         Infrastructure Layer                    │
│  - Data Access (EF Core, Repositories)          │
│  - External Services, File System               │
│  - Authentication, Logging                      │
└─────────────────────────────────────────────────┘

Projektstruktur in .NET:

Solution: OnlineShop
│
├── src/
│   ├── OnlineShop.Domain/
│   │   ├── Orders/
│   │   │   ├── Order.cs (Aggregate Root)
│   │   │   ├── OrderLine.cs (Entity)
│   │   │   ├── OrderStatus.cs (Enum)
│   │   │   └── Events/
│   │   │       ├── OrderConfirmedEvent.cs
│   │   │       └── OrderShippedEvent.cs
│   │   ├── Customers/
│   │   ├── Products/
│   │   ├── Common/
│   │   │   ├── Entity.cs
│   │   │   ├── ValueObject.cs
│   │   │   └── IAggregateRoot.cs
│   │   └── Exceptions/
│   │
│   ├── OnlineShop.Application/
│   │   ├── Orders/
│   │   │   ├── Commands/
│   │   │   │   ├── PlaceOrder/
│   │   │   │   │   ├── PlaceOrderCommand.cs
│   │   │   │   │   ├── PlaceOrderCommandHandler.cs
│   │   │   │   │   └── PlaceOrderCommandValidator.cs
│   │   │   │   └── ConfirmOrder/
│   │   │   ├── Queries/
│   │   │   │   └── GetOrderDetails/
│   │   │   ├── DTOs/
│   │   │   └── EventHandlers/
│   │   ├── Common/
│   │   │   ├── Interfaces/
│   │   │   │   ├── IOrderRepository.cs
│   │   │   │   └── IUnitOfWork.cs
│   │   │   ├── Behaviors/
│   │   │   └── Mappings/
│   │   └── DependencyInjection.cs
│   │
│   ├── OnlineShop.Infrastructure/
│   │   ├── Persistence/
│   │   │   ├── ApplicationDbContext.cs
│   │   │   ├── Configurations/
│   │   │   │   ├── OrderConfiguration.cs
│   │   │   │   └── CustomerConfiguration.cs
│   │   │   └── Repositories/
│   │   │       ├── OrderRepository.cs
│   │   │       └── CustomerRepository.cs
│   │   ├── ExternalServices/
│   │   ├── Authentication/
│   │   └── DependencyInjection.cs
│   │
│   └── OnlineShop.API/
│       ├── Controllers/
│       │   ├── OrdersController.cs
│       │   └── CustomersController.cs
│       ├── Middleware/
│       ├── Filters/
│       └── Program.cs
│
└── tests/
    ├── OnlineShop.Domain.Tests/
    ├── OnlineShop.Application.Tests/
    └── OnlineShop.API.Tests/

Domain Layer: Das Herz der Anwendung

Die Domain-Schicht enthält die gesamte Geschäftslogik und kennt keine technischen Details über Datenbanken, APIs oder UI.

// Domain/Orders/Order.cs
namespace OnlineShop.Domain.Orders
{
    public class Order : Entity<OrderId>, IAggregateRoot
    {
        // Alle Geschäftslogik ist hier gekapselt
        public Result Confirm()
        {
            if (!_orderLines.Any())
                return Result.Failure("ORDER_EMPTY", "Cannot confirm empty order");

            if (TotalAmount.Amount < MinimumOrderAmount.Amount)
                return Result.Failure(
                    "ORDER_MINIMUM_NOT_MET",
                    $"Order must be at least {MinimumOrderAmount}");

            Status = OrderStatus.Confirmed;
            AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, TotalAmount));

            return Result.Success();
        }
    }
}

Application Layer: Use Cases orchestrieren

Die Application-Schicht orchestriert Use Cases, koordiniert Domänenlogik und steuert Transaktionen. Sie verwendet das CQRS-Pattern (Command Query Responsibility Segregation).

// Application/Orders/Commands/PlaceOrder/PlaceOrderCommand.cs
public record PlaceOrderCommand : IRequest<Result<OrderDto>>
{
    public Guid CustomerId { get; init; }
    public List<OrderLineDto> OrderLines { get; init; } = new();
    public AddressDto ShippingAddress { get; init; } = null!;
    public PaymentMethodDto PaymentMethod { get; init; } = null!;
}

// Application/Orders/Commands/PlaceOrder/PlaceOrderCommandHandler.cs
public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Result<OrderDto>>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IPricingService _pricingService;

    public PlaceOrderCommandHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        IUnitOfWork unitOfWork,
        IPricingService pricingService)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
        _pricingService = pricingService;
    }

    public async Task<Result<OrderDto>> Handle(
        PlaceOrderCommand request,
        CancellationToken cancellationToken)
    {
        // 1. Validiere Eingabe (bereits durch FluentValidation erfolgt)
        var customerId = new CustomerId(request.CustomerId);
        var shippingAddress = MapToShippingAddress(request.ShippingAddress);

        // 2. Erstelle Order Aggregate
        var order = Order.CreateNew(customerId, shippingAddress);

        // 3. Füge Produkte hinzu
        foreach (var lineDto in request.OrderLines)
        {
            var product = await _productRepository.GetByIdAsync(
                new ProductId(lineDto.ProductId),
                cancellationToken);

            if (product == null)
                return Result<OrderDto>.Failure(
                    "PRODUCT_NOT_FOUND",
                    $"Product {lineDto.ProductId} not found");

            var price = _pricingService.CalculatePrice(
                product,
                lineDto.Quantity);

            order.AddLineItem(
                product.Id,
                product.Name,
                price,
                lineDto.Quantity);
        }

        // 4. Setze Zahlungsmethode
        var paymentMethod = MapToPaymentMethod(request.PaymentMethod);
        order.SetPaymentMethod(paymentMethod);

        // 5. Bestätige Order
        var confirmResult = order.Confirm();
        if (confirmResult.IsFailure)
            return Result<OrderDto>.Failure(confirmResult.Error);

        // 6. Speichere in Datenbank
        await _orderRepository.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        // 7. Mappe zu DTO
        var orderDto = MapToOrderDto(order);
        return Result<OrderDto>.Success(orderDto);
    }

    private ShippingAddress MapToShippingAddress(AddressDto dto)
        => new(dto.Street, dto.City, dto.PostalCode, new Country(dto.Country));

    private PaymentMethod MapToPaymentMethod(PaymentMethodDto dto)
        => dto.Type switch
        {
            "CreditCard" => PaymentMethod.CreditCard(dto.CardNumber, dto.CardHolder),
            "PayPal" => PaymentMethod.PayPal(dto.PayPalEmail),
            "Invoice" => PaymentMethod.Invoice(),
            _ => throw new ArgumentException($"Unknown payment method: {dto.Type}")
        };

    private OrderDto MapToOrderDto(Order order)
        => new()
        {
            OrderId = order.Id.Value,
            Status = order.Status.ToString(),
            TotalAmount = order.TotalAmount.Amount,
            OrderDate = order.OrderDate
        };
}

Infrastructure Layer: Technische Details

Die Infrastructure-Schicht implementiert die Interfaces aus der Application-Schicht und enthält alle technischen Details.

// Infrastructure/Persistence/Repositories/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;

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

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

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

    public void Update(Order order)
    {
        _context.Orders.Update(order);
    }
}

// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");

        builder.HasKey(o => o.Id);

        builder.Property(o => o.Id)
            .HasConversion(
                id => id.Value,
                value => new OrderId(value));

        builder.Property(o => o.CustomerId)
            .HasConversion(
                id => id.Value,
                value => new CustomerId(value))
            .IsRequired();

        builder.OwnsOne(o => o.TotalAmount, money =>
        {
            money.Property(m => m.Amount)
                .HasColumnName("TotalAmount")
                .HasPrecision(18, 2);

            money.Property(m => m.Currency)
                .HasColumnName("Currency")
                .HasConversion<string>();
        });

        builder.OwnsOne(o => o.ShippingAddress, address =>
        {
            address.Property(a => a.Street).HasMaxLength(200);
            address.Property(a => a.City).HasMaxLength(100);
            address.Property(a => a.PostalCode).HasMaxLength(20);
        });

        builder.HasMany<OrderLine>()
            .WithOne()
            .HasForeignKey("OrderId")
            .OnDelete(DeleteBehavior.Cascade);

        builder.Property(o => o.Status)
            .HasConversion<string>()
            .IsRequired();

        // Domain Events werden nicht persistiert
        builder.Ignore(o => o.DomainEvents);
    }
}

API Layer: Externe Schnittstelle

Die API-Schicht stellt HTTP-Endpunkte bereit und delegiert an die Application-Schicht.

// API/Controllers/OrdersController.cs
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

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

    [HttpPost]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> PlaceOrder(
        [FromBody] PlaceOrderCommand command,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(command, cancellationToken);

        if (result.IsFailure)
            return BadRequest(new ProblemDetails
            {
                Title = "Order placement failed",
                Detail = result.Error,
                Status = StatusCodes.Status400BadRequest
            });

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

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

        if (result.IsFailure)
            return NotFound();

        return Ok(result.Value);
    }

    [HttpPost("{orderId:guid}/confirm")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> ConfirmOrder(
        Guid orderId,
        CancellationToken cancellationToken)
    {
        var command = new ConfirmOrderCommand { OrderId = orderId };
        var result = await _mediator.Send(command, cancellationToken);

        if (result.IsFailure)
            return BadRequest(new ProblemDetails
            {
                Title = "Order confirmation failed",
                Detail = result.Error,
                Status = StatusCodes.Status400BadRequest
            });

        return Ok();
    }
}

Moderne NuGet-Pakete für DDD in .NET

Das .NET-Ökosystem bietet hervorragende Bibliotheken, die die Implementierung von DDD-Patterns erheblich vereinfachen. Diese Pakete sind ausgereift, gut dokumentiert und werden aktiv gewartet.

MediatR: CQRS und Mediator Pattern

MediatR implementiert das Mediator-Pattern und erleichtert die Umsetzung von CQRS (Command Query Responsibility Segregation).

Installation:

dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

Verwendung:

// Commands und Queries
public record PlaceOrderCommand : IRequest<Result<OrderDto>>
{
    public Guid CustomerId { get; init; }
    public List<OrderLineDto> OrderLines { get; init; } = new();
}

public record GetOrderDetailsQuery : IRequest<Result<OrderDto>>
{
    public Guid OrderId { get; init; }
}

// Handler
public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Result<OrderDto>>
{
    public async Task<Result<OrderDto>> Handle(
        PlaceOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Implementation
    }
}

// Pipeline Behaviors für Cross-Cutting Concerns
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

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

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

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

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

        return await next();
    }
}

// Logging Behavior
public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

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

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Handling {RequestName}", requestName);

        var stopwatch = Stopwatch.StartNew();
        var response = await next();
        stopwatch.Stop();

        _logger.LogInformation(
            "Handled {RequestName} in {ElapsedMilliseconds}ms",
            requestName,
            stopwatch.ElapsedMilliseconds);

        return response;
    }
}

// Registration in Program.cs
builder.Services.AddMediatR(cfg => {
    cfg.RegisterServicesFromAssembly(typeof(PlaceOrderCommand).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
});

FluentValidation: Deklarative Validierung

FluentValidation bietet eine fluent API für Validierungsregeln und integriert sich nahtlos mit MediatR.

Installation:

dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions

Verwendung:

// Validator für PlaceOrderCommand
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
    public PlaceOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty()
            .WithMessage("Customer ID is required");

        RuleFor(x => x.OrderLines)
            .NotEmpty()
            .WithMessage("Order must contain at least one item");

        RuleForEach(x => x.OrderLines)
            .SetValidator(new OrderLineDtoValidator());

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

        RuleFor(x => x.PaymentMethod)
            .NotNull()
            .WithMessage("Payment method is required")
            .SetValidator(new PaymentMethodDtoValidator());
    }
}

public class OrderLineDtoValidator : AbstractValidator<OrderLineDto>
{
    public OrderLineDtoValidator()
    {
        RuleFor(x => x.ProductId)
            .NotEmpty()
            .WithMessage("Product ID is required");

        RuleFor(x => x.Quantity)
            .GreaterThan(0)
            .WithMessage("Quantity must be positive")
            .LessThanOrEqualTo(100)
            .WithMessage("Quantity cannot exceed 100 per line item");
    }
}

public class AddressDtoValidator : AbstractValidator<AddressDto>
{
    public AddressDtoValidator()
    {
        RuleFor(x => x.Street)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.City)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.PostalCode)
            .NotEmpty()
            .Matches(@"^\d{5}$")
            .When(x => x.Country == "DE")
            .WithMessage("German postal code must be 5 digits");

        RuleFor(x => x.Country)
            .NotEmpty()
            .Length(2)
            .WithMessage("Country must be a valid ISO 3166-1 alpha-2 code");
    }
}

// Komplexere Validierungen mit Custom Rules
public class EmailValidator : AbstractValidator<string>
{
    public EmailValidator()
    {
        RuleFor(email => email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(BeUniqueEmail)
            .WithMessage("Email is already in use");
    }

    private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
    {
        // Prüfe Datenbank auf Eindeutigkeit
        return await Task.FromResult(true); // Implementierung
    }
}

// Registration
builder.Services.AddValidatorsFromAssembly(typeof(PlaceOrderCommandValidator).Assembly);

Ardalis.Specification: Repository Pattern verbessert

Ardalis.Specification erweitert das Repository-Pattern um typsichere Spezifikationen, die wiederverwendbare Abfragen ermöglichen.

Installation:

dotnet add package Ardalis.Specification
dotnet add package Ardalis.Specification.EntityFrameworkCore

Verwendung:

// Basis Repository Interface
public interface IRepository<T> : IRepositoryBase<T> where T : class, IAggregateRoot
{
}

public interface IReadRepository<T> : IReadRepositoryBase<T> where T : class, IAggregateRoot
{
}

// Spezifikationen definieren
public class OrdersByCustomerSpec : Specification<Order>
{
    public OrdersByCustomerSpec(CustomerId customerId)
    {
        Query
            .Where(order => order.CustomerId == customerId)
            .OrderByDescending(order => order.OrderDate)
            .Include(order => order.OrderLines);
    }
}

public class OrdersWithStatusSpec : Specification<Order>
{
    public OrdersWithStatusSpec(OrderStatus status, int page, int pageSize)
    {
        Query
            .Where(order => order.Status == status)
            .OrderByDescending(order => order.OrderDate)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Include(order => order.OrderLines)
            .AsNoTracking();
    }
}

public class RecentOrdersSpec : Specification<Order>
{
    public RecentOrdersSpec(DateTime since)
    {
        Query
            .Where(order => order.OrderDate >= since)
            .OrderByDescending(order => order.OrderDate);
    }
}

// Kombinierte Spezifikationen
public class HighValueOrdersSpec : Specification<Order>
{
    public HighValueOrdersSpec(decimal minimumAmount)
    {
        Query
            .Where(order => order.TotalAmount.Amount >= minimumAmount)
            .Include(order => order.OrderLines)
            .ThenInclude(line => line.Product);
    }
}

// Verwendung in Application Layer
public class GetCustomerOrdersQueryHandler
    : IRequestHandler<GetCustomerOrdersQuery, Result<List<OrderDto>>>
{
    private readonly IReadRepository<Order> _orderRepository;

    public GetCustomerOrdersQueryHandler(IReadRepository<Order> orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<Result<List<OrderDto>>> Handle(
        GetCustomerOrdersQuery request,
        CancellationToken cancellationToken)
    {
        var customerId = new CustomerId(request.CustomerId);
        var spec = new OrdersByCustomerSpec(customerId);

        var orders = await _orderRepository.ListAsync(spec, cancellationToken);

        var orderDtos = orders.Select(MapToDto).ToList();
        return Result<List<OrderDto>>.Success(orderDtos);
    }
}

// Repository Implementation
public class EfRepository<T> : RepositoryBase<T>, IRepository<T>
    where T : class, IAggregateRoot
{
    public EfRepository(ApplicationDbContext dbContext) : base(dbContext)
    {
    }
}

// Registration
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
builder.Services.AddScoped(typeof(IReadRepository<>), typeof(EfRepository<>));

AutoMapper: Object-Object Mapping

AutoMapper automatisiert das Mapping zwischen Domain Entities und DTOs.

Installation:

dotnet add package AutoMapper
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

Verwendung:

// Mapping Profiles
public class OrderMappingProfile : Profile
{
    public OrderMappingProfile()
    {
        // Order -> OrderDto
        CreateMap<Order, OrderDto>()
            .ForMember(dest => dest.OrderId, opt => opt.MapFrom(src => src.Id.Value))
            .ForMember(dest => dest.CustomerId, opt => opt.MapFrom(src => src.CustomerId.Value))
            .ForMember(dest => dest.TotalAmount, opt => opt.MapFrom(src => src.TotalAmount.Amount))
            .ForMember(dest => dest.Currency, opt => opt.MapFrom(src => src.TotalAmount.Currency.ToString()))
            .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString()))
            .ForMember(dest => dest.OrderLines, opt => opt.MapFrom(src => src.OrderLines));

        // OrderLine -> OrderLineDto
        CreateMap<OrderLine, OrderLineDto>()
            .ForMember(dest => dest.ProductId, opt => opt.MapFrom(src => src.ProductId.Value))
            .ForMember(dest => dest.UnitPrice, opt => opt.MapFrom(src => src.UnitPrice.Amount))
            .ForMember(dest => dest.TotalPrice, opt => opt.MapFrom(src => src.TotalPrice.Amount));

        // Address -> AddressDto
        CreateMap<Address, AddressDto>()
            .ForMember(dest => dest.Country, opt => opt.MapFrom(src => src.Country.Code));

        // Reverse Mappings
        CreateMap<OrderLineDto, OrderLine>()
            .ConstructUsing(dto => new OrderLine(
                new OrderLineId(Guid.NewGuid()),
                new ProductId(dto.ProductId),
                dto.ProductName,
                new Money(dto.UnitPrice, Currency.EUR),
                dto.Quantity));
    }
}

public class CustomerMappingProfile : Profile
{
    public CustomerMappingProfile()
    {
        CreateMap<Customer, CustomerDto>()
            .ForMember(dest => dest.CustomerId, opt => opt.MapFrom(src => src.Id.Value))
            .ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email.Value))
            .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name.GetFullName()));

        CreateMap<CreateCustomerCommand, Customer>()
            .ConstructUsing(cmd => Customer.Create(
                new Email(cmd.Email),
                new PersonName(cmd.FirstName, cmd.LastName)));
    }
}

// Verwendung in Handlers
public class GetOrderDetailsQueryHandler
    : IRequestHandler<GetOrderDetailsQuery, Result<OrderDto>>
{
    private readonly IReadRepository<Order> _orderRepository;
    private readonly IMapper _mapper;

    public GetOrderDetailsQueryHandler(
        IReadRepository<Order> orderRepository,
        IMapper mapper)
    {
        _orderRepository = orderRepository;
        _mapper = mapper;
    }

    public async Task<Result<OrderDto>> Handle(
        GetOrderDetailsQuery request,
        CancellationToken cancellationToken)
    {
        var orderId = new OrderId(request.OrderId);
        var order = await _orderRepository.GetByIdAsync(orderId, cancellationToken);

        if (order == null)
            return Result<OrderDto>.Failure("ORDER_NOT_FOUND", "Order not found");

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

// Registration
builder.Services.AddAutoMapper(typeof(OrderMappingProfile).Assembly);

Scrutor: Assembly Scanning für Dependency Injection

Scrutor vereinfacht die Registrierung von Services durch automatisches Assembly-Scanning.

Installation:

dotnet add package Scrutor

Verwendung:

// Automatische Registrierung aller Command Handlers
builder.Services.Scan(scan => scan
    .FromAssemblyOf<PlaceOrderCommandHandler>()
    .AddClasses(classes => classes.AssignableTo(typeof(IRequestHandler<,>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Automatische Registrierung aller Validators
builder.Services.Scan(scan => scan
    .FromAssemblyOf<PlaceOrderCommandValidator>()
    .AddClasses(classes => classes.AssignableTo(typeof(IValidator<>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Automatische Registrierung aller Repositories
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderRepository>()
    .AddClasses(classes => classes.AssignableTo(typeof(IRepository<>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Decorator Pattern für Cross-Cutting Concerns
public interface IOrderRepository : IRepository<Order>
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken cancellationToken = default);
}

public class OrderRepository : IOrderRepository
{
    // Implementation
}

// Caching Decorator
public class CachedOrderRepository : IOrderRepository
{
    private readonly IOrderRepository _innerRepository;
    private readonly IMemoryCache _cache;

    public CachedOrderRepository(
        IOrderRepository innerRepository,
        IMemoryCache cache)
    {
        _innerRepository = innerRepository;
        _cache = cache;
    }

    public async Task<Order?> GetByIdAsync(
        OrderId id,
        CancellationToken cancellationToken = default)
    {
        var cacheKey = $"order_{id.Value}";

        if (_cache.TryGetValue<Order>(cacheKey, out var cachedOrder))
            return cachedOrder;

        var order = await _innerRepository.GetByIdAsync(id, cancellationToken);

        if (order != null)
        {
            _cache.Set(cacheKey, order, TimeSpan.FromMinutes(5));
        }

        return order;
    }

    // Delegate other methods to inner repository
}

// Registration with Decorator
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.Decorate<IOrderRepository, CachedOrderRepository>();

Vorteile der modernen DDD-Architektur

Die Kombination von Domain-Driven Design mit modernen .NET-Tools und Bibliotheken bietet erhebliche Vorteile für die Softwareentwicklung.

1. Wartbarkeit und Verständlichkeit

Klare Struktur: Die Aufteilung in Schichten mit eindeutigen Verantwortlichkeiten macht den Code leicht verständlich. Neue Entwickler können sich schnell orientieren, da jede Komponente einen klar definierten Platz hat.

Ubiquitous Language: Code, der die Fachsprache spricht, ist selbstdokumentierend. Statt kryptischer technischer Begriffe finden sich Konzepte aus der Geschäftsdomäne.

// Selbsterklärender Code durch DDD
var order = Order.CreateNew(customerId, shippingAddress);
order.AddLineItem(product, quantity);
order.ApplyDiscount(customerLoyaltyDiscount);
var confirmation = order.Confirm();

// vs. traditioneller Ansatz
var order = new Order();
order.CustomerId = customerId;
order.Status = 1; // Was bedeutet 1?
SaveOrder(order);

2. Testbarkeit

Isolierte Domain-Logik: Da die Domain-Schicht keine Abhängigkeiten zu Infrastruktur hat, lässt sie sich ohne Datenbank oder externe Services testen.

[Fact]
public void Order_CannotBeConfirmed_WhenEmpty()
{
    // Arrange
    var customerId = new CustomerId(Guid.NewGuid());
    var address = TestData.CreateValidAddress();
    var order = Order.CreateNew(customerId, address);

    // Act
    var result = order.Confirm();

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Equal("ORDER_EMPTY", result.ErrorCode);
}

[Fact]
public void Order_CalculatesTotalCorrectly_WithMultipleItems()
{
    // Arrange
    var order = TestData.CreateOrderWithItems(
        (TestData.Product1, 2),
        (TestData.Product2, 3));

    // Act
    var total = order.TotalAmount;

    // Assert
    Assert.Equal(250.00m, total.Amount);
}

Mocking ist einfach: Interfaces aus der Application-Schicht lassen sich leicht mocken.

[Fact]
public async Task PlaceOrder_ReturnsFailure_WhenProductNotFound()
{
    // Arrange
    var mockProductRepo = new Mock<IProductRepository>();
    mockProductRepo
        .Setup(r => r.GetByIdAsync(It.IsAny<ProductId>(), It.IsAny<CancellationToken>()))
        .ReturnsAsync((Product?)null);

    var handler = new PlaceOrderCommandHandler(
        Mock.Of<IOrderRepository>(),
        mockProductRepo.Object,
        Mock.Of<IUnitOfWork>(),
        Mock.Of<IPricingService>());

    var command = new PlaceOrderCommand
    {
        CustomerId = Guid.NewGuid(),
        OrderLines = new List<OrderLineDto>
        {
            new() { ProductId = Guid.NewGuid(), Quantity = 1 }
        }
    };

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

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Equal("PRODUCT_NOT_FOUND", result.ErrorCode);
}

3. Flexibilität und Erweiterbarkeit

Open-Closed Principle: Die Architektur ist offen für Erweiterungen, aber geschlossen für Modifikationen. Neue Features werden hinzugefügt, ohne bestehenden Code zu ändern.

// Neue Payment-Methode hinzufügen, ohne bestehenden Code zu ändern
public abstract class PaymentMethod : ValueObject
{
    public abstract PaymentMethodType Type { get; }

    public static CreditCardPayment CreditCard(string cardNumber, string cardHolder)
        => new(cardNumber, cardHolder);

    public static PayPalPayment PayPal(string email)
        => new(email);

    // Neue Methode hinzufügen
    public static CryptoPayment Crypto(string walletAddress, CryptoCurrency currency)
        => new(walletAddress, currency);
}

public record CryptoPayment : PaymentMethod
{
    public string WalletAddress { get; init; }
    public CryptoCurrency Currency { get; init; }
    public override PaymentMethodType Type => PaymentMethodType.Cryptocurrency;

    public CryptoPayment(string walletAddress, CryptoCurrency currency)
    {
        WalletAddress = walletAddress;
        Currency = currency;
    }
}

Bounded Contexts: Die Aufteilung in Bounded Contexts ermöglicht es, verschiedene Teile des Systems unabhängig zu entwickeln und zu deployen.

4. Skalierbarkeit

Vertikale Skalierung: Die klare Trennung von Lese- und Schreiboperationen (CQRS) ermöglicht unterschiedliche Optimierungsstrategien.

Horizontale Skalierung: Bounded Contexts können als separate Microservices deployed werden.

Event-Driven Architecture: Domain Events ermöglichen asynchrone Kommunikation und eventuelle Konsistenz.

// Event Handler für asynchrone Verarbeitung
public class OrderConfirmedEventHandler : INotificationHandler<OrderConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;
    private readonly IAnalyticsService _analyticsService;

    public async Task Handle(
        OrderConfirmedEvent notification,
        CancellationToken cancellationToken)
    {
        // Diese Operationen laufen asynchron
        await Task.WhenAll(
            _emailService.SendOrderConfirmationAsync(notification.OrderId),
            _inventoryService.ReserveStockAsync(notification.OrderId),
            _analyticsService.TrackOrderAsync(notification.OrderId));
    }
}

5. Technologie-Unabhängigkeit

Infrastructure-Austausch: Die Infrastruktur kann ausgetauscht werden, ohne die Domain-Logik zu beeinflussen.

// Von SQL zu NoSQL wechseln
public class MongoDbOrderRepository : IOrderRepository
{
    private readonly IMongoCollection<Order> _collection;

    // Implementierung mit MongoDB statt SQL Server
}

// Registration ändern
if (useMongoDb)
    builder.Services.AddScoped<IOrderRepository, MongoDbOrderRepository>();
else
    builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

Herausforderungen und Best Practices

Trotz aller Vorteile bringt DDD auch Herausforderungen mit sich. Die folgenden Best Practices helfen, häufige Fallstricke zu vermeiden.

Herausforderung 1: Over-Engineering

Problem: DDD wird flächendeckend angewandt, auch dort, wo es keinen Mehrwert liefert – typisch bei einfachen CRUD-Teilen.

Empfehlung: Trennen Sie Core Domain von Supporting/Generic Subdomains. Nur die Core Domain bekommt volle DDD-Behandlung.

// Core Domain: Volle DDD-Behandlung
public class Order : Entity<OrderId>, IAggregateRoot
{
    // Komplexe Geschäftslogik
}

// Supporting Subdomain: Vereinfachtes Modell
public class AuditLog
{
    public int Id { get; set; }
    public string Action { get; set; }
    public DateTime Timestamp { get; set; }
    // Einfaches CRUD-Modell
}

Empfehlung: Identifizieren Sie die Core Domain (Kerngeschäft) und konzentrieren Sie DDD-Investments dort. Supporting und Generic Subdomains können einfacher modelliert werden.

Herausforderung 2: Bounded Context Grenzen

Problem: Die Definition von Bounded Context Grenzen ist schwierig und oft Gegenstand von Diskussionen.

Best Practice: Beginnen Sie mit größeren Contexts und verfeinern Sie später durch Splitting.

// Anfangs: Ein großer Context
namespace ECommerce.Domain
{
    public class Order { }
    public class Product { }
    public class Customer { }
    public class Inventory { }
}

// Später: Aufgeteilte Contexts
namespace ECommerce.Orders.Domain
{
    public class Order { }
    public class OrderItem { } // Nur Order-relevante Produktinfo
}

namespace ECommerce.Catalog.Domain
{
    public class Product { }
    public class ProductVariant { }
}

namespace ECommerce.Inventory.Domain
{
    public class StockItem { }
    public class WarehouseLocation { }
}

Empfehlung: Nutzen Sie Event Storming-Workshops mit Domänenexperten, um natürliche Kontextgrenzen zu identifizieren.

Herausforderung 3: Anämische Domain-Modelle

Problem: Entwickler erstellen "anämische" Modelle mit Gettern/Settern ohne Verhalten, wodurch die Geschäftslogik in Service-Klassen wandert.

Best Practice: Kapseln Sie Verhalten in den Entities.

// Schlecht: Anämisches Modell
public class Order
{
    public Guid Id { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; }
    public List<OrderLine> Lines { get; set; }
}

public class OrderService
{
    public void AddItemToOrder(Order order, Product product, int quantity)
    {
        var line = new OrderLine
        {
            ProductId = product.Id,
            Quantity = quantity,
            Price = product.Price * quantity
        };
        order.Lines.Add(line);
        order.Total += line.Price;
    }
}

// Gut: Reiches Domain-Modell
public class Order : Entity<OrderId>
{
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    public Money Total { get; private set; }
    public OrderStatus Status { get; private set; }

    public void AddLineItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify confirmed order");

        var existingLine = _lines.FirstOrDefault(l => l.ProductId == product.Id);
        if (existingLine != null)
        {
            existingLine.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(OrderLine.Create(product, quantity));
        }

        RecalculateTotal();
    }

    private void RecalculateTotal()
    {
        Total = _lines
            .Select(l => l.TotalPrice)
            .Aggregate(Money.Zero, (sum, price) => sum + price);
    }
}

Herausforderung 4: Performance und Datenbank-Zugriff

Problem: Aggregate-Grenzen und das Laden vollständiger Aggregates können Performance-Probleme verursachen.

Best Practice: Verwenden Sie Read Models für Queries und Aggregates nur für Commands.

// Command: Lädt vollständiges Aggregate
public class ConfirmOrderCommandHandler : IRequestHandler<ConfirmOrderCommand, Result>
{
    public async Task<Result> Handle(ConfirmOrderCommand request, CancellationToken ct)
    {
        var order = await _orderRepository.GetByIdAsync(
            new OrderId(request.OrderId),
            ct);

        // Vollständiges Aggregate mit allen OrderLines
        var result = order.Confirm();
        await _unitOfWork.SaveChangesAsync(ct);

        return result;
    }
}

// Query: Verwendet projektiertes Read Model
public class GetOrderDetailsQueryHandler
    : IRequestHandler<GetOrderDetailsQuery, OrderDetailsDto>
{
    private readonly IDbConnection _connection;

    public async Task<OrderDetailsDto> Handle(
        GetOrderDetailsQuery request,
        CancellationToken ct)
    {
        // Direkte SQL-Abfrage mit nur benötigten Feldern
        const string sql = @"
            SELECT
                o.Id, o.OrderDate, o.Status, o.TotalAmount,
                c.Name as CustomerName,
                ol.ProductName, ol.Quantity, ol.UnitPrice
            FROM Orders o
            INNER JOIN Customers c ON o.CustomerId = c.Id
            LEFT JOIN OrderLines ol ON o.Id = ol.OrderId
            WHERE o.Id = @OrderId";

        var result = await _connection.QueryAsync<OrderDetailsDto>(
            sql,
            new { OrderId = request.OrderId });

        return result.FirstOrDefault();
    }
}

Empfehlung: Verwenden Sie Dapper oder EF Core Direct SQL für performante Read-Operationen, Aggregates für Write-Operationen.

Herausforderung 5: Aggregate-Transaktionen

Problem: Transaktionen über mehrere Aggregates hinweg widersprechen DDD-Prinzipien.

Best Practice: Verwenden Sie Domain Events und eventuelle Konsistenz.

// Schlecht: Transaktion über mehrere Aggregates
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
    using var transaction = await _dbContext.Database.BeginTransactionAsync();

    var order = Order.CreateNew(cmd.CustomerId, cmd.Address);
    var customer = await _customerRepository.GetByIdAsync(cmd.CustomerId);
    var inventory = await _inventoryRepository.GetByProductIdAsync(cmd.ProductId);

    order.AddItem(cmd.ProductId, cmd.Quantity);
    customer.IncreaseLoyaltyPoints(10);
    inventory.Reserve(cmd.Quantity);

    await _orderRepository.SaveAsync(order);
    await _customerRepository.SaveAsync(customer);
    await _inventoryRepository.SaveAsync(inventory);

    await transaction.CommitAsync();
}

// Gut: Eventuelle Konsistenz mit Domain Events
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
    var order = Order.CreateNew(cmd.CustomerId, cmd.Address);
    order.AddItem(cmd.ProductId, cmd.Quantity);
    order.Confirm(); // Erzeugt OrderConfirmedEvent

    await _orderRepository.SaveAsync(order);
    await _unitOfWork.SaveChangesAsync(); // Publiziert Events
}

// Event Handler kümmern sich um andere Aggregates
public class OrderConfirmedEventHandler : INotificationHandler<OrderConfirmedEvent>
{
    public async Task Handle(OrderConfirmedEvent evt, CancellationToken ct)
    {
        // Asynchrone Updates anderer Aggregates
        var customer = await _customerRepository.GetByIdAsync(evt.CustomerId);
        customer.IncreaseLoyaltyPoints(10);
        await _customerRepository.SaveAsync(customer);

        var inventory = await _inventoryService.ReserveStock(
            evt.OrderId,
            evt.OrderLines);
    }
}

Best Practices Zusammenfassung

  1. Start Simple: Beginnen Sie mit einer einfachen Struktur und refactoren Sie zu DDD, wenn Komplexität steigt
  2. Focus on Core: Investieren Sie DDD-Effort in die Core Domain, nicht überall
  3. Embrace CQRS: Trennen Sie Lese- und Schreiboperationen für bessere Performance
  4. Use Events: Kommunizieren Sie zwischen Aggregates via Domain Events
  5. Value Immutability: Nutzen Sie C# Records für Value Objects
  6. Test First: Schreiben Sie Tests für Domain-Logik ohne Infrastruktur-Dependencies
  7. Iterate: DDD ist iterativ – erwarten Sie nicht, die perfekte Struktur beim ersten Versuch zu finden

Weiterführende Artikel:

Fazit: DDD dort, wo es sich rechnet

DDD ist kein Selbstzweck – es ist ein Werkzeug, um fachliche Komplexität beherrschbar zu machen. Setzen Sie es gezielt dort ein, wo Ihre Core Domain liegt, und halten Sie Supporting- und Generic-Subdomains bewusst schlank.

DDD ist die richtige Wahl, wenn:

  • die Geschäftsdomäne komplex ist und viele Regeln enthält
  • das System über Jahre weiterentwickelt wird
  • Entwickler und Fachexperten regelmäßig zusammenarbeiten
  • das System skalieren und sich fachlich verändern muss

Verzichten Sie auf DDD, wenn:

  • es sich um reine CRUD-Anwendungen handelt
  • Requirements noch unklar sind (Prototyp, MVP)
  • die Geschäftslogik trivial bleibt
  • das Projekt eine kurze Lebensdauer hat

Konkrete nächste Schritte, um DDD in .NET einzuführen:

  1. Core Domain identifizieren – wo liegt der fachliche Wettbewerbsvorteil?
  2. Ein Event-Storming-Workshop mit Fachexperten durchführen, um Bounded Contexts sichtbar zu machen.
  3. Einen Pilot-Kontext in Clean Architecture aufsetzen (Domain / Application / Infrastructure / API).
  4. MediatR, FluentValidation, Ardalis.Specification und EF Core sauber verdrahten – CQRS-ready.
  5. Anämische Modelle schrittweise durch reiche Aggregate ersetzen; Tests zuerst auf der Domain-Schicht.

Der größte Hebel kommt selten aus Perfektion im Pattern-Einsatz, sondern aus der engen Zusammenarbeit zwischen Entwicklern und Fachexperten. DDD hilft, diese Zusammenarbeit strukturell im Code zu verankern.

Nächste Schritte

← Zurück zu allen Publikationen