User Tools

Site Tools


technology:domainmodel:secondaryindex

Mengenindex - Set Index

In DDD heißt es, es gäbe keine transaktional einzuhaltenden, Mengenbasierten Regeln, die mehrere Aggregate umfassen. Ich behaupte, dass die explizite Verwendung von Mengenindizes eine pragmatische Abweichung von der reinen Anwendung von DDD ist. Ein Mengenindex ist im Grunde eine IoC1) von Entitäten und Aggregates, indem aus Entitäten Aggregates werden, und die Aggregates auf den Mengenindex2) zeigen. Durch Mengenindizes kann man den komplizierten Einsatz von Domain Services, SAGAs, Reservation Patterns und Compensating Actions umgehen. Vor allem aber kann man dieselbe Funktionalität von größeren Aggregates haben, und die Aggregates trotzdem klein halten. Man sollte damit natürlich vorsichtig sein, wobei der übermäßige Einsatz auch nicht schadet, weil ein Mengenindex immer relativ einfach eben durch einen anderen Prozess ersetzt werden kann, der Eventual Consistency verwendet, bzw. sofortige Konsistenz mit dem Reservation Pattern.

Wenn man sich relativ sicher sein kann, dass ein Aggregte immer gemeinsam mit dem Mengenindex in derselben Partition liegen wird, dann sollte der Einsatz eines Mengenindexes auch eine langfristige Lösung darstellen. Die Umsetzung ist relativ einfach - ein paar technische Voraussetzungen müssen eingebaut werden. Dann können Mengenindizes sogar so gebaut werden, dass sie sehr gut in DDD passen, denn dann sind sie nichts anderes als weitere Zweit- und Dritt-Aggregates, die die Ereignisse von anderen Aggregates verwenden.

To-Do: Sekundärindex noch in Mengenindex ändern - nur bis hier erledigt

Beispiel Zeitdatensätze

Angenommen die Regeln3) sind folgende

  • Zeitdatensätze dürfen sich nicht überschneiden
  • Wenn der Status des Zeitdatensatzes nicht editierbar ist, darf der Zeitdatensatz nicht verändert werden.

Die erste Regel umfasst mehrere Zeitdatensätze, die Zweite einen einzelnen. Wenn ein Zeitdatensatz noch mehr Verhalten hat als lediglich terminiert zu werden, möchte man ihn auch als Aggregate haben. Für die erste Regel braucht man aber ein Kalender Aggregate, ansonsten könnte man sie nicht einhalten. Also liegt folgende Lösung nahe:

Umsetzung als 2 Aggregate Typen

Es gibt den Zeitdatensatz und man kann ihn im Kalender eintragen/terminieren. Der Zeitdatensatz kann seine eigenen Regeln einhalten, der Kalender die Regel, dass es keine Zeitüberschneidungen geben darf. Aber irgendwie scheint diese Trennung recht künstlich. Wenn der Anwender einen Zeitdatensatz anlegt, und dann explizit im Kalender einträgt, wäre die Trennung auch für den Anwender verständlich. Trotzdem gibt es mehrere Probleme, u.a.

  • Die zweite Regel kann nicht eingehalten werden. Der Status gehört zum Zeitdatensatz. Ein Zeitdatensatz, der gesperrt wurde, darf nicht neu terminiert werden. Das kann nicht konsistent im Kalender geprüft werden. Man könnte den Zeitdatensatz4) zwar vor einer Neuterminierung fragen, ob das in Ordnung ist, das ist aber keine konsistente Prüfung. Man kann sie künstlich konsistent machen indem man in Aggregates einbaut, dass sich andere an sie anhängen können, eine connect oder eine lock Methode, und die Änderungen transaktional speichern, z.B. über einen Domain Service.
  • Der Zeitdatensatz ist für den Anwender nur schließlich konsistent. Da die Ereignisse aus unterschiedlichen Aggregates nicht zwingend in der Reihenfolge behandelt werden, könnte ein Datensatz in einem gesperrten Status mit einer alten Terminierung angezeigt werden. Das wäre irreführend.
  • Wenn der Projektleiter einen Zeitdatensatz genehmigen möchte, tut er das für den gesamten Datensatz, inklusive aller Eigenschaften und der Terminierung. Man könnte das hinter den Kulissen lösen, indem man auf beide Aggregates dieselbe Statusänderung anwendet - aber auch das scheint wieder mal eine rein künstliche und zu komplizierte Lösung für ein einfaches Problem5).
  • Zusammengefasst gehört die Terminierung fest zum Zeitdatensatz, sie ist ein Hauptbestandteil des Datensatzes. Also wäre die Trennung nicht die korrekte Lösung.

Umsetzung als ein großes Aggregate

In diesem Fall ist der Kalender das Aggregate, und die Zeitdatensätze sind Entitäten innerhalb des Kalenders. Das löst alle logischen Probleme:

  • Alles, was zum Zeitdatensatz gehört, steckt im Zeitdatensatz drin,
  • Eine Sicht auf alle Zeitdatensätze enthält niemals eine Überschneidung,
  • Die Regeln können problemlos eingehalten werden.

Es gibt aber technische Probleme:

  • Der Kalender umschließt alle Zeitdatensätze des Mitarbeiters, vom ersten bis zum letzten Tag seiner Beschäftigung. Das führt dazu, dass
    • jeder Methodenaufruf in einem einzelnen Zeitdatensatz immer das Laden des gesamten Kalenders benötigt, was unnötiger Rechenaufwand ist und nach einiger Zeit langsam werden kann,
    • die Methodenaufrufe in zwei getrennten Zeitdatensätzen sich in die Quere kommen können, auch wenn sie gar nichts miteinander zu tun haben, weil sie die Terminierung gar nicht betreffen. Der Projektleiter erhält dann u.U. eine ConcurrencyException wenn er einen Zeitdatensatz von letzter Woche genehmigen möchte, weil der Mitarbeiter gerade einen neuen Zeitdatensatz erfasst hat.

Diese Lösung löst also alle logischen Probleme, führt aber andere Probleme ein, die das Arbeiten mit dem System zu restriktiv machen6). Es muss also eine andere Lösung her.

Lösung als einzelnes Aggregate mit Sekundärindex

Nochmal zusammengefasst, was wir bisher haben:

Es wurde beschrieben, wie man einen Domain Service verwenden kann, um sicher zu stellen, dass sich Zeitdatensätze nicht überschneiden. Der Terminkalender wird als zweites Aggregate umgesetzt und direkt vom Client7) verwendet. Ich meine aber, dass der Client nur mit dem Zeitdatensatz arbeiten möchte. Der Zeitdatensatz wird neu angelegt, im Kalender verschoben, usw. Dafür den Kalender explizit einzuführen ist zwar eine sinnvolle Lösung, der Client muss dabei aber wissen, dass man einen Termin im Kalender erst verschieben kann, nachdem der Termin darauf geprüft wurde, ob er editierbar ist. D.h. der Terminkalender ist nichts anderes als ein Mittel, um Zeitüberschneidungen zu verhindern, die restliche Geschäftslogik ist vollständig im Zeitdatensatz verankert. Bildlich sieht das aus wie folgt:

Die pragmatische Lösung

Um das Programmiermodell für den Client zu vereinfachen, könnte man den Terminkalender hinter dem Zeitdatensatz verstecken. Der Client arbeitet nur mit Zeitdatensätzen, und diese verwenden den Terminkalender um auf Zeitüberschneidungen zu prüfen - transparent, und somit einfach, für den Client. Der Zeitdatensatz könnte wie folgt aussehen:

public class ServiceAction: AggregateRoot
{
  private ISecondaryIndexRepository<EmployeeCalendar> EmployeeCalendarRepository;
 
  public ServiceAction(Guid id, Guid employeeId, DateTime start, DateTime end) // Constructor for adding new ServiceAction
  {
    // vorher vielleicht noch ein paar prüfungen
    var change = ServiceActionAdded(id, employeeId, start, end);    
    var calendar = EmployeeCalendarRepository.GetOrCreate(employeeId);
    calendar.Validate(change); // hier unternimmt der calender = secondary index, eigene prüfungen
    ApplyChange(change), new List<IndexVersion>() { calendar.GetVersion() });
  }
}

Das ist ein bisschen erklärungsbedürftig. Es gibt bekanntermaßen den EventStore:

EventStore
  * ID: Int32
  * AggregateID: Guid (besser string wegen natürlichen IDs - für Später)
  * Version: Int32
  * Data: nvarchar(max)

Neu dazu gekommen ist der SecondaryIndexStore:

SecondaryIndexStore
  * ID: Int32
  * IndexID: string
  * Version: Int32
  * EventStoreID: Int32 (FK to EventStore)

Der SecondaryIndexStore hat einen Primärschlüssel der sich aus der IndexID und der Version zusammen setzt. Wie im EventStore muss die Version zu einer IndexID bei 1 anfangen, in einer Schritten aufsteigen sein, und dabei lückenlos sein8). Man kann jetzt zu jedem Ereignis beliebig viele Sekundärindizes prüfen. Ein Sekundärindex kann z.B. die IndexID 'Terminkalender-rtavassoli@gmail.com' haben. Alle Aggregates, die diesen Index prüfen müssen, können den Domänenereignissen einfach Index Ereignisse mit geben. Es können sogar Aggregates unterschiedlichster Art denselben Index verwenden.

Der Methode ApplyChange() im Aggregate kann somit ein SecondaryIndex mit gegeben werden9). Der SecondaryIndex enthält nach einem Methodenaufruf einfach eine ID und eine Version, aber keine Daten. Die Idee eines Sekundärindizes ist die, dass die Daten aus den Ereignissen der Aggregates verwendet werden10). Wenn der Sekundärindex eigene Daten hätte, wäre es ein vollwertiges Aggregate. Pro Domain Event darf maximal ein SecondaryIndex (ID|Version) gesetzt werden, anders herum kann aber ein SecondaryIndex (ID|Version) mehrere Domain Events umfassen, z.B. wenn der Index nach mehrerern Änderungen geprüft wird11).

Das Repository erkennt beim Speichern, ob an einem Domain Event weitere Ereignisse eines Sekundärindizes hängen. Wenn ja, werden die Sekundärindex Ereignisse zu dem Domain Event gespeichert. So wird 100% sicher gestellt12), dass der Sekundärindex eingehalten wird, denn wenn es eine concurrency violation gibt, dann haben mehrere Aggregates gleichzeitig Änderungen vorgenommen, die den Index betreffen. Wie bei concurrency violations für Aggregates kann die Aktion in dem Fall einfach wiederholt werden.

Das Repository eines Sekundärindizes lädt die Ereignisse, die es für den Index braucht, ganz einfach über

SELECT EventStore.DATA, SecondaryIndexStore.Version FROM EventStore INNER JOIN SecondaryIndexStore ON EventStore.ID = SecondaryIndexStore.EventStoreID WHERE SecondaryIndexStore.IndexID = @IndexID ORDER BY SecondaryIndexStore.Version ASC

Die Aggregates, die den Sekundärindex verwenden, sind dafür verantwortlich, alle Ereignisse, die den Index betreffen, mit SecondaryIndexEvents zu kennzeichnen, ansonsten würden sie nicht mit einbezogen werden. Das heißt

Der Sekundärindex gehört logisch zu den Aggregates, die ihn verwenden. Die Entwicklung der Aggregates und der Indizes muss somit von einem Team und in einem C# Projekt13) passieren. Die Speicherung der Aggregates muss transaktional möglich sein - wenn Aggregate-Typ A transaktional mit dem Index gespeichert wird, und ebenso Aggregate-Typ B, dann auch Aggregate-Typ A zusammen mit Typ B. Alle Aggregates, die den Index verwenden, müssen also immer in ein und derselben Partition liegen.

Der Client kann jetzt einfach einen Zeitdatensatz erstellen14) und über das Repository<ServiceAction> speichern. Der vereinfachte Code für die Neuanlage eines Zeitdatensatzes gegenüber dem Code eines Domain Service sieht dann so aus:

  public void Handle(AddServiceAction command)
  {
    var action_repo = new Repository<ServiceAction>();
    var action = new ServiceAction.Create(command.ID, command.EmployeeId, command.Start, command.End);
    action_repo.Save(action);
  }

Das ist wesentlich einfacher als mit einem Domain Service, und auch erwartungsgemäß. Ich will einen Zeitdatensatz erstellen, welcher für mich eine Entätit ist, mit der ich direkt arbeiten kann. Kein Terminkalender, kein Domain Service, und vor allem keine komplexen Prozesse, die verteilte Entitäten koordinieren müssen.

Auch eine Umterminierung des Zeitdatensatzen ergibt jetzt deutlich mehr Sinn. Die Änderung ist am Zeitdatensatz, also wird die Änderung auch durch den Zeitdatensatz geschützt15). Mit einem Terminkalender Aggregate würde die Änderung nur den Terminkalender betreffen, nicht aber den Zeitdatensatz - und Prüfungen wie z.B. auf den Status des Zeitdatensatzes müssten künstlich mit irgendwelchen Ereignissen wie ServiceActionStatusValidated festgehalten werden.

Warum Sekundärindizes wie kompensierende Aktionen für eine schließliche Konsistenz funktionieren

Es wird ein Index in einer bestimmten Reihenfolge aufgebaut, basierend auf Ereignissen von unterschiedlichen Aggregates, deren Reihenfolge unbestimmt ist. Der Index kann doch gar nicht funktionieren, was, wenn die Reihenfolge der Ereignisse von anderen Beobachtern eine andere ist? Nun, der Index

  • serialisiert Ereignisse für den Index. Die Ereignisse sind auch passiert, d.h. dass der Index wie ein weiteres Aggregate agiert,
  • Die Ereignisse können nun von jemanden anderem durchaus in einer anderen Reihenfolge beobachtet werden, da die Reihenfolge außerhalb der Domäne nur innerhalb eines Aggregates definiert ist. Außerhalb der Domäne stellt der Sekundärindex also auch lediglich eine schließliche Konsistenz sicher. Wenn Termin A von 10:00-12:00 auf 08:00-10:00 verschoben wird, danach Termin B von 12:00-14:00 auf 10:00-12:00, dann bleibt der Sekundärindex erhalten. Wenn jemand anderes aber erst die Verschiebung von Termin B beobachtet, sieht er eine Zeitüberschneidung der beiden Termine von 10:00-12:00 bis er auch die Verschiebung von Termin A sieht. D.h. aus seiner Sicht kann es kurzzeitige Zeitüberschneidungen geben.

Wenn das Ergebnis dasselbe ist wie mit Techniken für verteilte Zeitdatensätze16), die Umsetzung aber einfacher ist, wird die einfachere Variante gewählt, in dem Wissen, dass die Methoden und Techniken bekannt sind, das Ganze auf eine Verteilte Umgebung zu ändern.

Das Ergebnis ist aber stärker als bei verteilter schließlicher Konsistenz, weil in dem Beispiel die Änderung von Termin A nur noch nicht beobachtet wurde, sie hat aber bereits statt gefunden. Man kann somit mit der alten Version von Termin A direkt nichts machen, weil man in der Domäne eine ConcurrencyException erhalten würde.

Moment mal

Der Sekundärindex stellt sicher, dass die Ereignisse für die Aggregates, die den Index betreffen, serialisiert gespeichert werden, und somit auch in der richtigen Reihenfolge veröffentlicht werden17). Wenn ich also einen Denormalisierer habe, der die aus den Zeitdatensätzen eines Mitarbeiters einen Mitarbeiterkalender erstellt, dann hat dieser Kalender niemals überlappende Zeitdaten. Das ist unmöglich, solange der Denormalisierer die Ereignisses aus dem Event Store in der Reihenfolge abarbeitet, in der sie veröffentlicht werden.

D.h., dass im Falle des Vorhandenseins eines Sekundärindexes alle Ereignisse den Index betreffend, die aus einem Event Store kommen18), auch in der Reihenfolge veröffentlicht werden, so dass der Index niemals verletzt sein wird. Wenn man das einhalten kann, dann kann man auch von sofortiger Konsistenz der Regel19) ausgehen, und nicht nur von schließlicher Konsistenz. Man muss also noch folgende Regel definieren und einhalten: Alle Aggregates, die einen Sekundärindex verwenden, müssen in ein und demselben EventStore gespeichert werden. Dadurch werden über den IndexStore alle Ereignisse dieser Aggregates den Index betreffend auch im EventStore serialisiert, was dazu führt, dass man bei der Behandlung der Ereignisse aus dem EventStore 100% sicher sein kann, dass die Regel, die durch den Index geschützt wird, auch niemals verletzt wird.

Ist das zu restriktiv? Mitnichten. Die Lösung als ein großes Kalender-Aggregate ist ja noch restriktiver - alles würde über ein einziges Aggregate laufen, und somit auch in einem EventStore liegen müssen. D.h. dass wir uns mit einem Sekundärindex nicht stärker einschränken als ohne. Im Gegenteil. Die Ereignisse müssen zwar weiterhin im selben Event Store gespeichert werden, aber die Aggregates werden kleiner und somit flexibler, und nur bestimmte Index-betreffende Ereignisse müssen Aggregate übergreifend behandelt werden.

Und zum Abschluss

Eine schließliche Konsistenz im verteilten Mitarbeiterkalender20) herzustellen ist nicht so ohne. Wie stelle ich sie her? E-Mail an Mitarbeiter, dass es eine Überschneidung gibt? Zeitdatensätze nach einer Neuterminierung automatisch in einen Status wird geprüft versetzen, und dann einen Event-Handler bauen, der die Prüfungen serialisiert, und ihn auf geprüft oder Konflikt setzt - nach der Regel, wer zuerst kam, wird zuerst auf geprüft gesetzt.

Alternativ erlaubt man die Möglichkeit einer Überschneidung ohne speziellen Status, und die nachgelagerten Aktionen prüfen auf Überschneidungen. Z.B. würde das Drucken von Reports, das Erstellen von Monatsabschlüssen und das Abrechnen von Zeiten verhindert werden, wenn es Konflikte gibt. Dann gibt es einfach eine Meldung, und der Mitarbeiter muss den Konflikt lösen bevor die Zeiten weiter verarbeitet werden können.

Und noch was

Ein Sekundärindex, wie hier beschrieben, dient eigentlich nur dazu, ein Aggregate, das eines sein sollte, aufzubrechen in mehrere Aggregates. Das bedeutet, dass der Index im Grunde fest auf die eigentliche Aggregate Id geht, im Beispiel des Mitarbeiterkalenders auf die Mitarbeiter-Id. Das bedeutet auch, dass die Id, auf die sich der Index bezieht, unveränderlich21) sein muss, ansonsten hat das Ganze wenig Sinn. Die Aufteilung ist künstlich, weil die Regel den gesamten Kalender betrifft, aber ebenso natürlich und echt, weil man die Einträge einzeln betrachtet, und nicht den gesamten Kalender samt aller Einträge bemühen muss, wenn man in einem Eintrag nur den Kommentar ändert.

Gegen eine Aufteilung des Aggregates in ein Kalender-Aggregate, das nur den Zeitraum des Termins betrifft, und ein ein Detail-Aggregate, das alle weiteren Angaben betrifft, spricht, dass diese Aufteilung unnatürlich ist. Ein Zeitdatensatz wird z.B. als Ganzes abgerechnet. Wenn der Zeitdatensatz einen Status hat, der eine Bearbeitung nicht mehr erlaubt, dann gilt das für den Datensatz im Kalender, und auch für die Details des Datensatzes.

Ein Domain Service könnte natürlich eine Statusänderung immer auf beide Teile anwenden. Was, wenn es aber eine Abweichung gibt, und der Zeitdatensatz den Status erstellt hat, der terminierte Datensatz im Kalender den Status abgelehnt? Was anzeigen, welche Statusänderungsrechte prüfen?

Alternativ könnte jede Änderung im terminierten Teil, also im Mitarbeiterkalender, über einen Domain Service laufen. Der Domain Service würde den Eintrag im Kalender verschieben, und im Zeitdatensatz selbst nur den Befehl AllowRescheduling(von-bis) ausführen. Der Zeitdatensatz müsste dann das Ereignis ReschedulingAllowed(id, von-bis) veröffentlichen, damit alle Prüfungen innerhalb des Domain Service Aufrufs über beide Aggregates geschützt sind.

Die Lösung in diesem Fall laut Blue Book ist, auf eine transaktionale Konsistenz zu verzichten, und die Regel, dass es keine Zeitüberschneidung geben darf, nur schließlich konsistent einzuhalten. Oder man macht aus dem gesamten Kalender samt aller Zeitdatensätze und Details ein (potenziell) riesiges Aggregate. Ich suche Wege, das zu verhindern. Solch ein Sekundärindex ist ein Weg, der mir fast natürlicher erscheint als den Zeitdatensatz in zwei Aggregates aufzuteilen und einen Domain Service zu verwenden, der in diversen Fällen beide anfassen muss. Was ist ein Zeitdatensatz ohne Zeitangaben, und was ist ein terminierter Datensatz ohne dazu gehörige Details?

Also halte ich an dem Sekundärindex fest, und ja, er soll auch im Aggregate gesetzt werden, und nicht versteckt im Event Handler, denn die Regel ist ja fester Bestandteil der Domäne, und nicht was rauf geflaschtes.

1) Inversion of Control
2) das ursprüngliche Aggregate
3) Invariants
4) oder das Query/Read Model
5) wobei sie gar nicht so abwegig ist - kann man sich nochmal Gedanken drüber machen
6) die Einschränkung ist nötig um die Regeln einzuhalten, kann aber zu oft dazu führen, dass der Anwender eine Konfliktmeldung erhält
7) Domain Service
8) einer Schritte sind ja per Definition lückenlos
9) im Fall von oben ist das der calendar
10) Wie das funktioniert, sehen wir gleich
11) i.d.R. wird es aber zu jedem Ereignis, das den Index betrifft, genau eine Index-Version geben
12) Mit optimistic locking
13) am besten in einem Projektordner
14) oder umterminieren, dass würde intern ähnlich ablaufen
15) consistency boundary
16) Schließliche Konsistenz bezüglich Zeitüberschneidungen
17) Transaktionen werden über den EventStore pro Aggregate serialisiert. Transaktionen werden ebenso über den IndexStore pro Sekundärindex serialisiert
18) was nicht der Fall sein muss
19) die vom Index eingehalten wird
20) also ohne Sekundärindex
21) immutable
technology/domainmodel/secondaryindex.txt · Last modified: 2013/02/18 18:23 by rtavassoli