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.

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


