Domain-Driven Design – Taktische Modellierung: Teil 3
Aggregate Design am konkreten Beispiel

In diesem Artikel betrachten wir anhand eines konkreten Szenarios Fragestellungen, die bei der Anwendung von taktischem Design häufiger auftauchen. Im Vorgänger-Artikel wurden bereits klassische DDD Building Blocks beschrieben und einige ergänzende Patterns erläutert. Diese werden hier nun im praktischen Einsatz gezeigt. Dazu nutzen wir als Fachkontext eine Teildomäne aus dem Hotelmanagement und werden Vorgänge modellieren, die typischerweise an der Rezeption passieren. Der Schwerpunkt des Artikels liegt dabei auf den typischen Abwägungen, die Einfluss auf den passenden Schnitt der Aggregates nehmen. Dabei wird beschrieben, wie man sich schrittweise und systematisch einem sinnvollen Design nähern kann.
Domain-Driven Design: Das Szenario
Wir wollen ein System entwerfen, welches die Abläufe einer Hotelrezeption abbildet. Dafür starten wir mit einem Bounded Context "Rezeptionsdienste". Aus einem ersten kurzen Event-Storming-Workshop könnte die Skizze in Abb. 1 für diesen Vorgang im Rahmen des "Check-in" entstanden sein.
Wir gehen in unserem fiktiven Szenario davon aus, dass Buchungen über diverse externe Online-Buchungssysteme in der Hotelsoftware eingehen. Etwaige Buchungen müssen dem Rezeptionisten/der Rezeptionistin am Check-in-Schalter zur Verfügung gestellt werden, wenn ein Gast einchecken möchte. Es kann zudem auch vorkommen, dass ein Gast spontan eincheckt, der nicht über eines der genannten Online-Buchungssysteme gebucht hat. Beim Check-in werden Gastdaten geprüft und ggf. vervollständigt. Es wird ein entsprechendes Zimmer ausgewählt und dem Gast zugeordnet. Mit dem Aushändigen des Zimmerschlüssels ist der Check-in technisch erstmal abgeschlossen. Soweit kennt wahrscheinlich jeder diesen Ablauf ungefähr in dieser Form.
Wenn wir nun die wesentlichen Dinge in diesem Szenario versuchen zu erfassen, dann fallen uns direkt die Begriffe "Buchung", "Gast" und "Zimmer" auf, die beim Vorgang "Check-in" eine Rolle spielen. Bei einer klassischen Vorgehensweise könnte man diese Dinge dann in einem ER-Diagramm wie in Abb. 2 abbilden.
Nehmen wir an, das Design der Lösung wird für eine Hotelkette durchgeführt, dann müssen mehrere "Hotels" verwaltet werden. Jedes "Hotel" verfügt tendenziell über mehrere "Zimmer". Beim "Check-in" muss die “Buchung”, zu welcher immer genau ein Haupt-"Gast" gehört, dann einem "Zimmer" zugeordnet werden. Im Laufe der Zeit sammeln sich für jedes "Zimmer" mehrere "Buchungen" an, in denen zugehörige "Gäste" übernachtet haben. All diese Dinge sind Kandidaten für mögliche Aggregates. Generell spielt beim Design von Aggregate-Strukturen die Betrachtung von unterschiedlichen Einflussfaktoren eine Rolle. Diese werden nun nachfolgend genauer dargestellt.
Domain-Driven Design: Aggregate-Design und die Frage nach der richtigen Größe
Bevor wir anhand des dargestellten Beispiels ins Aggregate-Design starten, rufen wir uns ins Gedächtnis, dass ein Aggregate eine Gruppe von Domänenobjekten (Entities oder ValueObjects) darstellt, die als logische Einheit behandelt werden sollen. Aggregates definieren dabei eine transaktionale Grenze. Das bedeutet, dass bei Zustandsübergängen das komplette Aggregate idealerweise im Rahmen von einer Transaktion von einem Ausgangszustand in einen konsistenten (d. h. den Geschäftsregeln entsprechenden) Folgezustand abgebildet wird. In diesem Szenario lautet eine wesentliche Geschäftsregel, dass insbesondere Doppel-"Belegungen" von "Zimmern" zu vermeiden sind. Wenn wir also diesen Aspekt in den Vordergrund stellen und dabei im Hinterkopf haben, dass Geschäftsregeln idealerweise innerhalb von einem Aggregat abgebildet werden, dann würde das dafür sprechen, das "Hotel" als Wurzel-Entity eines (sehr großen) Aggregates zu definieren. Die entsprechende Logik für die "Belegung" eines "Zimmers" würde innerhalb des Aggregates "Hotel" abgebildet. Jeder schreibende Zugriff auf ein "Hotel" im Rahmen eines "Check-in" würde transaktional abgesichert (per optimistischem Locking quasi-serialisiert) stattfinden, d. h. der Ablauf würde in etwa so aussehen:
- Der Command für den "Check-in" wird abgesetzt.
- Das "Hotel" mit all seinen "Zimmern" und aktuellen "Belegungen" wie auch "Buchungen" wird geladen.
- Dabei kann das Aggregate "Hotel" intern prüfen, ob das beim "Check-in" zugeordnete "Zimmer" noch frei ist und kann, falls das so ist, das "Zimmer" entsprechend der "Buchung" zuordnen, den "Status" der "Buchung" auf "eingecheckt" setzen und den neuen Zustand speichern.
- Konkurrierende, schreibende Zugriffe auf dasselbe "Hotel" müssten im Sinne der transaktionalen Aggregate-Grenze bspw. mit einer "OptimisticLockException" abgewiesen werden.
Nun sehen wir direkt das primäre Problem mit diesem Design. Es ist wahrscheinlich, dass es auch innerhalb eines Hotels mehrere konkurrierende Schreib-Zugriffe auf die dem "Hotel" untergeordneten Domänenobjekte "Zimmer", "Buchung", "Belegung" usw. geben wird. Es finden potentiell parallele "Check-in"-Vorgänge in ein und demselben "Hotel" statt. Außerdem können über externe Online-Buchungssysteme jederzeit neue "Buchungen" eingehen. Es könnte auch sein, dass Wartungsarbeiten stattfinden, so dass diverse "Zimmer" für diese Zwecke für längere Zeiträume "belegt" werden müssen usw. Wählen wir Aggregates zu groß, dann haben wir potentiell sehr viele Konflikte durch potentiell parallel stattfindende Prozesse und müssen anders dafür sorgen, dass Konfliktsituationen gut aufgelöst werden können. Ebenso verursachen übermäßig groß gewählte Aggregates tendenziell auch Performance-Probleme. Es ist sehr aufwändig, für jeden Vorgang alle Daten über alle "Zimmer", "Belegungen" und "Buchungen" eines Hotels entweder zu laden oder (evtl. abgemildert per Lazy Loading) im Zugriff zu haben.
Machen wir hingegen jedes Domänenobjekt zu einem eigenständigen Aggregate, dann haben wir mehr Businesslogik hinsichtlich der Konsistenzerhaltung in DomainServices. Wir haben mehr Aufwand in dieser Ebene der Geschäftslogik, um entsprechende Objekte zu laden und zusammenzuführen. Außerdem haben wir dadurch potentiell häufiger repetitive Strukturen in DomainServices. So landen wir in der Extremausprägung wieder bei einem anämisch anmutenden Design (s. voriger Artikel), mit dem Resultat, dass die Businesslogik fast ausschließlich über DomainServices und ApplicationServices abgebildet werden müsste, was beim taktischen Design eher vermieden werden soll.
Es gilt also einen sinnvollen Mittelweg zu finden, der gut für die Anforderungen unseres Bounded Contexts geeignet ist. Vernon empfiehlt, für ein sinnvolles Design hinsichtlich Performance und Skalierbarkeit mit eher kleinen Aggregaten zu arbeiten [1]. Im vorliegenden Beispiel könnte folgendes Design zielführend sein: Wir definieren neben dem "Hotel" als eigenständiges Aggregate ebenso das "Zimmer" als Aggregate wie auch die "Buchung". Alle drei Objekte haben jeweils einen unabhängigen Lebenszyklus. Für "Hotel" müssen Name und Adresse flexibel gehalten werden. Ein "Hotel" muss unabhängig davon jedoch eindeutig identifizierbar sein. Änderungen an den zugehörigen "Zimmern" und "Buchungen" erfolgen unabhängig vom übergeordneten "Hotel".
Verschiedene "Buchungen" gehen unabhängig voneinander ein und verändern auch ihren Zustand jeweils unabhängig voneinander. Ein "Gast" muss grundsätzlich immer für jede "Buchung" angegeben sein. Das Domänenobjekt "Gast" ist in diesem Kontext eng mit der "Buchung" verbunden. Bei diesem Design wird bewusst darauf verzichtet, "Gäste" unabhängig von "Buchungen" zu verwalten. Die Angaben zu einem "Gast" können sich jedoch über die Zeit hinweg ändern und werden zumindest im Rahmen des "Check-in" nochmals überprüft, ggf. angepasst und vervollständigt. Deshalb wird der "Gast" hier als untergeordnete Entity zu einer "Buchung" modelliert.
Die hier verfolgte Idee beschreibt, dass ein "Zimmer" seine "Belegungen" verwaltet. Eine "Belegung" ohne zugehöriges "Zimmer" macht keinen Sinn. Eine "Belegung" wird beispielsweise im Rahmen eines "Check-in" neu angelegt oder beim "Check-out" wieder entfernt. Ein "Zimmer" verwaltet dabei nur die "Belegungen", die man aktuell im Blick haben muss (also aktuelle oder ggf. bereits für die nähere Zukunft vorgesehene). Eine "Belegung" verfügt in diesem Sinne über keinen eigenständigen Lebenszyklus. Sie wird angelegt und später wieder entfernt und kann damit gut als ValueObject abgebildet werden. Wie in diesem Fall die Konsistenz hinsichtlich der Vermeidung von Doppelbelegungen effizient geregelt werden kann, wird nachfolgend beschrieben.
Domain-Driven Design: Transaktionale Konsistenz beim "Check-in"
Die einzelnen untergeordneten Use Cases, aus welchen sich der Gesamtablauf des "Check-in" zusammensetzt, werden beim Applikationsdesign von "Domänen isolierenden" Architekturstilen, wie im vorigen Artikel beschrieben, über sogenannte ApplicationServices in den Domain Core geleitet.
Diese Use Cases setzen sich ganz klassisch aus Commands und Queries zusammen. Neben den Domain Commands "CheckeEin" und "VervollstaendigeGastDaten" werden diverse Abfragen auf Aggregates benötigt, um dem Benutzer über die UI alle notwendigen Informationen für den entsprechenden Vorgang bereitzustellen. Z. B. wird eine Auflistung aller aktiven "Buchungen" benötigt, um die passende "Buchung" zur Bearbeitung beim "Check-in" auswählen zu können. Für die Zuordnung eines freien "Zimmers" zur "Buchung" wird die Auflistung der verfügbaren "Zimmer" mit den entsprechenden Kriterien, die zur "Buchung" passen, in der UI dargestellt werden müssen.
Als abschließender Schritt wird dann über den DomainCommand "CheckeEin" die "Buchung" final eingecheckt. Im Rahmen der Abarbeitung dieses DomainCommands muss das "Zimmer" belegt und das ausgewählte "Zimmer" der "Buchung" zugeordnet werden. Da sich dieser Zustandsübergang bei dem angesetzten Aggregate-Design über zwei Aggregates erstreckt, bilden wir diese übergreifende Geschäftslogik in einem DomainService "Buchungseingang" ab. Wir erinnern uns: Geschäftslogik, die nicht exakt einem Aggregate zugeordnet werden kann, fällt typischerweise in einen DomainService. Wir wählen in diesem Fall bewusst die Abbildung über eine Aggregate-übergreifende Transaktion, was nicht die Standardlösung in einem typischen DDD ist. Die Standardlösung wäre die Herstellung Aggregate-übergreifender Konsistenz über DomainEvents und "Eventual Consistency".
Um besser zu verstehen, warum wir in diesem Fall mit einer übergreifenden Transaktion arbeiten, betrachten wir den alternativen Lösungsansatz über DomainEvents. In diesem Fall würde beispielsweise in einer ersten Transaktion zuerst die "Buchung" als eingecheckt markiert werden. Anschließend, nach Erhalt eines entsprechenden DomainEvents, würde das zugehörige "Zimmer" belegt werden. Nun könnte der Fall eintreten, dass das entsprechende "Zimmer" bereits durch einen parallel stattfindenden "Check-in"-Vorgang einer anderen "Buchung" zugeordnet wurde. In diesem Fall müsste nun die "Zimmer"-Zuordnung nochmals nachträglich korrigiert werden. Das wäre ungünstig. Es wäre die Aufgabe des Mitarbeiters an der Rezeption, die Konsistenz hinsichtlich "Buchung" und "Zimmer"-"Belegung" wiederherzustellen. In solchen Fällen, also wenn die Verantwortlichkeit für die übergreifende Konsistenz beim Auslöser eines Zustandsübergangs liegt, empfehlen Vernon und Evans, mit einer Aggregate-übergreifenden Transaktion und klassischer "Transactional Consistency" zu arbeiten.
Domain-Driven Design: Eventual Consistency über DomainEvents am Beispiel des "CheckOut"
Auf Basis des bereits dargestellten Aggregate-Designs muss ebenfalls beim "CheckOut" ein schreibender Zugriff auf mehrere Aggregates erfolgen. Insbesondere muss die betroffene "Buchung" nun als "ausgecheckt" markiert werden. Ebenso muss das betroffene "Zimmer" wieder freigegeben werden. Es kann hierbei prinzipiell jedoch nicht zu fachlichen Konflikten kommen wie beim "Check-in" zuvor beschrieben (gleichzeitiger "Check-in"-Versuch mit demselben "Zimmer"). Dieser Fall eignet sich sehr gut für die Herstellung Aggregate-übergreifender Konsistenz über ein DomainEvent und "Eventual Consistency". "Eventual Consistency" bedeutet hier, dass beide Aggregates in getrennten Transaktionen geändert werden. Erst nach erfolgreicher Durchführung der zweiten Transaktion herrscht aus übergreifender Perspektive ein konsistenter Zustand: Die "Buchung" ist "ausgecheckt" und das "Zimmer" ist freigegeben. Schlägt die zweite Transaktion aus technischen Gründen fehl, so können technische Reparatur-Mechanismen (bspw. eine Retry-Mechanik oder anderweitige Wiederholung der Event-Verarbeitung) mit hoher Wahrscheinlichkeit die Konsistenz wiederherstellen, ohne dass ein Benutzer direkt eingreifen muss. Die kurzzeitige Verzögerung oder auch ein kurzzeitig aus übergreifender Sicht inkonsistenter Zustand stört den Betriebsablauf nicht.
Der Ablauf beim "CheckOut" ist strukturell im Code abgebildet, so wie im obigen Diagramm: Der Command "CheckeAus" wird vom ApplicationService "BuchungUseCases" entgegengenommen. Beim "CheckOut" finden unter Umständen noch einige Prüfungen statt, die wir in den bisherigen Beschreibungen unterschlagen haben, z. B. wird geprüft, ob eine Rechnung für das "Zimmer" und ggf. weitere Dienstleistungen, die der "Gast" in Anspruch genommen hat (Zimmerservice u. Ä.), erstellt wurde. Diese Logik ist im obigen Diagramm der Übersichtlichkeit halber ausgeblendet. Sie ist jedoch der wesentliche Grund, warum der Checkout in einem gesonderten DomainService untergebracht ist. Dieser DomainService markiert die betroffene "Buchung" als ausgecheckt und speichert diesen Zustand über das Repository "Buchungen". Anschließend wird das DomainEvent "Buchung-Ausgecheckt" emittiert. Auf dieses hört nun wiederum der ApplicationService "ZimmerUseCases". Dieser beendet die aktuelle "Belegung" des Zimmers und speichert auch diesen veränderten Zustand über das Repository "Zimmerverwaltung" in der Datenbank. Die Verarbeitung des DomainEvents erfolgt in einer gesonderten Transaktion. Erst jetzt ist die letztendliche Konsistenz ("Eventual Consistency") hergestellt.
DevOps auf den diesjährigen IT-Tagen
Spannende Vorträge und Workshops zum Thema DevOps erwarten Euch auch auf den IT-Tagen, der Jahreskonferenz von Informatik Aktuell. Die IT-Konferenz findet jedes Jahr im Dezember in Frankfurt statt – dieses Jahr vom 08.-11.12.
Domain-Driven Design: Fazit
Auch für Fortgeschrittene ist bei taktischem Design die Frage nach der richtigen Aggregate-Größe und die damit verbundene Fragestellung, ob klassische transaktionale Konsistenz oder "Eventual Consistency" per DomainEvents angewendet werden soll, oft nicht trivial. Insbesondere in Fällen, bei denen ein übermäßig großes Aggregate nicht gleich ins Auge fällt, wie im beschriebenen Beispiel. Es hilft oft, wenn man sich bewusst macht, dass es immer mehrere Optionen gibt. Es gilt nicht den vermeintlich perfekten Weg zu finden, sondern eine für die spezifischen Bedürfnisse im entsprechenden Bounded Context adäquate Struktur. Zudem ist wichtig zu verstehen, dass insbesondere die Anforderungen in Verbindung mit Schreibvorgängen bzgl. der Konsistenzerhaltung einen wesentlichen Einfluss auf den Schnitt der Aggregates haben.
Bei taktischem Design kann man durchaus auch leichtgewichtig anfangen und einzelne Konzepte in der Strukturierung des Codes einführen, wie etwa die Unterscheidung von Entities und ValueObjects. Sobald man dann einige Schritte weitergeht, kommt man früher oder später sehr wahrscheinlich zu einigen der beschriebenen Fragestellungen. Wir hoffen, hiermit einige Hinweise gegeben zu haben, wie man diese Fragestellungen gut lösen kann. In komplexen Szenarien gewinnt man über das modellgetriebene Vorgehen und den zugehörigen Stil der Codierung an Klarheit beim Design und der detaillierten Abbildung der Fachlichkeit. Zudem gewinnt der Code in hohem Maß an Verständlichkeit und Lesbarkeit. Weiterhin schafft man strukturelle Eigenschaften, die wichtig sind, um in komplexen Szenarien mittel- und langfristig Änderungen und Erweiterungen regelmäßig, beherrschbar und ohne unerwünschte Seiteneffekte durchführen zu können. Für das vollständige Szenario und alle hier dargestellten Beispiele gibt es ein frei zugängliches Demo-Repository, bei welchem alle Zusammenhänge auch auf Code-Ebene nochmals nachvollzogen werden können [2].