post

Das Aggregat als Konsistenzgrenze

Ein Aggregat ist keine ORM-Entity und keine Tabelle, sondern eine Konsistenz- und Transaktionsgrenze: ein Verbund von Objekten, den eine Aggregate Root als Einheit schützt. Warum eine Transaktion genau ein Aggregat ändert und andere Aggregate nur per ID referenziert werden.

In fast jedem Workshop kommt der Moment, in dem jemand sein Domänenmodell auf ein Whiteboard malt: eine Klasse Order, daneben Customer, dazwischen ein Pfeil, und an dem Pfeil hängt ein Customer-Objekt, das direkt in der Bestellung steckt. „Das ist mein Aggregat", höre ich dann. Meist ist es das gerade nicht. Was da an der Tafel steht, ist ein Objektgraph – eine Ansammlung von Datenklassen mit Gettern und Settern, die zufällig zusammenhängen. Ein Aggregat ist etwas anderes. Es ist zuerst eine Entscheidung über Konsistenz, und erst danach eine Sache von Klassen und Tabellen.

Ich möchte in diesem Text den Begriff so schärfen, wie ich ihn in der Beratungspraxis brauche. Denn wer das Aggregat als Konsistenzgrenze versteht, trifft plötzlich andere Entscheidungen: über Transaktionen, über Referenzen zwischen Objekten, über die Größe seiner Modelle und darüber, wo eventual consistency anfängt. Das ist die Fortsetzung eines Gedankens, den ich schon an anderer Stelle verfolgt habe – dass CQRS eine fachliche Grenze ist und kein Framework. Das Aggregat ist die nächste, kleinere Grenze im selben Denkgebäude.

Was ein Aggregat wirklich ist

Martin Fowler formuliert es knapp: Ein Aggregat ist „a cluster of domain objects that can be treated as a single unit" – ein Verbund von Domänenobjekten, den man als eine Einheit behandelt. Das klassische Beispiel ist eine Order mit ihren LineItems. Das sind getrennte Objekte, aber sie werden nie einzeln geladen, geändert oder gespeichert. Sie kommen als Ganzes aus dem Repository und gehen als Ganzes zurück. Das Aggregat ist die Grundeinheit für den Speicher-Transfer.

Innerhalb dieses Verbunds gibt es genau eine ausgezeichnete Komponente: die Aggregate Root. Sie ist die einzige Entity, auf die von außen referenziert werden darf. Niemand außerhalb des Aggregats hält jemals direkt ein LineItem in der Hand – man geht immer über die Order. Das klingt nach einer lästigen Kapselungsregel, hat aber einen tiefen Grund: Nur wenn aller Zugriff über die Root läuft, kann die Root garantieren, dass der Zustand des gesamten Aggregats stimmt.

Und damit sind wir beim Kern. Eric Evans, Vaughn Vernon und Fowler meinen alle dasselbe, wenn sie vom Aggregat sprechen: Es ist die Konsistenzgrenze. Die Invarianten – die Geschäftsregeln, die immer gelten müssen – sind nach jeder abgeschlossenen Änderung erfüllt. Nicht „irgendwann", nicht „nach dem nächsten Batch-Lauf", sondern unmittelbar, transaktional. Vernon nennt das treffend „must always be transactionally consistent". Eine Invariante ist zum Beispiel: „Nach der Bestätigung dürfen keine Positionen mehr hinzukommen." Oder: „Die Summe der Positionen entspricht der Bestellsumme." Solche Regeln leben nicht in einem Service irgendwo weiter oben, sondern in der Root selbst.

Das ist der entscheidende Perspektivwechsel: Ein Aggregat definiert man nicht danach, welche Objekte zusammengehören, sondern danach, welche Invarianten gemeinsam geschützt werden müssen.

Die Root schützt ihre Invarianten

Sehen wir uns das im Code an. Das folgende Beispiel ist illustrativ – es geht mir um die Struktur, nicht um ein lauffähiges System. Wichtig ist, was fehlt: öffentliche Setter. Die lineItems-Liste ist privat, und der einzige Weg, sie zu verändern, führt über Methoden, die die Invarianten kennen.

type OrderStatus = 'DRAFT' | 'CONFIRMED';

class Order {
  private status: OrderStatus = 'DRAFT';
  private readonly lineItems: LineItem[] = [];

  constructor(
    readonly id: OrderId,
    private readonly customerId: CustomerId,
  ) {}

  addLineItem(product: ProductId, quantity: number): void {
    if (this.status === 'CONFIRMED') {
      throw new Error('Cannot modify a confirmed order');
    }
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    this.lineItems.push(new LineItem(product, quantity));
  }

  confirm(): void {
    if (this.lineItems.length === 0) {
      throw new Error('Cannot confirm an order without line items');
    }
    this.status = 'CONFIRMED';
  }
}

Zwei Invarianten stehen hier: Eine bestätigte Bestellung nimmt keine Positionen mehr an, und eine leere Bestellung lässt sich nicht bestätigen. Beide sind an genau einer Stelle durchgesetzt, in der Root. Es gibt keinen Weg, an dieser Prüfung vorbei ein LineItem in die Liste zu schmuggeln – denn niemand außerhalb der Order kommt an die Liste heran. Genau das meint „die Root sichert die Integrität des gesamten Aggregats". Der Zustand kann gar nicht erst ungültig werden.

Man vergleiche das mit der Datenklasse vom Anfang: einer Order mit public lineItems: LineItem[] und public status: string. Dort kann jeder Code an jeder Stelle die Liste manipulieren oder den Status setzen. Die Regel „keine Positionen nach Bestätigung" existiert dann bestenfalls als Konvention, verstreut über die Codebasis, und wird früher oder später verletzt. Das Aggregat verlegt sie an eine einzige, unumgehbare Stelle.

Eine Transaktion ändert genau ein Aggregat

Aus der Konsistenzgrenze folgt unmittelbar die Transaktionsgrenze. Evans und Vernon sind hier deutlich: Transaktionen dürfen Aggregatgrenzen nicht überschreiten. Die Faustregel lautet – eine Transaktion ändert genau eine Aggregat-Instanz. Was darüber hinausgeht, wird nicht im selben atomaren Commit erledigt, sondern in getrennten Transaktionen, verbunden über eventual consistency.

Das wirkt anfangs unbequem. Ein typischer Einwand aus Workshops: „Wenn eine Bestellung bestätigt wird, muss doch auch der Lagerbestand reserviert werden – das gehört in eine Transaktion." Nein, sage ich dann, das gehört gerade nicht zusammen. Order und Inventory sind zwei Aggregate mit je eigenen Invarianten. Sie in einen gemeinsamen Commit zu zwingen, koppelt sie technisch, erzeugt Lock-Contention und macht das System schwerer skalierbar. Stattdessen bestätigt die Transaktion T1 die Bestellung und schreibt ein OrderConfirmed-Event. Eine separate Transaktion T2 verarbeitet dieses Event und reserviert im Inventory den Bestand.

graph LR
  subgraph T1["Transaction T1"]
    O["Order<br/>confirm()"]
  end
  subgraph T2["Transaction T2"]
    I["Inventory<br/>reserve()"]
  end
  O -- "OrderConfirmed (event)" --> I

Wichtig gegen ein verbreitetes Missverständnis: Eventual consistency heißt nicht „keine Konsistenz". Innerhalb eines Aggregats ist alles strikt und transaktional konsistent – die Order ist nach T1 in jedem Fall ein gültiger Zustand. Zwischen den Aggregaten ist es eventual: Es vergeht eine kurze, aber endliche Zeit, bis das Inventory nachgezogen hat. Diese Grenze bewusst zu setzen, ist eine fachliche Entscheidung. Die Frage lautet immer: Muss diese Regel im selben Augenblick gelten, oder darf sie ein paar Millisekunden später gelten? Nur wenn die Antwort „im selben Augenblick" ist, gehören die Daten ins selbe Aggregat.

Wer diese Trennung von schreibenden Absichten und den daraus folgenden Fakten sauber halten will, landet ohnehin schnell bei der Frage, wie man Commands, Events und Queries trennt. Das Event zwischen T1 und T2 ist genau so ein Fakt: etwas, das passiert ist und andere Aggregate reagieren lässt.

Andere Aggregate nur per Identity referenzieren

Damit die Transaktionsgrenze überhaupt hält, braucht es eine zweite Regel, und sie ist die, gegen die am häufigsten verstoßen wird: Ein Aggregat referenziert ein anderes Aggregat nur über dessen Identität, über die ID, niemals über einen direkten Objekt-Pointer. Vernon führt das als eigene Design-Regel: „Reference Other Aggregates by Identity".

In der Order oben sieht man das bereits. Die Root hält customerId: CustomerId, nicht customer: Customer. Der Unterschied ist keine Kleinigkeit.

// Wrong: the customer is pulled inside the order
class Order {
  private customer: Customer; // pulls another aggregate inside the boundary
}

// Right: only the identity crosses the boundary
class Order {
  private readonly customerId: CustomerId; // reference by identity
}

Sobald Order ein echtes Customer-Objekt hält, verschwimmt die Grenze. Der Entwickler, der die Customer-Referenz sieht, wird sie früher oder später benutzen – order.customer.updateAddress(...) – und schon werden in einer Transaktion zwei Aggregate geändert. Die Referenz per ID verhindert das strukturell: Man kann eine CustomerId nicht versehentlich mutieren. Braucht man wirklich das Kunden-Aggregat, lädt man es vorher über sein eigenes Repository und übergibt gezielt, was gebraucht wird – die ID-Referenz erzwingt also die bewusste Entscheidung.

Der Nebeneffekt ist ebenfalls wertvoll: Aggregate bleiben klein. Es gibt kein Eager-Loading ganzer Objektgraphen, kein „Bestellung laden zieht den Kunden zieht dessen Adressen zieht deren Länder". Es wird geladen, was in der Grenze liegt, und sonst nichts.

Damit die Regeln zusammen im Bild bleiben, hier die Konsistenzgrenze als Diagramm. Innerhalb des umrandeten Blocks liegt das Order-Aggregat mit Root und inneren Objekten; Customer liegt außerhalb, als eigenes Aggregat, und ist nur über die Identität verbunden.

graph TB
  ext["Client code"]
  subgraph agg["Aggregate: Order (consistency boundary)"]
    root["Order (Aggregate Root)"]
    li["LineItem"]
    money["Money (Value Object)"]
    root --> li
    li --> money
  end
  cust["Customer<br/>(separate aggregate)"]
  ext -->|"references the root only"| root
  root -. "customerId (by identity)" .-> cust

Man beachte die Pfeile: Der externe Zugriff zeigt ausschließlich auf die Root, nie auf LineItem oder Money. Und die Verbindung zum Customer ist gestrichelt und mit customerId beschriftet – eine Referenz, kein Enthaltensein.

Aggregate klein halten

Das führt zu Vernons vielleicht praktischster Regel: „Design Small Aggregates". Die Versuchung, Aggregate groß werden zu lassen, ist enorm, und sie hat sogar einen Namen – „object graph greed". Aus Bequemlichkeit schließt man alles ein, was man beim Navigieren gern zur Hand hätte. Ein Customer, der alle seine Orders als Liste hält, ist das Standardbeispiel. Fachlich klingt das plausibel, technisch ist es ein Problem: Jede Änderung an einer einzelnen Bestellung müsste das gesamte Kunden-Aggregat mitsamt aller Bestellungen laden und sperren. Bei einem Kunden mit tausend Bestellungen wird jede kleine Operation teuer, und gleichzeitige Zugriffe blockieren sich gegenseitig.

Die Heuristik, die ich mitgebe, ist einfach: Modelliere entlang echter Invarianten, nicht entlang von Navigationswünschen. Die Frage ist nicht „will ich von A zu B navigieren können", sondern „muss A ungültig werden, wenn B sich ändert, und zwar sofort". Nur wenn zwischen zwei Objekten eine echte, augenblickliche Konsistenzregel besteht, gehören sie ins selbe Aggregat. Für alles andere gibt es Referenzen per ID und, wenn man lesend navigieren will, ein Read Model. Das Aggregat ist ein Schreibmodell; wer Navigationskomfort für die Anzeige sucht, baut sich dafür eine Projection, statt das Aggregat aufzublähen.

Fassen wir die Regeln zusammen, an denen ich ein Aggregat im Entwurf prüfe:

  • Aller Zugriff von außen läuft über die Aggregate Root; innere Objekte sind gekapselt.
  • Die Root setzt die Invarianten durch, und zwar transaktional konsistent nach jeder Änderung.
  • Eine Transaktion ändert genau eine Aggregat-Instanz; alles Weitere läuft über eventual consistency und Domain Events.
  • Andere Aggregate werden nur über ihre Identität referenziert, nie als eingebettetes Objekt.
  • Aggregate bleiben klein und werden entlang echter Invarianten geschnitten, nicht entlang von Navigationswünschen.

Die drei Fallstricke

In der Praxis begegnen mir immer wieder dieselben drei Verwechslungen, und es lohnt sich, sie beim Namen zu nennen.

Der erste und häufigste: das Aggregat mit einer Datenbanktabelle oder einer ORM-Entity gleichzusetzen. Das ist falsch. Das Aggregat ist ein Modellierungs- und Konsistenzkonzept; wie es auf Tabellen abgebildet wird, ist eine völlig getrennte Frage. Ein Aggregat kann über mehrere Tabellen persistiert werden, und eine Tabelle kann mehrere Value Objects enthalten. Die Persistenz ist orthogonal zum Modell. Eine @Entity-annotierte Klasse mit lauter Gettern und Settern ist noch lange kein Aggregat – ihr fehlt das Entscheidende, die durchgesetzte Invariante.

Der zweite: die Aggregate Root als „God Object" misszuverstehen. Weil die Root der einzige Einstiegspunkt ist, landet bei unerfahrenen Teams gern alles in ihr. Das ist das Gegenteil der Absicht. Die Root ist klein und invariantengetrieben. Sie hält genau das, was sie zur Durchsetzung ihrer Regeln braucht – nicht mehr. Eine Root, die alles hält, ist meist ein Zeichen dafür, dass mehrere Aggregate fälschlich zu einem verschmolzen wurden.

Der dritte: direkte Objekt-Referenzen zwischen Aggregaten, den Fehler vom Anfang. Er ist deshalb so tückisch, weil er sich harmlos anfühlt und weil ORMs ihn geradezu einladen – eine @ManyToOne-Beziehung ist schnell annotiert. Aber jede solche Referenz ist eine Einladung, in einer Transaktion mehrere Aggregate zu ändern und die Grenze aufzuweichen. Die Disziplin lautet: per ID referenzieren, und wenn man das fremde Aggregat wirklich braucht, es vorher über sein Repository laden – vor dem Aufruf der Aggregat-Methode, nicht mittendrin.

Eine kurze Abgrenzung

Weil im DDD-Umfeld ständig durcheinandergerät, was zusammengehört und was nicht, eine kurze Klarstellung am Rande. CQRS und Event Sourcing sind nicht dasselbe und beide sind nicht das Aggregat. CQRS trennt Schreib- und Lesemodell, Command von Query. Event Sourcing persistiert den Zustand als Sequenz von Events statt als aktuellen Snapshot. Beide sind unabhängig voneinander einsetzbar – man kann jedes ohne das andere nutzen, wie Fowler und Greg Young betonen. Das Aggregat wiederum ist ein Baustein, der in all diesen Welten vorkommt: Es ist die Konsistenzgrenze, gegen die ein Command validiert und aus der Events entstehen. Wer diese Begriffe sauber auseinanderhält, spart sich viele fruchtlose Architekturdiskussionen.

Und noch eine feine, aber wichtige Unterscheidung: Die Konsistenzgrenze ist nicht die Transaktionsgrenze der Datenbank. Es ist umgekehrt. Das Aggregat definiert die logische Grenze, innerhalb derer alles konsistent sein muss – und die Datenbanktransaktion hat sich daran zu halten. Nicht die verfügbaren Transaktionsmechanismen bestimmen den Zuschnitt der Modelle, sondern die fachlichen Invarianten bestimmen, wie weit eine Transaktion reichen darf. Wer das umdreht und seine Aggregate an den technischen Möglichkeiten des ORM ausrichtet, hat die Grenze am falschen Ende gezogen.

Fazit

Das Aggregat verdient seinen zentralen Platz in DDD, weil es eine der schwierigsten Fragen der Softwareentwicklung beantwortet: Wo endet Konsistenz und wo beginnt Koordination? Es ist kein Datencontainer und kein Persistenzartefakt, sondern eine bewusst gezogene Konsistenz- und Transaktionsgrenze mit einer Root, die ihre Invarianten unerbittlich durchsetzt. Aus dieser einen Idee folgt alles Weitere fast von selbst: eine Transaktion pro Aggregat, Referenzen zwischen Aggregaten nur über Identität, kleine Modelle entlang echter Regeln, und eventual consistency dort, wo augenblickliche Konsistenz weder nötig noch bezahlbar ist.

Wenn Sie das nächste Mal ein Domänenmodell an ein Whiteboard malen, stellen Sie nicht die Frage „welche Objekte gehören zusammen". Stellen Sie die Frage „welche Regeln müssen im selben Augenblick gelten". Die Antwort schneidet Ihre Aggregate – und mit ihnen die halbe Architektur.

Weiterführende Quellen

  • Martin Fowler: DDD_Aggregate – die kompakte Definition von Aggregat und Aggregate Root.
  • Vaughn Vernon: Effective Aggregate Design (2011), Teil I–III – die Regeln „Design Small Aggregates" und „Reference Other Aggregates by Identity".
  • Eric Evans: Domain-Driven Design (2003), Kapitel 6 – die Primärquelle für Aggregate und Aggregate Roots.
  • Greg Young / Martin Fowler zur Abgrenzung von CQRS und Event Sourcing – nützlich, um das Aggregat sauber davon zu trennen.
  • Aus meinem eigenen Vortragsmaterial: das Deck „CQRS + Event Sourcing" (SlideShare), in dem ich diese Grenzziehung an konkreten Beispielen durchspiele.

Kommentare