Angular bietet sich aufgrund seiner Struktur und des gebotenen Leistungsumfangs für die Umsetzung großer Frontends an. Um solche Systeme beherrschbar zu gestalten, gilt es, sie in kleine und wenig komplexe Module zu untergliedern. Das ist soweit bekannt. Die Frage, die sich hierbei jedoch immer wieder aufdrängt, ist, nach welchen Kriterien der Modulschnitt erfolgen soll. Außerdem gilt es, festzulegen, wie diese Module zu implementieren sind, aber auch, wie sie untereinander kommunizieren können.
Dieser Artikel gibt zunächst eine Antwort basierend auf den Ideen des Strategic Designs aus DDD. Danach zeigt der Artikel, wie sich eine solche Architektur mit Nx [1], einer populären und freien Erweiterung für die Angular CLI, umsetzen lässt. Außerdem besprechen wir ausgewählte Ideen aus Tactical Design, die bei der Umsetzung helfen. Die verwendeten Beispiele finden sich wie immer in meinem GitHub-Account [2].
Vertikale und horizontale Trennlinien
Domain Driven Design sieht vor, dass ein Gesamtsystem in mehrere kleine, möglichst autarke Subdomänen zu untergliedern ist. Dieses Vorgehen nennt sich auch Strategic Design.
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).
Alternativ zur Schichtentrennung lassen sich 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. Seine fachlichen Teile entsprechen dem von DDD vorgeschlagenen Shared Kernel. Zusätzlich beherbergt er technische Bibliotheken, z. B. für Authentifizierung und 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 angular-enterprise-monorepo-patterns [3] unterscheidet der hier vorgeschlagene 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
Die 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 und 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 Domainbibliotheken 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 [4] 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).
Während Fassaden genau hierfür 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 aktuellen stabilen Versionen jeder Library im Master Branch.
Während sich mittlerweile ein mit dem Angular CLI erstelltes Projekt als Monorepo nutzen lässt, bietet das beliebte Werkzeug Nx [1] noch einige zusätzliche 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 auch durch einen Blick in die Git History erkennen, welche Bibliotheken von den letzten Codeänderungen betroffen sind. Diese Information nutzt es, um nur die betroffenen Anwendungen neu zu kompilieren bzw. nur die Tests der betroffenen Bibliotheken laufen zu lassen. Offensichtlich spart das eine Menge Zeit bei großen Systemen, die als Ganzes in einem Repository hinterlegt sind.
Wurde erst mal das Angular CLI installiert, genügt der Einsatz von npm init zum Erzeugen eines neuen Nx Monorepo: npm init nx-workspace eProcurement.
Dieser Befehl lädt ein Script herunter, das daraufhin den Workspace unter Nutzung des CLI einrichtet. Um eine Bibliothek im Monorepo zu erstellen, reicht die Anweisung ng generate library domain –directory catalog.
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).
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-browse-products oder feature-request-products.
Zur Isolation des eigentlichen Domänenmodells unterteilt das hier betrachtete Beispiel die Domain-Bibliothek in die drei genannten weiteren Schichten (Abb. 4).
Entitäten und clientseitiges DDD
Manche Leute mögen sich am Begriff Entität bei der Nutzung von DDD am Client stoßen, zumal es üblich ist, dass lediglich die für einen konkreten Use Case benötigten Daten im Rahmen von Data-Transfer-Objekten (DTOs) übers Netzwerk gesendet werden. Auf der anderen Seite sind moderne SPAs mehr als nur der Empfänger von DTOs. Gerade die Vielzahl von Bibliotheken und Artikeln rund um das Thema Zustandsmanagement zeigt, dass sich bei vielen Clients reichhaltige Modelle materialisieren, die dem aus Entitäten und Aggregaten bestehenden Domänenmodell aus DDD gleichen.
Während das klassische DDD einen sehr klassischen Einsatz der Objektorientierung vorsieht, verfolgen moderne Web-Frontends in der Regel das funktionale oder zumindest das objektfunktionale Paradigma. Werke, wie „Functional Domain Modeling“ [5] und „Functional and Reactive Domain Modeling“ [6] widmen sich genau diesem Thema und stellen fest, dass es hier Regeländerungen u. a. in Hinblick auf Entitäten bedarf. Im Gegensatz zur Objektorientierung trennt die funktionale Programmierung nämlich zwischen Datenstrukturen und darauf operierenden Logiken, die in Form von Funktionen bereitgestellt werden:
Export interface Product {
Readonly id: number;
Readonly productCategoryId: number;
Readonly name: string;
Readonly pictures: ProductPicture[];
Readonly prices: ProductPrice[];
}
Anhänger des klassischen DDD bezeichnen so etwas abschätzig als blutarme Entitäten (Anemic Entities). Um einen konsistenten Zustand zu garantieren, sind solche Entitäten in der Regel immutable. Das bedeutet, dass sie sich nicht ändern können. Stehen Änderungen an, sind sie gegen ein neues Objekt zu tauschen. Ist eine Objekt-instanz also erst mal erfolgreich validiert, kann davon ausgegangen werden, dass sie valide bleibt.
LUST AUF NOCH MEHR DOMAIN-DRIVEN DESIGN-TRENDS?
Entdecke Workshops vom 25. - 27. November 2024
Abhängigkeiten visualisieren
Zum Visualisieren der Abhängigkeiten zwischen Bibliotheken erstellt das von Nx angebotene npm-Skript dep-graph einen Abhängigkeitsgraphen (npm run dep-graph). Abbildung 5 zeigt den Abhängigkeitsgraphen für das hier verwendete Beispiel.
Zur besseren Lesbarkeit habe ich die Zugehörigkeit zu einzelnen Domänen bzw. zum Shared Kernel mit unterschiedlichen Symbolen eingezeichnet. Diese machen ersichtlich, dass die Umsetzung die oben erwähnten Zugriffseinschränkungen einhält. Bibliotheken einer Domäne kommunizieren demnach nur mit anderen Bibliotheken derselben Domäne oder mit dem Shared Kernel. Außerdem veröffentlicht das Catalog API ausgewählte Aspekte für die Ordering-Domäne.
Während dieser Graph einen netten Überblick über das Gesamtsystem gibt, ist es sehr schwierig, die einzelnen Zugriffseinschränkungen ständig auf diese Weise zu kontrollieren. Wie der nächste Abschnitt zeigt, bietet Nx aber auch hierfür eine Lösung.
Zugriffe auf Bibliotheken kontrollieren
Um die diskutierten Einschränkungen zu erzwingen, kommt Nx mit eigenen Linting Rules. Um die nutzen zu können, sind die einzelnen Bibliotheken mit sogenannten Tags in der von Nx gepflegten nx.json zu kategorisieren (Listing 1).
Listing 1
"projects": {
"ui": {
"tags": ["domain:app", "type:app"]
},
"catalog-feature-request-product": {
"tags": ["domain:catalog", "type:feature"]
},
"catalog-feature-browse-products": {
"tags": ["domain:catalog", "type:feature"]
},
"catalog-api": {
"tags": ["domain:catalog", "domain:catalog/api", "type:api"]
},
"catalog-domain": {
"tags": ["domain:catalog", "type:domain-logic"]
},
"ordering-feature-send-order": {
"tags": ["domain:ordering", "type:feature"]
},
"ordering-domain": {
"tags": ["domain:ordering", "type:domain-logic"]
},
"shared-ui-address": {
"tags": ["domain:shared", "type:ui"]
},
"shared-util-auth": {
"tags": ["domain:shared", "type:util"]
}
}
Alternativ dazu lassen sich diese Tags auch beim Einrichten der Applikationen und Bibliotheken angeben. Angelehnt an die Vorschläge aus angular-enterprise-monorepo-patterns werden die Domänen hier mit dem Präfix domain und die Bibliotheksarten mit dem Präfix type versehen. Präfixe dieser Art sollen lediglich die Lesbarkeit erhöhen und lassen sich frei vergeben.
Danach kann man basierend auf diesen Tags in der tslint.json Linting Rules zur Sicherstellung der Zugriffsbeschränkungen einrichten (Listing 2).
Listing 2
"nx-enforce-module-boundaries": [
true,
{
"allow": [],
"depConstraints": [
{
"sourceTag": "domain:app",
"onlyDependOnLibsWithTags": ["domain:catalog", "domain:ordering"]
},
{
"sourceTag": "domain:catalog",
"onlyDependOnLibsWithTags": ["domain:catalog", "domain:shared"]
},
{
"sourceTag": "domain:ordering",
"onlyDependOnLibsWithTags": ["domain:ordering", "domain:catalog/api", "domain:shared"]
},
{
"sourceTag": "domain:shared",
"onlyDependOnLibsWithTags": ["domain:shared"]
},
{
"sourceTag" "type:app",
"onlyDependOnLibsWithTags": ["type:feature"]
},
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:domain-logic", "type:util", "type:api", "type:ui"]
},
{
"sourceTag": "type:api",
"onlyDependOnLibsWithTags": ["type:domain-logic", "type:util"]
},
{
"sourceTag": "type:ui", "onlyDependOnLibsWithTags": ["type:util"]
},
{
"sourceTag": "type:util", "onlyDependOnLibsWithTags": ["type:util"]
}
],
"enforceBuildableLibDependency": true
}
]
Dieses Beispiel zeigt auch die Domäne Ordering, die entsprechend des Context Mappings Zugriff auf das CatalogApi erhält.
Um gegen sie Regeln zu prüfen, reicht ein Aufruf von ng lint auf der Kommandozeile. Entwicklungsumgebungen wie WebStorm/IntelliJ oder Visual Studio Code zeigen solche Regelverletzungen schon während des Tippens an. Im letzteren Fall ist ein entsprechendes Plug-in zu installieren.
Automatisierung
Zur Umsetzung der hier vorgestellten Architektur sind immer wieder dieselben Arbeitsschritte notwendig: Dazu gehört das Einrichten von Domänen samt Linting Rules sowie das Erstellen und Verknüpfen einzelner Bibliotheken. Außerdem sind immer wieder dieselben Building-Blocks zu implementieren: Services für den Datenzugriff, Fassaden und Komponenten für Features.
Glücklicherweise lässt sich diese Aufgabe mit den sogenannten Workspace Schematics [7] automatisieren. Dabei handelt es sich um dieselbe Technologie, die auch das Angular CLI zum Generieren von Komponenten, Services etc. verwendet. Das Plug-in @angular-architects/ddd [8] implementiert diese Idee.
Fassaden aka Application Services
Die Aufgabe der Application Services ist es, Details des Domänenmodells für bestimmte Use Cases aufzubereiten. Das Beispiel in Listing 3 veranschaulicht solch eine Fassade in Form eines Angular Services für das Suchen nach Produkten.
Listing 3
@Injectable({ providedIn: 'root'})
Export class BrowseProductsFacade {
private productsSubject = new BehaviorSubject<Product[]>([]);
public products$ = this.productsSubject.asObservable();
constructor(private productService: ProductService) { }
load(): void {
this.productService.loadProducts().subscribe(
products => this.productsSubject.next(products),
err => console.error('err', err)
);
}
}
Während es mittlerweile zum guten Ton gehört, serverseitig zustandslos zu arbeiten, 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 ein BehaviorSubject. Da solche Subjects immer einen aktuellen Wert aufweisen und daneben Interessenten nach Änderungen informieren, kommen sie häufig für das Verwalten von Zuständen zum Einsatz.
Um zu verhindern, dass jeder den Zustand ändern kann, gehört es zum guten Ton, Subjects als private zu kennzeichnen. Damit sich andere Systembestandteile über den aktuellen Wert informieren (lassen) können, werden sie in Form öffentlicher Observables bereitgestellt.
State-Management mit Redux und NgRx
Die hier gezeigte Architektur muss erweitert werden, wenn viele Fassaden dieselben Daten benötigen. Das hat nämlich Redundanzen und wechselseitige Benachrichtigungen zur Folge. Ersteres führt schnell zu inkonsistenten Zuständen und Letzteres zu Zyklen, die sich weder auf die Performance noch auf die Nachvollziehbarkeit gut auswirken.
Das Redux-Muster, das in der Welt von Angular unter anderem durch die populäre Bibliothek NgRx implementiert wird, bietet hier eine Lösung. Es sieht einen zentralisierten Zustand vor, der von einem sogenannten Store verwaltet wird.
Leider ist dieses Muster sehr umfangreich, zumal es einige Building Blocks mit sich bringt. Deswegen fragen sich viele Entwicklungsteams, ob der damit einhergehende Mehraufwand gerechtfertigt ist. Genau hier kommt auch die besprochene Fassade ins Spiel, denn sie schirmt solche Details vom Rest der Anwendung ab. Während man zunächst, wie hier gezeigt, mit einem Behavior Subject starten kann, lässt sich Redux bzw. NgRx bei Bedarf Schritt für Schritt hinter der Fassade einführen, ohne dass die konsumierenden Komponenten davon betroffen sind.
Domain-driven Design und Microfrontends?
Die Ideen von Domain-driven Design lassen sich bekanntlich für die Schaffung von Microservices-Architekturen nutzen. Genau so lässt sich DDD am Client auch als Grundlage für Microfrontends verwenden.
Ob sich nämlich ein Deployment-Monolith, Microfrontends oder irgendetwas dazwischen ergibt, hängt von der Verwendung der Domänen ab. Richtet das Team pro Domäne eine eigene Anwendung im Monorepo ein, geht es einen großen Schritt in Richtung Microfrontends (Abb. 6).
Die Zugriffsbeschränkungen sichern eine lose Kopplung und ermöglichen sogar eine spätere Aufteilung auf mehrere Repositories, falls das für die Entkopplung als vorteilhaft angesehen wird. Dann kann bei Microfrontends im klassischen Sinn von 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.
Der Einsatz von Monorepos mit mehreren Bibliotheken, die nach Domänen gruppiert werden, hilft beim Aufbau der grundsätzlichen Struktur. 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. Ganz nebenbei schafft ein Team durch den Einsatz von DDD am Client auch die Voraussetzung für Microfrontends.
Links & Literatur
[1] https://nx.dev
[2] https://github.com/manfredsteyer/monorepo_domains.git
[3] https://go.nrwl.io/angular-enterprise-monorepo-patterns-new-book
[4] https://medium.com/@thomasburlesonIA/ngrx-facades-better-state-management-82a04b9a1e39
[5] Wlaschin, Scott: „Domain Modeling Made Functional“; The Pragmatic Bookshelf, 2018
[6] Ghosh, Debasish: „Functional and Reactive Domain Modeling“; Manning, 2016
[8] https://www.npmjs.com/package/@angular-architects/ddd