Domain-Driven Design (DDD) in der Praxis: Pragmatische Ansätze für moderne Softwareentwicklung

Wie Sie Domain-Driven Design erfolgreich in Ihren Projekten umsetzen
Abstract
- #Domain-Driven Design
- #DDD
- #Softwareentwicklung
- #Value Objects
DDD entmystifiziert: Praktische Patterns für besseren Code
Domain-Driven Design (DDD) hat sich als mächtiger Ansatz zur Bewältigung komplexer Softwareprojekte etabliert. Doch die vollständige Implementierung von DDD ist nicht immer praktikabel oder notwendig. Dieser Artikel zeigt auf, wie Sie die wertvollsten Konzepte von DDD in Ihre tägliche Entwicklungsarbeit integrieren können, ohne gleich eine komplette Transformation durchführen zu müssen.
Was ist Domain-Driven Design?
Domain-Driven Design entstand aus Eric Evans' wegweisendem Buch "Domain-Driven Design: Tackling Complexity in the Heart of Software". Das zentrale Konzept von DDD ist die Ubiquitous Language - eine gemeinsame Sprache zwischen Entwicklern und Domänenexperten, die sich direkt im Code widerspiegelt.
Die Herausforderung der Ubiquitous Language
Die Ubiquitous Language soll überall im Projekt verwendet werden, doch in der Realität beschränkt sie sich oft auf spezifische Bereiche der Domäne. Das Ziel ist es, dass Ihr Code so lesbar wird, dass Domänenexperten verstehen können, was der Code bewirkt.
Ein einfaches Beispiel verdeutlicht dies:
- Domänenexperte sagt: "Basierend auf dem Kundenrabatt können wir die Gesamtkosten des Kaufs berechnen"
- Entwickler implementiert: "Sobald wir die Rabatteinstellung des Benutzers haben, können wir den Gesamtpreis der Bestellung aktualisieren"
Obwohl beide Aussagen funktional identisch sind, verwendet die Implementierung andere Begriffe. Ein DDD-Ansatz würde die exakte Sprache der Domäne beibehalten.
Wann DDD nicht vollständig praktikabel ist
Herausforderungen für Consultants
Für Beratungsunternehmen und externe Entwickler ist die vollständige DDD-Implementierung oft nicht realistisch. Der Aufbau einer Ubiquitous Language und das tiefe Verständnis der Domäne erfordern Zeit und kontinuierliche Präsenz, die bei kurzen Projekten nicht gegeben ist.
Alternative Ansätze
Statt auf DDD zu verzichten, können Sie die taktischen Patterns von DDD nutzen. Diese Codepatterns verbessern die Lesbarkeit und Wartbarkeit Ihres Codes, ohne eine vollständige DDD-Transformation zu erfordern.
Taktische DDD-Patterns in der Praxis
Value Objects: Das mächtigste Werkzeug
Value Objects gehören zu den wertvollsten Konzepten aus dem DDD-Werkzeugkasten. Sie kapseln zusammengehörige Daten und deren Validierungslogik in unveränderlichen Objekten.
Eigenschaften von Value Objects
- Wertgleichheit: Zwei Value Objects sind gleich, wenn alle ihre Eigenschaften übereinstimmen
- Unveränderlichkeit: Einmal erstellt, können Value Objects nicht mehr verändert werden
- Selbstvalidierung: Value Objects können niemals in einem ungültigen Zustand erstellt werden
Praktisches Beispiel: PersonalDetails
class PersonalDetails {
constructor(
public readonly firstName: string,
public readonly lastName: string,
public readonly age: number,
) {
if (!firstName?.trim()) {
throw new Error('Vorname ist erforderlich');
}
if (!lastName?.trim()) {
throw new Error('Nachname ist erforderlich');
}
if (age < 0 || age > 150) {
throw new Error('Ungültiges Alter');
}
}
equals(other: PersonalDetails): boolean {
return (
this.firstName === other.firstName &&
this.lastName === other.lastName &&
this.age === other.age
);
}
withAge(newAge: number): PersonalDetails {
return new PersonalDetails(this.firstName, this.lastName, newAge);
}
}
Entities vs. Value Objects
Der Unterschied zwischen Entities und Value Objects liegt in der Identität:
- Entities haben eine eindeutige Identität und ändern sich über die Zeit
- Value Objects haben keine Identität und werden durch ihre Werte definiert
Natürliche Identifikatoren bevorzugen
Anstatt automatisch generierte Datenbank-IDs zu verwenden, sollten Sie natürliche Identifikatoren bevorzugen, wo diese existieren. Beispiele:
- Für Personen: Personalausweisnummer oder Sozialversicherungsnummer
- Für Fahrzeuge: Fahrzeug-Identifikationsnummer
- Für Produkte: Artikelnummer oder Barcode
Property Setters und Kapselung
Ein wichtiger Aspekt der objektorientierten Programmierung ist die Verwendung von Setters für die Validierung:
class User {
private _firstName: string = '';
get firstName(): string {
return this._firstName;
}
set firstName(value: string) {
if (!value?.trim()) {
throw new Error('Vorname ist erforderlich');
}
this._firstName = value;
}
updatePersonalDetails(details: PersonalDetails): void {
this.firstName = details.firstName;
// weitere Validierung und Updates...
}
}
Architekturpatterns für DDD
Schichtenarchitektur vs. Ports and Adapters
Während die klassische Schichtenarchitektur (Presentation → Business → Persistence → Infrastructure) weit verbreitet ist, bietet das Ports and Adapters Pattern (auch Onion Architecture genannt) bessere Flexibilität für moderne Anwendungen.
Vorteile von Ports and Adapters
- Bessere Testbarkeit: Die Domäne ist vollständig isoliert
- Flexiblere Integration: Verschiedene Ein- und Ausgabekanäle können einfach hinzugefügt werden
- Klare Abhängigkeitsrichtung: Alle Abhängigkeiten zeigen nach innen
Bounded Contexts und Subdomains
Große Domänen sollten in kleinere, handhabbare Teile aufgeteilt werden:
Domänentypen
- Core Domain: Der wichtigste Teil Ihrer Anwendung
- Supporting Subdomains: Wichtige, aber unterstützende Bereiche
- Generic Subdomains: Standardfunktionalitäten, die eingekauft werden können
Anti-Corruption Layers
Wenn Sie mit externen Systemen interagieren, die andere Datenmodelle verwenden, schützt ein Anti-Corruption Layer Ihre Domäne vor fremden Strukturen.
interface Order {
id: OrderId;
orderLines: OrderLine[];
totalAmount: number;
}
class OrderId {
constructor(public readonly value: string) {
if (!value) throw new Error('OrderId required');
}
}
interface IOrderService {
getOrder(orderId: OrderId): Promise<Order>;
}
class ExternalSystemAdapter implements IOrderService {
constructor(private externalApi: IExternalApi) {}
async getOrder(orderId: OrderId): Promise<Order> {
const externalData = await this.externalApi.getItem(orderId.value);
return this.mapToOrder(externalData); // Transformation zur eigenen Domäne
}
private mapToOrder(externalData: ExternalItem): Order {
// Mapping-Logik von externem Format zu internem Order-Model
return {
id: { value: externalData.itemId },
orderLines: externalData.subItems.map(this.mapToOrderLine),
totalAmount: externalData.totalValue,
};
}
}
Domain Events und Messaging
Domain Events für lose Kopplung
Domain Events ermöglichen es, verschiedene Teile Ihrer Anwendung lose zu koppeln:
interface OrderCompletedEvent {
readonly orderId: string;
readonly completedAt: Date;
readonly customerId: string;
}
interface DomainEventHandler<T> {
handle(event: T): Promise<void>;
}
class EmailNotificationHandler
implements DomainEventHandler<OrderCompletedEvent>
{
async handle(event: OrderCompletedEvent): Promise<void> {
// E-Mail-Versand-Implementierung
console.log(`Sending confirmation email for order ${event.orderId}`);
}
}
class InventoryUpdateHandler
implements DomainEventHandler<OrderCompletedEvent>
{
async handle(event: OrderCompletedEvent): Promise<void> {
// Lagerbestand aktualisieren
console.log(`Updating inventory for order ${event.orderId}`);
}
}
Diese Events können von verschiedenen Handlers verarbeitet werden:
- E-Mail-Versand
- Lagerverwaltung
- Analyseaktualisierung
Konsistenz und das Outbox Pattern
Bei der Verwendung von Events ist Konsistenz ein wichtiges Thema. Das Outbox Pattern löst das Problem der Transaktionsgrenzen:
interface OutboxEvent {
id: string;
eventType: string;
payload: any;
createdAt: Date;
processed: boolean;
}
class OrderService {
constructor(
private orderRepository: IOrderRepository,
private outboxRepository: IOutboxRepository,
) {}
async completeOrder(orderId: string): Promise<void> {
// 1. Order und Event in derselben Transaktion speichern
await this.orderRepository.transaction(async (tx) => {
await this.orderRepository.updateOrderStatus(orderId, 'completed', tx);
const event: OutboxEvent = {
id: crypto.randomUUID(),
eventType: 'OrderCompleted',
payload: { orderId, completedAt: new Date() },
createdAt: new Date(),
processed: false,
};
await this.outboxRepository.saveEvent(event, tx);
});
}
}
// 2. Separater Service verarbeitet Events
class OutboxProcessor {
async processEvents(): Promise<void> {
const unprocessedEvents =
await this.outboxRepository.getUnprocessedEvents();
for (const event of unprocessedEvents) {
await this.publishEvent(event);
await this.outboxRepository.markAsProcessed(event.id);
}
}
}
- Speichern Sie Entität und Event in derselben Datenbanktransaktion
- Ein separater Service liest Events aus der Datenbank und veröffentlicht sie
- Dies garantiert, dass Events niemals verloren gehen
CQRS in Kombination mit DDD
Command Query Responsibility Segregation (CQRS) kann DDD gut ergänzen:
Vorteile von CQRS
- Optimierte Lesevorgänge: Separate Read-Models für bessere Performance
- Komplexe Schreibvorgänge: Rich Domain Models für Geschäftslogik
- Skalierbarkeit: Getrennte Optimierung von Lese- und Schreibvorgängen
Ein-Datenbank-CQRS
Sie müssen nicht zwingend separate Datenbanken verwenden. Ein-Datenbank-CQRS mit verschiedenen Datenmodellen ist oft ausreichend:
class Order {
private constructor(
public readonly id: OrderId,
private _lineItems: OrderLineItem[],
private _status: OrderStatus,
) {}
addLineItem(product: Product, quantity: number): void {
if (this._status !== OrderStatus.Draft) {
throw new Error(
'Kann keine Artikel zu abgeschlossener Bestellung hinzufügen',
);
}
if (quantity <= 0) {
throw new Error('Menge muss größer als 0 sein');
}
const existingItem = this._lineItems.find((item) =>
item.productId.equals(product.id),
);
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this._lineItems.push(
new OrderLineItem(product.id, quantity, product.price),
);
}
}
complete(): void {
if (this._lineItems.length === 0) {
throw new Error('Bestellung muss mindestens einen Artikel enthalten');
}
this._status = OrderStatus.Completed;
}
}
// Query-Side: Einfaches Read-Model
interface OrderSummaryDto {
orderNumber: string;
totalAmount: number;
orderDate: Date;
customerName: string;
itemCount: number;
}
class OrderQueryService {
async getOrderSummary(orderId: string): Promise<OrderSummaryDto> {
// Optimierte Query direkt aus der Datenbank
const result = await this.database.query(
`
SELECT
o.orderNumber,
o.totalAmount,
o.orderDate,
c.name as customerName,
COUNT(oi.id) as itemCount
FROM orders o
JOIN customers c ON o.customerId = c.id
JOIN order_items oi ON o.id = oi.orderId
WHERE o.id = ?
GROUP BY o.id
`,
[orderId],
);
return result[0];
}
}
TypeScript-Integration mit ORMs
Value Objects mit TypeORM
TypeORM unterstützt Value Objects durch Embedded Entities:
import { Entity, Column, Embedded } from 'typeorm';
@Entity()
class User {
@Column()
id: number;
@Embedded(() => PersonalDetails)
personalDetails: PersonalDetails;
}
// Value Object als Embedded Entity
class PersonalDetails {
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
age: number;
constructor(firstName: string, lastName: string, age: number) {
// Validierung wie oben gezeigt
}
}
Versteckte Identifikatoren
Für natürliche Identifikatoren können Sie Datenbank-IDs als private Felder verstecken:
class Person {
@Column({ select: false }) // Wird nicht automatisch geladen
private _id: number;
@Column({ unique: true })
public readonly personNumber: PersonNumber;
constructor(personNumber: PersonNumber) {
this.personNumber = personNumber;
}
}
Best Practices für die Implementierung
Code-First-Design
Entwerfen Sie Ihre Objekte zuerst für die optimale Benutzererfahrung, dann passen Sie sie an die Infrastruktur an:
- Erstellen Sie eine intuitive API für Ihre Domänenobjekte
- Verstecken Sie Infrastruktur-Details (wie Datenbank-IDs)
- Verwenden Sie Dependency Injection für externe Abhängigkeiten
Sprache im Code
Bemühen Sie sich, Code zu schreiben, der wie natürliche Sprache liest:
interface IUserRepository {
livingIn(city: string): Promise<User[]>;
withPremiumSubscription(): Promise<User[]>;
withAgesBetween(min: number, max: number): Promise<User[]>;
}
// Verwendung:
const users = await userRepository.livingIn('Stockholm');
const premiumUsers = await userRepository.withPremiumSubscription();
Dies ist lesbarer als:
interface IUserRepository {
getByCity(city: string): Promise<User[]>;
getBySubscriptionType(type: string): Promise<User[]>;
}
Fazit
Domain-Driven Design bietet wertvolle Konzepte für die moderne Softwareentwicklung, auch wenn Sie nicht das gesamte DDD-Framework implementieren. Die wichtigsten Erkenntnisse:
- Value Objects verbessern Kapselung und Validierung erheblich
- Natürliche Identifikatoren sind oft besser als Datenbank-IDs
- Anti-Corruption Layers schützen Ihre Domäne vor externen Systemen
- Domain Events ermöglichen lose Kopplung
- CQRS kann auch mit einer einzigen Datenbank wertvoll sein
Der Schlüssel liegt darin, pragmatisch vorzugehen und die Patterns zu wählen, die Ihrem Team und Projekt den größten Nutzen bringen. Nicht jedes Projekt benötigt eine vollständige DDD-Implementierung, aber fast jedes Projekt kann von den taktischen Patterns profitieren.
Beginnen Sie mit Value Objects und natürlichen Identifikatoren - diese bieten den besten Return on Investment für die meisten Entwicklungsteams. Von dort aus können Sie schrittweise weitere DDD-Konzepte einführen, wenn sie Ihrem Projekt zugutekommen.
Häufig gestellte Fragen
Ist Domain-Driven Design nur für große Projekte geeignet?
Nein, die taktischen Patterns von DDD wie Value Objects und Domain Events können auch in kleineren Projekten wertvoll sein. Sie verbessern die Codequalität und Wartbarkeit erheblich, ohne den Overhead einer vollständigen DDD-Implementierung.
Wie entscheide ich, ob ich eine vollständige DDD-Implementierung
benötige? Eine vollständige DDD-Implementierung macht Sinn, wenn Sie ein langfristiges Produktteam haben, das sich tief in die Domäne einarbeiten kann. Für Consulting-Projekte oder Teams mit häufig wechselnden Domänen sind die taktischen Patterns meist ausreichend.
Kann ich DDD-Patterns mit bestehenden Architekturen kombinieren?
Absolut! Value Objects, Domain Events und Anti-Corruption Layers können schrittweise in bestehende Systeme integriert werden. Sie müssen nicht Ihre gesamte Architektur ändern, um von DDD-Konzepten zu profitieren.
- Technologien
- Programmiersprachen
- Tools