Nachhaltige Angular-Anwendungen mit taktischem DDD und Monorepos

Domain-driven Design in Angular? Taktisches DDD und Monorepos

Manfred Steyer

©Shutterstock / Profit_Image

Taktisches DDD hilft bei der Beherrschung der steigenden Komplexität in Single Page Applications (SPAs) und harmoniert noch dazu gut mit den in der Angular-Welt anzutreffenden Gepflogenheiten. Wie lassen sich bewährte Architekturkonzepte wie z. B. DDD nun in Verbindung mit modernen JavaScript-Businessanwendungen nutzen?

Geschäfts- und Industrieanwendungen sind in der Regel langlebig. Eine Lebenszeitspanne von einer oder mehreren Dekaden ist keine Seltenheit. Dazu kommt, dass immer mehr Teile dieser Anwendungen zur Steigerung der Benutzerfreundlichkeit ins Frontend wandern und dort mittels JavaScript implementiert sind. Somit stellt sich die Frage, wie sich bewährte Architekturkonzepte in der Welt von JavaScript-Frameworks einsetzen lassen.

Dieser Artikel liefert eine Antwort, die sich in der Praxis des Autors bereits mehrfach bewährt hat. Es geht dabei um die Nutzung von Domain-driven Design (DDD) in Angular-Anwendungen sowie um die gemeinsame Nutzung von Best Practices aus beiden Welten. Da bereits in [1] über den Einsatz von Strategic Design in Angular-Anwendungen geschrieben wurde, fokussiert sich dieser Artikel auf die andere Seite der Medaille: Tactical Design. Die verwendeten Beispiele finden sich wie immer in meinem GitHub-Account.

Domain-driven Design (DDD): Vertikale und horizontale Trennlinien

Domain-driven Design (DDD) sieht vor, dass ein Gesamtsystem in mehrere kleine, möglichst autarke Subdomänen zu untergliedern ist. Jede Subdomäne ist separat zu modellieren und erhält ihre eigenen Entitäten, die den jeweiligen Geschäftsbereich bestmöglich widerspiegeln. Dieses Vorgehen nennt sich auch Strategic Design [1]. Sind diese Subdomänen erst einmal identifiziert, stellt sich die Frage, wie sie strukturiert werden sollen. Eine klassische Vorgehensweise sieht die Unterteilung in Schichten vor. Diesen Ansatz verfolgt auch der vorliegende Text (Abb. 1).

Abb. 1: Domänen und Layer

Abb. 1: Domänen und Layer

Alternativ zur Schichtentrennung lassen sich natürlich auch eine hexagonale Architektur oder Ideen aus Clean Architecture einsetzen. Dank des in Angular integrierten Dependency-Injection-Mechanismus gestalten sich auch solche Implementierungen sehr gradlinig.

Wie Abbildung 1 zeigt, führt die verfolgte Vorgehensweise zu einer vertikalen Unterteilung nach Subdomänen und zu einer zusätzlichen horizontalen Unterteilung nach Schichten. Für jene Aspekte, die domänenübergreifend zu nutzen sind, kommt ein zusätzlicher vertikaler Abschnitt mit der Bezeichnung shared zum Einsatz. Dessen fachliche Teile entsprechen dem von DDD vorgeschlagenen Shared Kernel. Zusätzlich beherbergt er technische Bibliotheken, z. B. für Authentifizierung oder Logging.

Jede Schicht erhält nun eine oder mehrere Bibliotheken. Zugriffsregeln zwischen diesen Bibliotheken führen zu einer losen Kopplung und somit zu einer gesteigerten Wartbarkeit. Typischerweise legt man fest, dass jede Schicht nur mit darunterliegenden Schichten kommunizieren darf, aber auch, dass domänenübergreifende Zugriffe lediglich über den Shared-Bereich erlaubt sind. Um zu verhindern, dass zu viel im Shared-Bereich landet, nutzt der hier vorgestellte Ansatz auch APIs, die Building Blocks für andere Domänen veröffentlichen. Das entspricht der Idee von Open Services in DDD.

In Anlehnung an die Enterprise Angular Monorepo Patterns unterscheidet der hier verschlagene Ansatz zwischen fünf Kategorien von Schichten bzw. Bibliotheken (Tabelle 1).

 

Kategorie Beschreibung Beispielhafte Inhalte
feature Beinhaltet Komponenten für einen Use Case. book-flight-component
api Exportiert Building Blocks aus der aktuellen Subdomäne für andere flight (aus Domain-Schicht)
ui Beinhaltet sogenannte „dumme Komponenten“ (Dumb Components), die Use-Case-agnostisch sind und somit wiederverwendet werden können. datetime-component
address-component
adress-pipe
domain Beinhaltet jene Teile des Domänenmodells, die clientseitig zum Einsatz kommen flight
passenger
util Beinhalten allgemeine Hilfsfunktionen formatDate

Tabelle 1: Kategorisierung von Schichten und Bibliotheken

Diese vollständige Architekturmatrix wirkt ein wenig erdrückend, aber wie so oft wird auch hier nichts so heiß gegessen, wie es gekocht wird. Wie die ausgegrauten Blöcke in Abbildung 1 andeuten, befinden sich die meisten Util-Bibliotheken nur im Shared-Bereich, zumal Aspekte wie Authentifizierung oder Logging systemübergreifend zum Einsatz kommen sollen. Dasselbe gilt auch für allgemeine UI-Bibliotheken, die ein systemweites Look and Feel sicherstellen.

Die Use-Case-spezifischen Featurebibliotheken und die domänenspezifischen Domain-Bibliotheken befinden sich hingegen in der Regel nicht im Shared-Bereich. Das wäre zwar im Sinne eines Shared Kernels konform zu Ideen von DDD, da es jedoch zu geteilten Verantwortungsbereichen, mehr Abstimmungsaufwand und Breaking Changes führen kann, sollte damit sparsam umgegangen werden.

Die Domäne isolieren

Um die Domänenlogik zu isolieren, werden ihr Fassaden vorangestellt. Diese bereiten die Domänenlogik für jeweils einen Use Case auf und kümmern sich auch um die Verwaltung von Zuständen (Abb. 2).

Abb. 2: Den Domain-Layer isolieren

Abb. 2: Den Domain-Layer isolieren

Während Fassaden gerade im Angular-Umfeld sehr beliebt sind, korreliert diese Idee auch wunderbar mit DDD, wo von Application Services die Rede ist. Auch Infrastrukturangelegenheiten werden von der eigentlichen Domänenlogik getrennt. Bei SPAs handelt es sich hierbei meist um Serverzugriffe. Somit ergeben sich drei weitere Schichten: Die Application-Schicht mit Fassaden, die eigentliche Domänenschicht und die Infrastrukturschicht.

Diese Schichten können nun ebenfalls in eigene Bibliotheken verpackt werden. Zur Vereinfachung kann man auch dazu übergehen, sie in einer einzigen Bibliothek, die entsprechend untergliedert wird, zu verstauen. Vor allem vor dem Hintergrund, dass die Schichten meist gemeinsam genutzt werden und nur für Unit-Tests ausgetauscht werden müssen, kann diese Entscheidung sinnvoll sein.

Umsetzung mit einem Monorepo

Nachdem die Bestandteile unserer Architektur festgelegt wurden, stellt sich die Frage, wie sie sich in der Welt von Angular umsetzen lassen. Ein sehr üblicher und auch von Google selbst beschrittener Weg ist der Einsatz von Monorepos. Dabei handelt es sich um ein Code-Repository, das sämtliche Bibliotheken eines Softwaresystems beinhaltet. Monorepos vereinfachen unter anderem die Nutzung von geteiltem Code wie dem zuvor diskutierten Shared-Bereich, da dieser nun nicht mehr versioniert und verteilt werden muss. Stattdessen befinden sich immer die aktuellsten stabilen Versionen jeder Library im Master-Branch.

Abb. 3: Abbildung der Domänen und Layer im Monorepo

Abb. 3: Abbildung der Domänen und Layer im Monorepo

Während sich mittlerweile ein mit dem Angular CLI erstelltes Projekt als Monorepo nutzen lässt, bietet das beliebte Werkzeug Nx noch einige zusätzliches Möglichkeiten, die gerade bei großen Unternehmenslösungen wertvoll sind. Dazu gehört die zuvor diskutierte Möglichkeit, Zugriffsbeschränkungen zwischen Bibliotheken einzuführen. Das verhindert, dass jede Bibliothek auf jede andere zugreift und sich somit ein stark gekoppeltes Gesamtsystem ergibt. Daneben kann Nx durch einen Blick in die Git-History auch erkennen, welche Bibliotheken von den letzten Codeänderungen betroffen sind. Diese Information nutzt es, um nur diese Bibliotheken neu zu kompilieren bzw. nur deren Tests laufen zu lassen. Offensichtlich spart das einige Menge Zeit bei großen Systemen, die als Ganzes in einem Repository hinterlegt sind. Um eine Bibliothek im Monorepo zu erstellen, reicht eine Anweisung:

ng generate library domain --directory boarding

Der von Nx nachgerüstete Schalter directory gibt ein optionales Unterverzeichnis an, in dem die Bibliotheken abzulegen sind. Auf diese Weise lassen sie sich nach den Domänen des Systems gruppieren (Abb. 3).

Abb. 4: Isolation des Domänenmodells

Abb. 4: Isolation des Domänenmodells

Die Namen der Bibliotheken spiegeln die Schichten wider. Weist eine Schicht mehrere Bibliotheken auf, bietet es sich an, diese Namen als Präfix zu nutzen. Somit ergeben sich Bezeichnungen wie feature-search oder feature-edit. Zur Isolation des eigentlichen Domänenmodells unterteilt das hier betrachtete Beispiel die Domain-Bibliothek in die drei genannten weiteren Schichten (Abb. 4).

Entitäten

Um die Strukturierung der Domain-Schicht drehen sich die Ideen aus dem DDD-Teilbereich Tactical Design. Im Zentrum stehen die Entitäten, die in den meisten Fällen der Server liefert. Sie spiegeln die Beschaffenheit der Domäne wider und basieren auf dem dort üblichen Vokabular. Listing 1 zeigt neben einem enum zwei Entitäten, die den in objektorientierten Sprachen wie Java oder C# üblichen Gepflogenheiten entsprechen.

public enum BoardingStatus {
  WAIT_FOR_BOARDING,
  BOARDED,
  NO_SHOW
}

public class BoardingList {
  private int id;
  private int flightId;
  private List<BoardingListEntry> entries;
  private boolean completed;

  // Getters and Setters

  public void setStatus(int passengerId, BoardingStatus status) {
    // Complex logic to update status
  }
}

public class BoardingListEntry {
  private int id;
  private BoardingStatus status;

  // Getters and Setters

}

Wie in der Objektorientierung üblich, nutzen diese Entitäten Information Hiding, um sicherzustellen, dass ihr Zustand konsistent bleibt. Sie implementieren das mit privaten Feldern und öffentlichen Methoden, die darauf operieren. Außerdem kapseln diese Entitäten nicht nur Daten, sondern auch darauf optierende Logiken. Diesen Umstand deutet zumindest die Methode setStatus an. Lediglich für Fälle, wo sich Logiken nicht sinnvoll in einer Entität unterbringen lassen, definiert DDD sogenannte Domain Services.

Entitäten, die lediglich Datenstrukturen repräsentieren, sind in DDD hingegen verpönt. Abwertend bezeichnet die Community sie als blutarm (Anemic Domain Model). Von einem objektorientierten Standpunkt gesehen, mag das auch korrekt sein. Allerdings steht bei Sprachen wie JavaScript und TypeScript die Objektorientierung weniger im Vordergrund. Es handelt sich vielmehr um Multiparadigmensprachen, in denen die funktionale Programmierung (FP) einen besonders starken Stellenwert hat. Da jedoch die funktionale Programmierung die Trennung von Datenstrukturen und Logik propagiert, sind Domain-Modelle hier zwangsweise blutarm. Werke, die sich mit funktionalem DDD beschäftigen, unterstreichen das (vgl. [2]), und selbst „Domain-Driven Design Distilled“ von Vauhgn Vernon [3], das als eines der Standardwerke für DDD gilt und primär auf OOP setzt, gesteht ein, dass diese Regeländerung in der FP notwendig ist. Das vorhin betrachtete Entitätsmodell würde demnach in TypeScript in einen Datenteil (Listing 2) und einen Logikteil (Listing 3) getrennt.

export type BoardingStatus = 'WAIT_FOR_BOARDING' 
                           | 'BOARDED' | 'NO_SHOW';

export interface BoardingList {
  readonly id: number;
  readonly flightId: number;
  readonly entries: BoardingListEntry[];
  readonly completed: boolean;
}

export interface BoardingListEntry {
  readonly passengerId: number;
  readonly status: BoardingStatus;
}
export function updateBoardingStatus(
               boardingList: BoardingList, 
               passengerId: number, 
               status: BoardingStatus): Promise<BoardingList> {

  // Complex logic to update status

}

Bei diesen Entitäten fällt auch auf, dass sie öffentliche Eigenschaften nutzen. Auch das ist in der FP durchaus üblich, während der exzessive Einsatz von Gettern und Settern, die lediglich an private Eigenschaften delegieren, oftmals belächelt wird – andere Paradigmen, andere Sitten!

Nun lässt sich natürlich vortrefflich darüber streiten, was der bessere Stil ist. Viel interessanter ist jedoch die Frage, wie die funktionale Welt inkonsistente Zustände vermeidet. Die Antwort ist verblüffend einfach: Datenstrukturen sind bevorzugt unveränderbar (immutable). Das Schlüsselwort readonly in Listing 2 unterstreicht dies. Ein Programmteil, der solche Objekte ändern möchte, muss das entprechende Objekt also klonen, und haben andere Programmteile erst mal ein Objekt für ihre Zwecke validiert, können sie davon ausgehen, dass es valide bleibt.

Aggregate

Um den Überblick über die Bestandteile eines Domänenmodells zu wahren, fasst Tactical DDD Entitäten zu Aggregaten zusammen. In Listing 2 bilden zum Beispiel BoardingList und BoardingListEntry ein Aggregat. Der Zustand sämtlicher Bestandteile eines Aggregats muss als Gesamtes konsistent sein. Beispielsweise könnte man im betrachteten Fall festlegen, dass completed in der BoardingList nur dann auf true gesetzt werden darf, wenn kein BoardingListEntry den Status WAIT_FOR_BOARDING hat. Außerdem dürfen verschiedene Aggregate nicht über Objektreferenzen auf einander verweisen. Stattdessen können sie IDs verwenden. Das soll eine unnötige Kopplung zwischen Aggregaten verhindern. Große Domänen lassen sich somit auf kleinere Gruppen von Aggregaten herunterbrechen.

Vernon schlägt in [3] vor, Aggregate so klein wie möglich zu gestalten. Zunächst einmal solle man jede einzelne Entität als Aggregat ansehen und dann Aggregate, die ohne Zeitverzögerung gemeinsam geändert werden müssen, miteinander verschmelzen.

Fassaden aka Application Services

Die Aufgabe der Application Services ist es, Details des Domänenmodells für bestimmte Use Cases aufzubereiten. Diese Idee erfreut sich in der Welt von Angular auch unabhängig von DDD seit einiger Zeit großer Beliebtheit. Man spricht hierbei auch von Fassaden (Facades).

Das Beispiel in Listing 4veranschaulicht solch eine Fassade in Form eines Angular-Services für das Suchen nach Flügen.

@Injectable({ providedIn: 'root' })
export class FlightFacade {

  private flightsSubject = new BehaviorSubject<Flight[]>([]); 
  public flights$ = this.flightsSubject.asObservable();

  constructor(private flightService: FlightService) {
  }

  search(from: string, to: string, urgent: boolean): void {
    this.flightService.find(from, to, urgent).subscribe(
      flights => {
        this.flightsSubject.next(flights)
      },
      err => {
        console.error('err', err);
      }
    );
  }
}

Während es mittlerweile zum guten Ton gehört, serverseitige Services zustandslos zu gestalten, trifft das nicht für Services in SPAs zu. Eine SPA hat nun einmal einen Zustand, und genau das erhöht auch die Benutzerfreundlichkeit: Man möchte eben nicht alle Informationen immer und immer wieder vom Server abrufen. Diesen Umstand spiegelt auch die betrachtete Fassade wider, indem sie die abgerufenen Flüge für eine spätere Verwendung innerhalb des Use Case vorhält. Dazu nutzt sie Observables. Das bedeutet, dass die Fassade Angular, aber auch andere Systembestandteile informieren kann, wenn sich Zustände ändern.

Events und State Management

Die Implementierung im letzten Abschnitt funktioniert so lange gut, solange nur eine oder wenige Komponenten mit dem Zustand interagieren. Besonders schlimm wird es, wenn viele verschiedene Komponenten den Zustand verändern und somit Inkonsistenzen entstehen. Es können aber auch zyklische Abhängigkeiten entstehen. Deswegen müssen sich Entwicklungsteams bei SPAs früher oder später die Frage stellen, wie damit umzugehen ist.

Auf der anderen Seite ist der Einsatz von Observables eine gute Idee. Ein damit geschaffenes reaktives System verbessert die Datenbindungsperformance und sorgt für Entkopplung, da sich Sender und Empfänger theoretisch nicht direkt kennen müssen. Das passt auch zu DDD, wo der Einsatz von Domain Events mittlerweile zur Tagesordnung gehört: Passiert in einem Anwendungsteil etwas Interessantes, versendet er ein Domain Event, und andere Anwendungsteile können darauf reagieren. Im betrachteten Fall könnte solch ein Domain Event anzeigen, dass sich ein Passagier oder alle Passagiere eines Flugs nun im Zustand BOARDED befinden.

Diese beiden Fliegen, State Management und Eventing, lassen sich dem populären Redux-Muster auf einmal schlagen. Der vorliegende Text soll zwar keine Einführung in Redux werden. Nichtsdestotrotz beleuchten die nächsten Beispiele, wie sich eine darauf aufbauende Architektur anfühlt. Dazu kommt @ngrx/store, der De-facto-Standard für Redux im Angular-Umfeld, zum Einsatz.

Beim Einsatz von @ngrx/store wird zunächst der Anwendungszustand für jedes Feature der Anwendung modelliert. Das passiert typischerweise durch die Bereitstellung von Interfaces. Für Events, aber auch für Kommandos, die zu einer Zustandsänderung führen, sind sogenannte Actions zu modellieren. Im Wesentlichen sind das Objekte mit einem Typ und weiteren Properties. Der Typ beschreibt die Art des Events bzw. Kommandos (Listing 5).

export const loadFlightsSuccess = createAction(
  '[Booking] Load Flights Success',
  props<{ flights: Flight[] }>()
);

Der Typ der hier betrachteten Action lässt darauf schließen, dass die Action darüber informiert, dass Flüge geladen wurden. Die Properties beinhalten diese Flüge. Die einzelnen Programmteile – man spricht bei NgRx auch von Features – erhalten Reducer, die auf die Actions reagieren und den globalen Anwendungszustand aktualisieren. Der Reducer in Listing 6 reagiert beispielsweise auf die zuvor betrachtete loadFlightsSuccess Action. Er nimmt den aktuellen Zustand sowie die Action mit ihren Properties entgegen und verändert daraufhin den Zustand. Genaugenommen klont er den Zustand, da @ngrx/store die vorhin diskutierten Immutables erzwingt. Den so entstehenden neuen Zustand liefert der Reducer zurück.

const bookingReducer = createReducer(
  initialState,

  on(loadFlightsSuccess, (state, action) =>
    […]
  )
);

Um nun eine Action auszulösen, besorgt sich ein Programmteil via Dependency Injection den sogenannten Store, der sich um die Verwaltung der Zustände kümmert. Seine dispatch-Methode versendet die gewünschte Aktion an alle Reducer, ohne diese direkt zu kennen:

this.store.dispatch(loadFlightsSuccess({flights}));

Ähnlich einfach gestaltet sich das Abrufen von Daten aus dem Store:

public flights$ = this.store.pipe(select(state => state.booking.flights));

Hierbei erhält der Aufrufer nicht den Wert selbst, sondern ein Observable, das diesen Wert repräsentiert. Das bedeutet, dass sich der Aufrufer über jede Änderung informieren lassen kann.

Der Einsatz von Redux lässt sich für den Rest der Anwendung hinter der im letzten Abschnitt besprochenen Fassade verbergen. Der Konsument der Fassade merkt also von einem eventuellen Einsatz von Redux nichts, und somit kann ein Team Redux auch erst im Nachhinein einführen, wenn die Komplexität bestimmter Anwendungsfälle es rechtfertigen.

Domain-driven Design und Micro Frontends?

Die Ideen von Domain-driven Design lassen sich bekanntlich für das Erstellen von Microservices-Architekturen nutzen. Genauso lässt sich DDD auf dem Client auch als Grundlage für Micro Frontends verwenden. Ob sich nämlich ein Deployment-Monolith, Micro Frontends oder irgendetwas dazwischen ergibt, hängt von der Verwendung des Monorepos ab. Richtet das Team pro Domäne eine eigene Anwendung im Monorepo ein, geht es einen großen Schritt in Richtung Micro Frontends (Abb. 5).

Abb. 5: DDD auf dem Client als Grundlage für Micro Frontends

Abb. 5: DDD auf dem Client als Grundlage für Micro Frontends

Die Zugriffsbeschränkungen sichern eine lose Kopplung und ermöglichen sogar eine spätere Aufteilung auf mehrere Repositories, falls das zur Entkopplun©g als vorteilhaft angesehen wird. Dann kann von Micro Frontends im klassischen Sinn einer Microservices-Architektur gesprochen werden. Das Team muss sich dann jedoch, wie bei auch bei Microservices üblich, um das Versionieren und Verteilen der Bibliotheken aus dem Shared-Bereich kümmern.

Fazit

Moderne Single Page Applications sind häufig mehr als Empfänger von Data-Transfer-Objekten. Sie beinhalten einiges an Logik, und das erhöht die Komplexität. Ideen von DDD helfen, die dadurch entstehende Komplexität zu beherrschen. Aufgrund des objektfunktionalen Charakters von TypeScript und den dort vorherrschenden Gepflogenheiten sind ein paar Regeländerungen notwendig.

Der Einsatz von Monorepos mit mehreren Bibliotheken, die nach Domänen gruppiert werden, hilft beim Aufbau der grundsätzlichen Struktur, und Zugriffsbeschränkungen zwischen Bibliotheken verhindern eine Kopplung zwischen Domänen. Fassaden bereiten das Domänenmodell für einzelne Use Cases auf und kümmern sich um das Vorhalten von Zuständen. Bei Bedarf lässt sich hierzu Redux hinter der Fassade nutzen, ohne dass der Rest der Anwendung etwas davon bemerkt. In diesem Fall erhält man auch einen Eventing-Mechanismus frei Haus. Ganz nebenbei schafft ein Team durch den Einsatz von DDD am Client auch die Voraussetzung für Micro Frontends.

Geschrieben von
Manfred Steyer
Manfred Steyer
Manfred Steyer ist selbstständiger Trainer und Berater mit Fokus auf Angular 2. In seinem aktuellen Buch "AngularJS: Moderne Webanwendungen und Single Page Applications mit JavaScript" behandelt er die vielen Seiten des populären JavaScript-Frameworks aus der Feder von Google. Web: www.softwarearchitekt.at
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: