Zum Inhalt springen

DDD in der Praxis: Bounded Contexts und Context Mapping

Praktischer Leitfaden zu Bounded Contexts und Context Mapping: die strategischen DDD-Muster, die Domain-Modelle vor Korruption bewahren.

5 Min. Lesezeit
Diagramm einer Context Map, das Bounded Contexts und ihre Integrationsbeziehungen zeigt

Warum Bounded Contexts wichtiger sind als Aggregates

Die meisten Einführungen in Domain-Driven Design konzentrieren sich auf taktische Patterns: Entities, Value Objects, Aggregates, Repositories. Diese Patterns sind nützlich, aber sie sind Implementierungsdetails. Die strategischen Patterns—Bounded Contexts, Context Maps und Ubiquitous Language—entscheiden darüber, ob ein System mit organisatorischer Komplexität skaliert oder unter ihr zusammenbricht.

Ein Bounded Context ist eine Grenze, innerhalb der ein bestimmtes Domain Model definiert und gültig ist. Dasselbe reale Konzept—"Kunde", "Auftrag", "Produkt"—bedeutet in verschiedenen Teilen deines Systems unterschiedliche Dinge. Der Versuch, ein einheitliches Modell über alle Kontexte hinweg zu erzwingen, erzeugt ein fragiles, kompromissreiches Durcheinander, das niemanden zufriedenstellt.

Grenzen von Bounded Contexts identifizieren

Bounded Contexts decken sich mit Bereichen, in denen sich die Bedeutung der Sprache ändert. Wenn das Sales-Team "Kunde" sagt, meint es etwas anderes als das Billing-Team, was wiederum vom Support-Team abweicht.

tstypescript
// ❌ Single "Customer" model trying to serve every context
interface UnifiedCustomer {
  id: string;
  name: string;
  email: string;
  // Sales context fields
  leadScore: number;
  salesRepId: string;
  pipelineStage: string;
  // Billing context fields
  paymentMethod: string;
  billingAddress: string;
  taxId: string;
  creditLimit: number;
  // Support context fields
  supportTier: string;
  openTickets: number;
  satisfactionScore: number;
  preferredContactMethod: string;
}
 
// ✅ Each context owns its own model
// --- Sales Context ---
interface Lead {
  id: string;
  contactName: string;
  contactEmail: string;
  score: number;
  assignedRepId: string;
  stage: "prospect" | "qualified" | "proposal" | "negotiation" | "closed";
}
 
// --- Billing Context ---
interface BillingAccount {
  accountId: string;
  accountHolder: string;
  paymentMethod: PaymentMethod;
  billingAddress: Address;
  taxId: string | null;
  creditLimit: number;
}
 
// --- Support Context ---
interface SupportContact {
  contactId: string;
  displayName: string;
  tier: "basic" | "premium" | "enterprise";
  preferredChannel: "email" | "phone" | "chat";
}

Jedes Modell enthält nur das, was sein Kontext braucht. Den Sales Context interessieren Rechnungsadressen nicht. Der Support Context braucht keine Lead Scores. Das ist keine Redundanz—es ist angemessenes Modellieren.

Context-Mapping-Patterns

Bounded Contexts existieren nicht isoliert. Sie müssen Informationen austauschen. Context Mapping definiert die Beziehungen und Integrationspatterns zwischen Kontexten.

tstypescript
enum ContextRelationship {
  PARTNERSHIP = "partnership",
  SHARED_KERNEL = "shared-kernel",
  CUSTOMER_SUPPLIER = "customer-supplier",
  CONFORMIST = "conformist",
  ANTICORRUPTION_LAYER = "anticorruption-layer",
  OPEN_HOST_SERVICE = "open-host-service",
  PUBLISHED_LANGUAGE = "published-language",
  SEPARATE_WAYS = "separate-ways",
}
 
interface ContextMapEntry {
  upstream: string;
  downstream: string;
  relationship: ContextRelationship;
  integrationPattern: string;
  dataFlow: string;
}
 
const contextMap: ContextMapEntry[] = [
  {
    upstream: "Sales",
    downstream: "Billing",
    relationship: ContextRelationship.CUSTOMER_SUPPLIER,
    integrationPattern: "Domain events via message queue",
    dataFlow: "Sales publishes LeadConverted, Billing creates BillingAccount",
  },
  {
    upstream: "Legacy ERP",
    downstream: "Order Management",
    relationship: ContextRelationship.ANTICORRUPTION_LAYER,
    integrationPattern: "ACL translates ERP XML into domain events",
    dataFlow: "ERP exports orders, ACL transforms into Order aggregate",
  },
  {
    upstream: "Order Management",
    downstream: "Shipping",
    relationship: ContextRelationship.OPEN_HOST_SERVICE,
    integrationPattern: "REST API with published schema",
    dataFlow: "Shipping queries order details via versioned API",
  },
];

Die Context Map ist kein technisches Diagramm—sie ist ein politisches Dokument. Sie beschreibt, wer von wem abhängt, wer die Macht hat, Schnittstellen zu ändern, und wo Übersetzungsschichten nötig sind.

Eine Anti-Corruption Layer implementieren

Die Anti-Corruption Layer (ACL) ist das wichtigste Integrationspattern, um dein Domain Model vor externem Einfluss zu schützen. Sie übersetzt zwischen der Sprache eines Upstream-Kontexts und deiner eigenen.

tstypescript
// External legacy system model (upstream — we don't control this)
interface LegacyOrderRecord {
  ORD_NUM: string;
  CUST_ID: string;
  ORD_DT: string; // Format: YYYYMMDD
  ITEM_LST: string; // Pipe-separated: "SKU1|QTY1|PRC1||SKU2|QTY2|PRC2"
  TOT_AMT: number; // In cents
  STAT_CD: number; // 1=pending, 2=confirmed, 3=shipped, 4=delivered, 9=cancelled
}
 
// Our domain model (downstream — this is our bounded context)
interface Order {
  orderId: string;
  customerId: string;
  placedAt: Date;
  items: OrderItem[];
  total: Money;
  status: OrderStatus;
}
 
interface OrderItem {
  sku: string;
  quantity: number;
  unitPrice: Money;
}
 
interface Money {
  amount: number;
  currency: string;
}
 
type OrderStatus =
  | "pending"
  | "confirmed"
  | "shipped"
  | "delivered"
  | "cancelled";
 
// Anti-Corruption Layer
class OrderAntiCorruptionLayer {
  private static readonly STATUS_MAP: Record<number, OrderStatus> = {
    1: "pending",
    2: "confirmed",
    3: "shipped",
    4: "delivered",
    9: "cancelled",
  };
 
  translate(legacy: LegacyOrderRecord): Order {
    return {
      orderId: legacy.ORD_NUM,
      customerId: legacy.CUST_ID,
      placedAt: this.parseDate(legacy.ORD_DT),
      items: this.parseItems(legacy.ITEM_LST),
      total: { amount: legacy.TOT_AMT / 100, currency: "USD" },
      status: this.mapStatus(legacy.STAT_CD),
    };
  }
 
  private parseDate(dateStr: string): Date {
    const year = parseInt(dateStr.slice(0, 4), 10);
    const month = parseInt(dateStr.slice(4, 6), 10) - 1;
    const day = parseInt(dateStr.slice(6, 8), 10);
    return new Date(year, month, day);
  }
 
  private parseItems(itemList: string): OrderItem[] {
    const parts = itemList.split("||");
    return parts.map((part) => {
      const [sku, qty, price] = part.split("|");
      return {
        sku,
        quantity: parseInt(qty, 10),
        unitPrice: { amount: parseFloat(price) / 100, currency: "USD" },
      };
    });
  }
 
  private mapStatus(code: number): OrderStatus {
    const status = OrderAntiCorruptionLayer.STATUS_MAP[code];
    if (!status) {
      throw new Error(`Unknown legacy status code: ${code}`);
    }
    return status;
  }
}

Die ACL schützt das Domain Model vor den Namenskonventionen, Datenformaten und strukturellen Entscheidungen des Legacy-Systems. Wenn sich das Upstream-System ändert, muss nur die ACL aktualisiert werden—das Domain Model bleibt rein.

Domain Events für die Integration von Kontexten

Domain Events sind der primäre Mechanismus, damit Bounded Contexts ohne Kopplung kommunizieren. Jeder Kontext veröffentlicht Events zu bedeutsamen Zustandsänderungen; andere Kontexte abonnieren die Events, die sie interessieren.

tstypescript
interface DomainEvent {
  eventId: string;
  eventType: string;
  occurredAt: Date;
  aggregateId: string;
  payload: Record<string, unknown>;
}
 
// Sales context publishes when a lead converts
interface LeadConvertedEvent extends DomainEvent {
  eventType: "sales.lead.converted";
  payload: {
    leadId: string;
    contactName: string;
    contactEmail: string;
    contractValue: number;
    salesRepId: string;
  };
}
 
// Billing context subscribes and creates a billing account
class BillingEventHandler {
  constructor(private readonly accountRepo: AccountRepository) {}
 
  async handleLeadConverted(event: LeadConvertedEvent): Promise<void> {
    const account: BillingAccount = {
      accountId: crypto.randomUUID(),
      accountHolder: event.payload.contactName,
      paymentMethod: { type: "pending-setup" },
      billingAddress: { type: "pending-collection" },
      taxId: null,
      creditLimit: this.calculateInitialCredit(event.payload.contractValue),
    };
 
    await this.accountRepo.save(account);
  }
 
  private calculateInitialCredit(contractValue: number): number {
    return Math.min(contractValue * 0.1, 10000);
  }
}
 
// Support context subscribes and creates a support record
class SupportEventHandler {
  constructor(private readonly contactRepo: ContactRepository) {}
 
  async handleLeadConverted(event: LeadConvertedEvent): Promise<void> {
    const contact: SupportContact = {
      contactId: crypto.randomUUID(),
      displayName: event.payload.contactName,
      tier: "basic",
      preferredChannel: "email",
    };
 
    await this.contactRepo.save(contact);
  }
}

Jeder Downstream-Kontext übersetzt das Event in seine eigene Sprache und erzeugt seine eigenen Domain Objects. Der Sales Context muss nicht wissen, dass Billing und Support existieren—er kündigt einfach an, was passiert ist.

Shared Kernel: Wenn Kontexte gemeinsamen Boden brauchen

Manchmal sind zwei Kontexte so eng verwandt, dass vollständig separate Modelle mehr Probleme schaffen als Teilen. Das Shared-Kernel-Pattern erlaubt es zwei Kontexten, eine kleine, explizit definierte Teilmenge des Domain Models zu teilen.

tstypescript
// Shared kernel between Order and Shipping contexts
// This module is co-owned by both teams and changes require agreement
 
// shared-kernel/money.ts
export class Money {
  constructor(
    public readonly amount: number,
    public readonly currency: string
  ) {
    if (amount < 0) throw new Error("Money amount cannot be negative");
  }
 
  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);
  }
 
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}
 
// shared-kernel/address.ts
export interface Address {
  street: string;
  city: string;
  state: string;
  postalCode: string;
  country: string;
}
 
// shared-kernel/product-reference.ts
export interface ProductReference {
  sku: string;
  name: string;
  weight: { value: number; unit: "kg" | "lb" };
}

Der Shared Kernel muss klein, stabil und gemeinsam gepflegt sein. Wenn ein Team ihn einseitig ändern will, ist der Kernel zu groß oder die Beziehung ist in Wirklichkeit Customer-Supplier, nicht Shared Kernel.

Wichtige Erkenntnisse

Bounded Contexts sind das einflussreichste DDD-Pattern, weil sie die Korruption des Domain Models verhindern, die große Systeme unhandhabbar macht. Identifiziere Grenzen, an denen sich die Sprache ändert, nicht dort, wo du denkst, dass Microservice-Grenzen sein sollten. Nutze Context Mapping, um die politischen Beziehungen zwischen Kontexten explizit zu machen.

Schütze dein Domain Model vor externen Systemen mit Anti-Corruption Layers. Kommuniziere zwischen Kontexten über Domain Events statt direkter API-Aufrufe—Events bewahren Autonomie und ermöglichen unabhängiges Deployment. Wenn zwei Kontexte wirklich gemeinsame Konzepte brauchen, nutze einen Shared Kernel, aber halte ihn minimal und gemeinsam im Besitz.

Das Ziel ist nicht, alle Kopplung zu eliminieren—sondern sie bewusst, explizit und handhabbar zu machen. Eine gut gezeichnete Context Map ist mehr wert als jede Menge taktischer DDD-Patterns, die in einem einzigen, ausufernden Kontext angewendet werden.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX