Zum Inhalt springen

Domain-Driven Design: Die wesentlichen Bausteine

Entities, Value Objects, Aggregates und Bounded Contexts: die DDD-Patterns, die komplexe Logik ordnen, ohne einfache Systeme zu überfrachten.

3 Min. Lesezeit
Domänenmodell-Diagramm mit Bounded Contexts und Aggregate-Grenzen

Domain-Driven Design ist eine Menge von Mustern, um komplexe Geschäftslogik zu strukturieren. Es geht nicht um bestimmte Frameworks oder Ordnerstrukturen – es geht darum, die Software um die tatsächliche Geschäftsdomäne herum zu modellieren und dabei eine gemeinsame Sprache zwischen Entwicklern und Domain-Experten zu verwenden. Die taktischen Muster (Entities, Value Objects, Aggregates) geben dir konkrete Werkzeuge, um dieses Modell umzusetzen.

Entities vs. Value Objects

Eine Entity hat eine eindeutige Identität, die über die Zeit bestehen bleibt. Ein Value Object wird vollständig durch seine Attribute definiert – zwei Value Objects mit denselben Attributen sind austauschbar.

tstypescript
// Entity: identified by its ID, attributes can change
class Order {
  constructor(
    readonly id: string,
    private items: OrderItem[],
    private status: OrderStatus
  ) {}
 
  addItem(item: OrderItem): void {
    if (this.status !== "draft") {
      throw new Error("Cannot modify a non-draft order");
    }
    this.items.push(item);
  }
 
  // Two orders with the same items are still different orders
  equals(other: Order): boolean {
    return this.id === other.id;
  }
}
 
// Value Object: defined by its attributes, immutable
class Money {
  constructor(
    readonly amount: number,
    readonly currency: string
  ) {
    if (amount < 0) throw new Error("Amount cannot be negative");
    if (!currency.match(/^[A-Z]{3}$/)) throw new Error("Invalid currency code");
  }
 
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Cannot add different currencies");
    }
    return new Money(this.amount + other.amount, this.currency);
  }
 
  // Two Money objects with the same amount and currency are equal
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Aggregates

Ein Aggregate ist eine Gruppe von Entities und Value Objects mit einer einzelnen Root-Entity, die den gesamten Zugriff steuert. Externer Code bezieht sich nur über die Root auf das Aggregate – so werden Konsistenzregeln durchgesetzt.

tstypescript
// ❌ Direct access to internals — no consistency guarantees
const order = await orderRepo.findById(orderId);
const item = order.items[0];
item.quantity = 100;  // Modified directly, bypassing business rules
await orderItemRepo.save(item);
 
// ✅ Access through aggregate root — invariants are enforced
class Order {
  private items: OrderItem[] = [];
  private status: OrderStatus = "draft";
 
  updateItemQuantity(itemId: string, newQuantity: number): void {
    if (this.status !== "draft") {
      throw new Error("Cannot modify a submitted order");
    }
 
    const item = this.items.find(i => i.id === itemId);
    if (!item) throw new Error(`Item ${itemId} not found`);
 
    if (newQuantity <= 0) {
      this.items = this.items.filter(i => i.id !== itemId);
      return;
    }
 
    if (newQuantity > 100) {
      throw new Error("Maximum quantity per item is 100");
    }
 
    item.updateQuantity(newQuantity);
  }
 
  get totalAmount(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.lineTotal),
      new Money(0, "USD")
    );
  }
}
 
// External code uses the aggregate root
const order = await orderRepo.findById(orderId);
order.updateItemQuantity(itemId, 5);
await orderRepo.save(order); // Saves the entire aggregate

Bounded Contexts

Ein Bounded Context definiert die Grenze, innerhalb der ein Modell gilt. Derselbe reale Begriff (z. B. „User“) kann in unterschiedlichen Kontexten unterschiedliche Darstellungen haben.

tstypescript
// ❌ Shared "User" model across the entire system
interface User {
  id: string;
  name: string;
  email: string;
  shippingAddress: Address;     // Only relevant in orders
  creditScore: number;          // Only relevant in payments
  loginAttempts: number;        // Only relevant in auth
  preferredCategories: string[]; // Only relevant in recommendations
}
 
// ✅ Each bounded context has its own model
 
// Auth context — cares about credentials
interface AuthUser {
  id: string;
  email: string;
  passwordHash: string;
  loginAttempts: number;
  lockedUntil: Date | null;
}
 
// Order context — cares about shipping
interface OrderCustomer {
  id: string;
  name: string;
  shippingAddress: Address;
  loyaltyTier: "standard" | "premium";
}
 
// Payment context — cares about billing
interface PaymentCustomer {
  id: string;
  defaultPaymentMethod: string;
  billingAddress: Address;
  creditLimit: Money;
}

Domain Events

Domain Events erfassen wichtige Dinge, die innerhalb eines Bounded Context passieren. Sie sind der primäre Mechanismus für die Kommunikation zwischen Kontexten.

tstypescript
interface DomainEvent {
  type: string;
  occurredAt: string;
  aggregateId: string;
}
 
class Order {
  private domainEvents: DomainEvent[] = [];
 
  submit(): void {
    if (this.items.length === 0) {
      throw new Error("Cannot submit an empty order");
    }
 
    this.status = "submitted";
    this.submittedAt = new Date();
 
    // Record the domain event
    this.domainEvents.push({
      type: "order.submitted",
      occurredAt: new Date().toISOString(),
      aggregateId: this.id,
    });
  }
 
  pullDomainEvents(): DomainEvent[] {
    const events = [...this.domainEvents];
    this.domainEvents = [];
    return events;
  }
}
 
// Repository publishes events after saving
class OrderRepository {
  async save(order: Order): Promise<void> {
    await this.db.save(order);
 
    const events = order.pullDomainEvents();
    for (const event of events) {
      await this.eventBus.publish(event);
    }
  }
}

Repository Pattern

Repositories kapseln den Persistenzmechanismus. Das Domänenmodell weiß nichts über Datenbanken – es arbeitet mit reinen Objekten.

tstypescript
// ❌ Domain logic coupled to database queries
async function submitOrder(orderId: string) {
  const row = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);
  const items = await db.query("SELECT * FROM order_items WHERE order_id = $1", [orderId]);
  // Reconstruct domain object from raw rows...
  // Business logic mixed with SQL
}
 
// ✅ Repository returns domain objects
interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findByCustomer(customerId: string, status?: OrderStatus): Promise<Order[]>;
}
 
// Application service uses the repository
class OrderService {
  constructor(private orderRepo: OrderRepository) {}
 
  async submitOrder(orderId: string): Promise<void> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new Error("Order not found");
 
    order.submit(); // Pure domain logic
 
    await this.orderRepo.save(order); // Persistence abstracted
  }
}

Key Takeaways

  1. Entities haben Identität, Value Objects haben Attribute — wähle das richtige Modellierungswerkzeug für jedes Konzept
  2. Aggregates setzen Konsistenzgrenzen durch — alle Änderungen laufen über die Aggregate-Root
  3. Bounded Contexts verhindern monolithische Modelle — derselbe Begriff bedeutet in verschiedenen Teilen des Systems verschiedenes
  4. Domain Events ermöglichen Kontextkommunikation — publiziere Events nach dem Speichern und lasse andere Kontexte reagieren
  5. Repositories verbergen Persistenzdetails — Domain-Objekte sollten nichts über Datenbanken oder ORMs wissen
  6. DDD ist für komplexe Domänen — einfache CRUD-Anwendungen profitieren nicht von diesem Modellierungsgrad
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX