Moderne Architektur mit .NET und Domain-Driven Design
In modernen Softwareprojekten bildet eine gut durchdachte Architektur die Grundlage für nachhaltigen Erfolg. Die Kombination von .NET als leistungsfähige Plattform mit Domain-Driven Design (DDD) als methodischem Ansatz ermöglicht die Entwicklung wartbarer, flexibler und klar strukturierter Systeme.
Moderne Architektur mit .NET und Domain-Driven Design
In modernen Softwareprojekten bildet eine gut durchdachte Architektur die Grundlage für nachhaltigen Erfolg. Die Kombination von .NET als leistungsfähige Plattform mit Domain-Driven Design (DDD) als methodischem Ansatz ermöglicht die Entwicklung wartbarer, flexibler und klar strukturierter Systeme. Dieser Artikel beleuchtet die Kernprinzipien von DDD, zeigt deren praktische Umsetzung in .NET und stellt moderne Tools und Bibliotheken vor, die die Entwicklung erheblich vereinfachen.
Einleitung: Warum Domain-Driven Design?
Die Komplexität moderner Geschäftsanwendungen steigt kontinuierlich. Legacy-Systeme, die über Jahre gewachsen sind, leiden häufig unter unklaren Strukturen, engen Kopplungen und schwer nachvollziehbarer Geschäftslogik. Domain-Driven Design bietet einen methodischen Ansatz, um diese Herausforderungen zu bewältigen, indem es die fachliche Domäne in den Mittelpunkt der Softwareentwicklung stellt.
Die Herausforderungen traditioneller Architekturen
Viele Unternehmen kämpfen mit folgenden Problemen in ihren bestehenden Anwendungen:
Verstreute Geschäftslogik: Business-Regeln sind über Controller, Services und Datenzugriffsschichten verteilt, was zu Inkonsistenzen und Duplikationen führt. Eine einfache Änderung der Geschäftslogik erfordert Anpassungen an mehreren Stellen im Code.
Schwache Modellierung: Datenbankzentrierte Designs führen zu "anämischen" Domänenmodellen, die lediglich als Datencontainer dienen, ohne eigenes Verhalten zu implementieren. Die gesamte Logik wandert in Service-Klassen, die zu monolithischen "God Objects" werden.
Unklare Fachsprache: Die Kommunikation zwischen Entwicklern und Fachexperten scheitert an unterschiedlichen Begrifflichkeiten. Was der Fachbereich als "Bestellung" bezeichnet, heißt im Code möglicherweise "Order", "Transaction" oder "Purchase" – und bedeutet an verschiedenen Stellen unterschiedliche Dinge.
Technische Schulden: Ohne klare architektonische Leitlinien häufen sich über die Zeit technische Schulden an, die die Weiterentwicklung zunehmend verlangsamen und kostspielig machen.
Der DDD-Ansatz
Domain-Driven Design, erstmals 2003 von Eric Evans systematisiert, adressiert diese Probleme durch einen ganzheitlichen Ansatz:
Fachliche Domäne steht im Mittelpunkt: Die Software spiegelt die reale Geschäftsdomäne wider, nicht technische Infrastrukturdetails.
Gemeinsame Sprache: Entwickler und Fachexperten verwenden die gleiche Terminologie ("Ubiquitous Language"), die sich direkt im Code wiederfindet.
Strategisches und taktisches Design: DDD bietet sowohl Makro-Patterns (Bounded Contexts, Context Mapping) als auch Mikro-Patterns (Entities, Value Objects, Aggregates) für die Implementierung.
Klare Verantwortlichkeiten: Jedes Element hat eine eindeutige Rolle und Verantwortlichkeit, was zu wartbarem und testbarem Code führt.
Die Kombination von DDD mit .NET ist besonders vielversprechend, da die Plattform mit ihren modernen Features (Records, Pattern Matching, Nullable Reference Types) und einem reichen Ökosystem von Bibliotheken ideale Voraussetzungen bietet.
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:
- Externe Zugriffe erfolgen nur über die Aggregate Root
- Interne Objekte haben keine direkten Referenzen von außen
- Transaktionen ändern jeweils nur ein Aggregate
- 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 kann zu over-engineered Lösungen führen, besonders bei einfachen CRUD-Anwendungen.
Best Practice: Verwenden Sie DDD selektiv. Nicht jeder Teil des Systems benötigt die 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
- Start Simple: Beginnen Sie mit einer einfachen Struktur und refactoren Sie zu DDD, wenn Komplexität steigt
- Focus on Core: Investieren Sie DDD-Effort in die Core Domain, nicht überall
- Embrace CQRS: Trennen Sie Lese- und Schreiboperationen für bessere Performance
- Use Events: Kommunizieren Sie zwischen Aggregates via Domain Events
- Value Immutability: Nutzen Sie C# Records für Value Objects
- Test First: Schreiben Sie Tests für Domain-Logik ohne Infrastruktur-Dependencies
- Iterate: DDD ist iterativ – erwarten Sie nicht, die perfekte Struktur beim ersten Versuch zu finden
Fazit
Die Kombination von Domain-Driven Design mit modernem .NET bietet einen leistungsfähigen Ansatz für die Entwicklung komplexer Geschäftsanwendungen. Die klare Trennung von Domänen-, Anwendungs- und Infrastrukturlogik führt zu wartbarem, testbarem und erweiterbarem Code.
Wann DDD verwenden?
DDD ist ideal für:
- Komplexe Geschäftsdomänen mit vielen Regeln
- Langlebige Systeme, die über Jahre weiterentwickelt werden
- Projekte mit engen Zusammenarbeit zwischen Entwicklern und Domänenexperten
- Systeme, die skalieren und sich weiterentwickeln müssen
DDD ist übertrieben für:
- Einfache CRUD-Anwendungen
- Prototypen und MVPs mit unklaren Requirements
- Systeme mit sehr einfacher Geschäftslogik
- Projekte mit sehr kurzer Laufzeit
Die Zukunft von DDD in .NET
Mit jeder neuen Version führt .NET Features ein, die DDD-Implementierungen vereinfachen:
- Records und Immutability: Perfect für Value Objects
- Pattern Matching: Elegante Implementierung von Business Rules
- Nullable Reference Types: Sicherere Domain-Modelle
- Minimal APIs: Einfachere API-Schicht für Domain-Services
- Native AOT: Bessere Performance für Domain-Logik
Die Investition in Domain-Driven Design zahlt sich langfristig aus. Während die initiale Lernkurve steil sein mag, führen die resultierenden Systeme zu höherer Produktivität, weniger Bugs und besserer Geschäftsausrichtung. In Kombination mit den vorgestellten modernen NuGet-Paketen wird die Implementierung von DDD in .NET zunehmend zugänglich und praktikabel für Teams jeder Größe.
Der Schlüssel zum Erfolg liegt darin, DDD als methodischen Ansatz zu verstehen, nicht als dogmatisches Framework. Passen Sie die Patterns an Ihre spezifischen Bedürfnisse an, beginnen Sie mit kleinen Schritten und iterieren Sie kontinuierlich. Die Zusammenarbeit zwischen Entwicklern und Domänenexperten ist dabei genauso wichtig wie die technische Umsetzung – denn am Ende geht es bei DDD darum, Software zu bauen, die das Geschäft wirklich versteht und optimal unterstützt.