Domain-Driven Design in C#: Wie man in der Komplexität nicht untergeht
\n\nWenn Ihr C#-Projekt die Phase der "einfachen CRUD-Anwendung" hinter sich lässt, kommt der Moment der Wahrheit. Die Geschäftslogik beginnt, sich in die Quere zu kommen, die Methoden der Dienste wachsen auf Hunderte von Zeilen an, und das Wort "Refactoring" löst nervöses Zittern aus. Kommt Ihnen das bekannt vor? Willkommen in der Welt, in der Sie ohne Domain-Driven Design (DDD) riskieren, Ihren Code in einen Sumpf zu verwandeln.
\n\nDDD geht nicht um "ein weiteres Framework". Es ist eine Philosophie, die die Geschäftsdomäne in den Mittelpunkt des Universums stellt. Heute werden wir uns ansehen, wie Sie Domain-Driven Design in C#-Projekten so anwenden, dass der Code nicht nur funktioniert, sondern verständlich und flexibel ist. Kein überflüssiges Gerede – nur echte Beispiele und praktische Tipps.
\n\nWarum rettet DDD C#-Projekte?
\n\nStellen Sie sich vor, Sie schreiben ein System zur Verwaltung von Bestellungen in einem Online-Shop. Wenn Sie einfach die gesamte Logik in ASP.NET-Controller packen, werden Sie nach einem halben Jahr nicht mehr erklären können, warum die Methode CreateOrder() die Methode CalculateShipping() aufruft, die wiederum für Wechselkurse in die Datenbank greift. DDD bietet eine klare Trennung: Es gibt eine Domänenschicht, in der die Geschäftslogik lebt, und eine Infrastruktur, die sich um technische Details kümmert.
Die Schlüsselidee von DDD ist die Ubiquitous Language (allgegenwärtige Sprache). Sie und die Fachexperten sprechen dieselbe Sprache. Im Code drückt sich dies in Begriffen wie "Order", "LineItem", "DiscountPolicy" aus, nicht in "OrderDto", "OrderService" und "Helper". Je näher der Code an der Geschäftssprache ist, desto einfacher ist er zu warten.
\n\nStrategisches und taktisches Design
\n\nDDD unterteilt sich in zwei Teile: den strategischen (wie man das System in Module aufteilt) und den taktischen (wie man den Code innerhalb eines Moduls schreibt). In C# bietet uns das taktische Design vier mächtige Bausteine:
\n\n- \n
- Entity – ein Objekt mit einer eindeutigen Kennung (z. B.
OrderId). \n - Value Object – ein Objekt, das durch seine Attribute definiert wird (z. B.
MoneyoderAddress). \n - Aggregate Root – die Wurzelentität, die eine Gruppe von Objekten verwaltet. \n
- Repository – eine Abstraktion des Datenzugriffs. \n
Schauen wir uns an, wie das im Code aussieht.
\n\nBeispiel: Entwurf einer Bestellung (Order)
\n\nBeginnen wir einfach: Wir haben eine Bestellung, die aus Positionen besteht. Eine Bestellung kann den Status "Neu", "Bezahlt" oder "Storniert" haben.
\n\n1. Value Object: Money
\n\nGeld ist ein klassisches Value Object. Zwei Money-Objekte sind gleich, wenn sie denselben Betrag und dieselbe Währung haben. Sie sind unveränderlich (immutable).
public sealed record Money(decimal Amount, string Currency)\n{\n public static Money operator +(Money left, Money right)\n {\n if (left.Currency != right.Currency)\n throw new InvalidOperationException("Currency mismatch");\n return new Money(left.Amount + right.Amount, left.Currency);\n }\n}\n\n\nBeachten Sie das record – in C# 9+ ist dies die ideale Möglichkeit, ein Value Object zu erstellen. Es liefert automatisch Equals, GetHashCode und Unveränderlichkeit.
2. Entity: Order und LineItem
\n\nEntitäten haben eine Kennung. Erstellen wir eine Basisklasse für Entity:
\n\npublic abstract class Entity<TId>\n{\n public TId Id { get; protected set; }\n\n protected Entity(TId id) => Id = id;\n\n public override bool Equals(object? obj) =>\n obj is Entity<TId> other && Id!.Equals(other.Id);\n\n public override int GetHashCode() => Id!.GetHashCode();\n}\n\n\nJetzt die Order und LineItem selbst:
public class Order : Entity<Guid>\n{\n private readonly List<LineItem> _items = new();\n public IReadOnlyList<LineItem> Items => _items.AsReadOnly();\n public OrderStatus Status { get; private set; }\n public Money Total { get; privat