<p>Strukturen für das Frontend: von Featuremodulen bis zu hexagonaler Architektur von Rainer Hahnekamp</p>
Termine

Strukturen für das Frontend: von Featuremodulen bis zu hexagonaler Architektur von Rainer Hahnekamp

Strukturen für das Frontend: von Featuremodulen bis zu hexagonaler Architektur

Bereits früh im Verlauf der Anwendungsentwicklung eine Architektur zu implementieren, die dann mitwächst, bewahrt ebenso vor Overengineering wie vor einem am Ende unübersichtlichen Moloch. Wir schauen uns die Möglichkeiten an und bringen dabei das Tool Sheriff zum Einsatz.

Rainer Hahnekamp

Rainer Hahnekamp ist Trainer und Berater im Expertennetzwerk von AngularArchitects.io und dort für Schulungen rund um Angular verantwortlich. Darüber hinaus gibt er mit ng-news auf YouTube einen wöchentlichen Kurzüberblick über relevante Ereignisse im Angular-Umfeld.

Mit Softwarearchitekturen verhält es sich ähnlich wie mit dem Testen. Jeder weiß, dass man nicht ohne auskommt. Zu Beginn, wenn man noch in einer prototypenhaften Phase steckt, schenkt man der Architektur meist noch nicht so viel Beachtung. Jedes Element kann auf alles zugreifen und man kommt so schnell zu ersten Resultaten. Die Anwendung wächst und wächst, bis sie irgendwann so groß ist, dass man den Überblick verliert. Dadurch, dass jeder mit jedem „reden“ darf, verursacht man mit jeder Änderung auch gleich einen neuen Bug, bis die Entwicklung ins Stocken gerät.

Zu diesem Zeitpunkt erinnert man sich an die schön strukturierten Architekturpläne, die diese Probleme verhindert hätten. Nur ist es jetzt zu spät. Eine Umstrukturierung würde so viele Änderungen und somit Bugs nach sich ziehen, dass man das Risiko nicht eingehen möchte. Ein Teufelskreis.

Im Folgenden möchte ich gängige Architekturen vorstellen, wie wir sie in einer klassischen Frontend-Anwendung bspw. bei Angular vorfinden. Dabei sortieren wir die Modelle nach Anwendungsgröße. Wir starten mit einer kleinen Anwendung und schauen uns an, welche Anforderungen sich an die Architektur mit steigender Größe ergeben und wie man darauf reagiert. Das hat den Vorteil, dass man mit einem einfachen Modell starten kann und bereits bestens für die nächste Stufe vorbereitet ist. Dabei bleiben wir ausschließlich bei einem monolithischen System. Das Thema Microfrontends würde den Rahmen dieses Artikel sprengen.

Minimale Architektur

Nehmen wir an, wir haben eine sehr einfache Anwendung. Eine Website für eine Reiseagentur zum Beispiel. Am Anfang umfasst sie neben der Startseite ein Impressum und eine Seite mit Kontaktdaten. Das ist nicht viel. Impressum und Kontakt sind jeweils zwei Komponenten mit statischem Text und es werden keine Services benötigt. Auf der Startseite sollen aktuelle News angezeigt werden.

Für die Kommunikation mit einem Backend bzw. einer statischen .json-Datei mit den Newsdaten reicht ein einfacher Service. Weiterhin wird der Router verwendet. Falls wir uns den Luxus von Mehrsprachigkeit leisten wollen, kommt noch eine der gängigen i18n-Bibliotheken wie transloco oder das offizielle Modul des Angular-Frameworks hinzu. Bei derartigen Anwendungen brauchen wir uns über Architektur noch keine Gedanken zu machen. Es kommt alles direkt in das Projektverzeichnis.

Features und Shared-Module

Nehmen wir nun jedoch an, dass zwei weitere Funktionen hinzukommen. Wir möchten eine Übersicht über Städtetrips und eine kleine Listenansicht bestehender Kunden ergänzen. Das sind bereits Funktionen, die über die Kernbestandteile hinausgehen. Als Kernbestandteile kann man die Shell, die Startseite sowie die Footer-Seiten bezeichnen.

Mit den beiden neuen Funktionen ist der Zeitpunkt erreicht, ab dem eine Architektur mit eigenen Regeln empfehlenswert ist. Warum? Nun, wir möchten zwar kein Overengineering betreiben, aber zumindest die Grundstrukturen für die Module vorbereiten. Aktuell setzen wir ein Feature mit einem Modul gleich. Diese Vorgabe erlaubt uns, später neue Features (bspw. einen Onlineshop) hinzuzufügen, und gleichzeitig haben wir einen separaten Ordner, in dem wir unsere bestehenden Features ausbauen können, ohne den Rest der Anwendung damit zu belasten. Wenn wir also einen Ordner für die sogenannten Holidays haben, können wir dort weitere Services, Komponenten usw. hinzufügen und behalten trotzdem den Überblick.

Es werden sich schnell Services oder Komponenten ergeben, die von beiden Features verwendet werden sollen. Damit diese erkennbar als featurelos oder generisch gekennzeichnet sind, geben wir sie in einen separaten Ordner, den wir Shared nennen. Auch diesen Ordner sehen wir als Modul an.

Unsere App besteht nun aus drei Modulen. Das sind die Holidays, die Customers und Shared. Jedem Modul ist dabei ein eigener Ordner zugeteilt. Das ist unser Fundament, auf dem wir mit fortschreitender Anwendungsgröße aufbauen können (Abb. 1).

Software Architecture Abb. Blogartikel
Abb. 1: Applikation mit Featuremodulen

Encapsulation

Diese Unterteilung der Ordnerstruktur nach Features und einem Shared ist eine der gängigsten. Wir sind jedoch noch nicht fertig. Im Weiteren wollen wir mit einem wesentlichen Aspekt eines Moduls starten, nämlich mit der Encapsulation.

Wir haben nun Module. Sie sind aber nicht nur dafür da, eine übersichtliche Ordnerstruktur widerzuspiegeln. Es soll auch möglich sein, dass ein Modul sich einzelne Services, Komponenten etc. nur für interne Zwecke bewahrt und den Zugriff von außen verweigert. Aktuell ist das nicht so. Man kann jederzeit bspw. von Customers auf Holidays zugreifen. Theoretisch könnte somit Customers einen Service von Holidays verwenden und selbstständig neue Holidays-Elemente anlegen etc. Wie stellen wir sicher, dass das nicht passiert? Man kann das bei einer Codereview zwar immer manuell überprüfen, doch ist hierbei das Risiko sehr hoch, dass man es in stressigen Zeiten das eine oder andere Mal übersieht.

Automatische Qualitätssicherung

Es muss also eine Automatisierung her. Derartige Tools gibt es für verschiedene Programmiersprachen und Plattformen. Ein sehr bekanntes Beispiel aus dem Java-Bereich ist ArchUnit, das die Qualitätssicherung in Rahmen von Unit-Tests durchführt.

Im JavaScript-Umfeld hat sich für derartige Zwecke das sogenannte Linting durchgesetzt. Dies erlaubt es, ad hoc Qualitätschecks auszuführen, wodurch man bereits während des Schreibens des Quelltexts von der IDE eine entsprechende Fehlermeldung bekommen kann. Im Umfeld von Angular würde sich hier Nx anbieten. Daneben gibt es noch generischere Lösungen wie dependency-cruiser.

In diesem Artikel soll allerdings ein wesentlich schlankeres Tool verwendet werden, dass unter dem Namen Sheriff bekannt ist und für jedwede TypeScript-Anwendung eingesetzt werden kann.

Konfiguration von Sheriff

Die Voraussetzung ist, dass ESLint mit dem Sheriff-Plug-in installiert und konfiguriert ist. Bei einem Angular-CLI-basierten Projekt müsste man folgende Kommandos ausführen:

npx ng add @angular-eslint/schematics

npm install –save-dev @softarc/eslint-plugin-sheriff

Danach noch die Regel in der Datei .eslintrc.json aktivieren und Sheriff ist einsatzbereit.

Die Encapsulation basiert bei Sheriff auf Konventionen. So muss jedes Modul in irgendeiner Art und Weise definieren, welche Elemente von außen erreichbar sind. In TypeScript oder JavaScript ist das normalerweise eine index.ts- bzw. index.js-Datei. Sie markieren das API für den entsprechenden Ordner samt Unterordner. Diese Konvention hat aber normalerweise technisch keinerlei Bedeutung. Man kann nach wie vor bei einem Import die index.ts-Datei ignorieren und direkt auf die entsprechende Datei innerhalb eines Moduls referenzieren.

Sheriff behandelt jedoch jedes Verzeichnis, das eine index.ts-Datei aufweist als ein Modul. Das heißt, alle Importe von außerhalb müssen auf sie referenzieren. Alles andere würde einen Linting-Fehler produzieren. Damit hat man ohne irgendwelche Konfigurationseinstellungen und durch den Einsatz bestehender Konventionen bereits eine automatische Überprüfung der Encapsulation eingebaut (Abb. 2).

Für das Beispiel werden also jeweils drei index.ts-Dateien für die Module Customers, Holidays sowie Shared erstellt. Bei Featuremodulen ist es normalerweise so, dass sie in Angular lazy über das Routingsystem geladen werden.

Aus diesem Grund wird in den beiden index.ts-Dateien lediglich das Routes-Objekt exportiert. Beim Shared-Modul ist es anders. Da es eher dafür gedacht ist, von Featuremodulen verwendet zu werden, wird der Großteil, wenn nicht sogar alles, exportiert.

Abb. 2: Linting-Fehler wegen Verletzung der Encapsulation
Abb. 2: Linting-Fehler wegen Verletzung der Encapsulation

Multiple Features und Domains

Nach einiger Zeit wird man mehr und mehr Features hinzuprogrammiert haben. Dabei stellt sich größtenteils heraus, dass sich sogenannte Featuregruppen bilden. Das heißt, dass wir neben Holidays und Customers vielleicht noch andere Features haben. Innerhalb von Holidays gibt es ein Feature zur Buchung, ein weiteres, um Kommentare zu hinterlassen, oder eines, um sich Bilder von vergangenen Reisen anzusehen.

Wir werden die Featuregruppen im weiteren Verlauf Domains nennen. Denn das ist es, was sie wirklich sind. Mittlerweile hat sich der Ansatz des Domain-driven Design durchgesetzt, in dem man seine Anwendung nach Domains unterteilt und diese aus einzelnen Features bestehen. Ganz vereinfacht ausgedrückt, bildet die Domain ein Gruppe von Benutzern ab, die in demselben Bereich arbeiten, was zum Beispiel innerhalb einer Organisation eine Fachabteilung sein könnte. Diese Gruppen verwenden untereinander eigene Begriffe, deren Bedeutung innerhalb der Gruppe dieselbe ist. Dabei kann es auch sein, dass eine andere Fachabteilung dieselben Begriffe verwendet, jedoch im Kontext einer anderen Aufgabe. Somit wäre die andere Fachabteilung eine eigene Domain und der Begriff würde pro Domain eigenständig verwendet werden. Für mehr Informationen über Domain-driven Design empfiehlt der Autor die Lektüre der Fachliteratur.

Domain-Module

Wir sind nun bereits bei einer dreifachen Hierarchie angelangt. Wo am Anfang die Anwendung und ihre Featuremodule waren, finden wir nun die Anwendung, die Domains und erst darunter die Features. Im Weiteren müssen wir uns über Domain-einheitlichen Code Gedanken machen. Das wären auf jeden Fall einmal die Domain-Modelle selbst, wie zum Beispiel der Typ einer Holidays- oder Customer-Domain mit Properties und Validierungsregeln.

Zum anderen kann man davon ausgehen bzw. ist zu hoffen, dass sich die Domains bereits im API wiederfinden. Dadurch hat man domänenspezifische Services, die den Zugriff auf das API abwickeln. Mit einer wachsenden Anzahl von Features kann dieser Service auch durch ein komplettes State Management à la NgRx abgelöst werden.

Wie dem auch sei, wir haben nun innerhalb einer Domain mehrere Featuremodule und ein Kernmodul, woraus sich die Features bedienen. Daneben dürfen wir nicht vergessen, dass wir noch unser Shared-Modul haben. Auch hier wird es notwendig sein, es in weitere kleine Module aufzuteilen. So kann man zum Beispiel ein Shared-Modul haben, das Spezialkomponenten für Fomularfelder anbietet, ein anderes kümmert sich um das Error Handling, das Logging oder ist für die Authentifizierung zuständig. Wir kommen somit auf eine neue Evolutionsstufe, die skizzenhaft aussehen kann wie in Abbildung 3. Im Endeffekt ist das die Referenzarchitektur in Angular, wie sie von Manfred Steyer bekannt gemacht wurde.

Software Architecture Abb. Blogartikel
Abb. 3: Domains mit Featuremodulen

Abhängigkeitsregeln und Tags

Mit wachsender Anwendungsgröße steigen auch die Anforderungen an unser Automatisierungstool für Architekturchecks. Encapsulation ist bei dieser Anzahl von Modulen nicht mehr ausreichend. War es bei ausschließlichen Featuremodule noch möglich, dass alle gleichgestellt waren, gibt es nun eine Hierarchie.

Eine Hierarchie erfordert wiederum Abhängigkeitsregeln. Wie in einem Unternehmen auch, möchte man vielleicht nicht, dass ein Mitarbeiter einen Vorgesetzten einer anderen Abteilung direkt kontaktiert und dadurch Kommunikationsprozesse am direkten Vorgesetzten vorbei stattfinden. In einem Unternehmen wird man vielleicht noch ein bisschen toleranter sein. Bei einer technischen Hierarchie wie in unserer Anwendung müssen diese Regeln jedoch streng eingehalten werden. Was kann man hier also tun?

Wie bereits vorher erwähnt, gibt es im Angular-Umfeld den von Nx bekannt gemachten Ansatz der Tags und der darauf aufbauenden Abhängigkeitsregeln. Nachdem wir bereits Sheriff einsetzen, ist es natürlich wünschenswert, dass wir hier nicht das Tool wechseln müssen. Das ist auch nicht nötig. Im Gegensatz zur Encapsulation müssen wir hier jedoch selbst Hand anlegen. Wir müssen alle unsere Module mit generischen Tags versehen und aufgrund dieser Tags dann Abhängigkeitsregeln aufbauen. So kann man zum Beispiel Module innerhalb einer Domain als solche markieren und dann eine Regel einführen, nach der Module nicht Domain-übergreifend kommunizieren dürfen.

Sheriff bietet hierbei eine Konfigurationsdatei an, in der man sowohl die Vergabe der Tags als auch der Regeln festlegen kann. Der Idealfall wäre, dass man nun alle Domain-Module nach denselben Ordnerstrukturen aufbaut. Dann benötigt man nur eine Konfigurationseinstellung und muss nicht für jede neue Domain wieder eingreifen.

Nehmen wir an, sämtliche Domains sind als Unterordner von Domains angelegt und man hätte jeweils zwei Features pro Domain sowie einen Domain-Kern, der als data bezeichnet wird. Man käme auf die Ordnerstruktur, die in Abbildung 4 zu sehen ist. Die zugehörige Sheriff-Konfiguration würde aussehen, wie in Listing 1 gezeigt.

Software Architecture Abb. Blogartikel
Abb. 4: Ordnerstruktur Domain- und Featuremodule

Listing 1: sheriff.config.ts

import { SheriffConfig } from '@softarc/sheriff-core';
 
export const config: SheriffConfig = {
  version: 1,
  tagging: {
    'src/app/': {
      children: {
        shared: { tags: 'shared' },
        'domains/{domain}': {
          tags: (_, { placeholders: { domain } }) => [`domain:${domain}`],
          children: {
            data: { tags: 'type:data' },
            'feat-{type}': { tags: 'type:feature' },
          },
        },
      },
    },
  }
};

Der Code sieht auf den ersten Blick ein wenig kompliziert aus, ist es aber nicht. Mit der tagging Property können wir dynamisch und mittels Platzhalter Tags zuweisen. Mit der hierarchischen Struktur definieren wir mittel des Keys ’src/app‘, dass wir die Tags auf alle Unterordner in diesem Verzeichnis anwenden wollen.

Wir starten mit dem Verzeichnis shared, das eine Sonderrolle einnimmt und den gleichnamigen Tag bekommt. Mit domains/{domain} definieren wir einen Platzhalter für das unmittelbare Unterverzeichnis. Dieser Schlüssel hat zwei Properties. Mit tags wird eine Funktion aufgerufen, die über die Variable placeholder den Verzeichnisnamen erhält. Diesen ziehen wir gleich heran, um allen Modulen den Tag domain:[Verzeichnisname] zuzuordnen.

Dasselbe Spiel geht dann in den nächsten Unterordnern weiter. Nachdem das Verzeichnis data immer das Kernmodul ist, markieren wir es mit type:data. Bei den Verzeichnissen, die mit feat- beginnen, verwenden wir den Tag type:feature. Wir können nun so viele Domains anlegen, wie wir möchten. Solange wir uns an diese Verzeichniskonvention halten, müssen wir die Tagkonfiguration nicht mehr ändern.

Was wir zudem sehen, ist, dass hier offenbar zwei Arten von Tags verwendet werden. Es gibt diejenigen, die mit domain: und jene, die mit type: beginnen. Das hat natürlich einen Grund. Jedes Modul wird anhand von zwei Dimension kategorisiert und darauf bauen wir die Abhängigkeitsregeln auf. Man kann natürlich mehr Dimensionen verwenden. Allerdings muss man dann beachten, dass man für jede Dimension auch neue Abhängigkeitsregeln definieren muss.

Bleiben wir bei zwei Dimensionen. Wir möchten, dass bei der Dimension domain nur Module mit demselben Wert aufeinander zugreifen können. Dazu kommt die Spezial-Domain domain:shared, auf die wiederum alle Domains Zugriff haben. Zusätzlich gelten dann weitere Regeln für die einzelnen Typen. Wo die domain-Dimension die Abhängigkeiten zwischen den Domains regelt, geht type eine Ebene tiefer und fokussiert sich auf die Abhängigkeiten innerhalb der Domain. So wollen wir, dass die Featuremodule zwar auf das Kernmodul (data) zugreifen können, aber nicht umgekehrt. Zudem sollen die Featuremodule sich auch nicht gegenseitig aufrufen können. Ehrlicherweise müsste man shared als dritte Dimension bezeichnen. Das ist jedoch, wie bereits angeführt, ein Sonderfall. In Sheriff würden die Regeln für beide Dimensionen wie in Listing 2 definiert werden.

Listing 2: sheriff.config.ts

import { SheriffConfig } from '@softarc/sheriff-core';
 
export const config: SheriffConfig = {
  version: 1,
  tagging: { 
    ...// tags
  },
  depRules: {
    'domain:*': [({ from, to }) => from === to, 'shared'],
    'type:feature': 'type:data',
    'type:data': null,
    shared: null,
  },
};

Wir sehen hier, dass wir bei jedem Tag, der mit domain: beginnt, mit einer Funktion den Zugriff bestimmen. Die Regel ist ganz einfach: Es muss exakt dieselbe Domain oder aber ein Zugriff auf shared sein.

Ein Modul type:feature: darf nur auf ein Modul mit type:data, aber auf kein anderes mit type:feature zugreifen. Zusammen mit den domain-Regeln ist somit gewährleistet, dass hier keine domainübergreifenden Zugriffe erfolgen. type:data und shared stehen für sich allein und dürfen zu keinen anderen Modulen Abhängigkeiten haben. Sollte nun eine Zugriffsverletzung im Code auftreten, bekommt man eine entsprechende Fehlermeldung (Abb. 5).

Software Architecture Abb. Blogartikel
Abb. 5: Zugriffsfehler Dependency Rules

Domänenspezifische UI-Komponenten

  • Unsere Anwendung wächst weiter. Es wird nicht lange dauern, und wir werden nach Möglichkeiten suchen, unseren Featurecode weiter aufzuteilen. Hierbei hat sich grundsätzlich die Unterscheidung in zwei Komponententypen durchgesetzt. Man kennt dieses Pattern unter verschiedenen Namen:
  • – Container-/Presentational-Komponenten

  • – Smart-/Dumb-Komponenten

  • – Feature-/UI-Komponenten

Alle haben gemeinsam, dass eine Komponente sich entweder auf die Logik fokussiert oder auf die Darstellung. Die Logik- oder Containerkomponente ist die Elternkomponente der Presentational-Komponente. Dabei verwendet die Containerkomponente bestehende Services und bereitet die Daten für die eigentliche Darstellung durch die Presentational-Komponente auf.

Letztere hingegen verzichtet auf jegliche Services und bekommt die Daten über @Input von der Containerkomponente angeliefert. Dafür fokussiert sie sich auf das Template bzw. das CSS. Die Benutzerinteraktion findet konsequenterweise über die Presentational-Komponente statt. Da diese sich jedoch passiv verhält, leitet sie die entsprechenden Benutzeraktionen über einen Event Emitter bzw. @Output an die Containerkomponente weiter, die dann entscheidet, was die nächsten Schritte sind.

Mit diesem Vorgehensmuster haben wir die Möglichkeit, eine größere Komponente sauber in zwei Teile zu schneiden. Damit gewinnen wir wieder etwas an Übersicht zurück. Im Endeffekt haben wir das bereits bei dem Shared-Modul so gemacht. Die Komponenten in Shared sind generisch, haben überhaupt keine Ahnung, in welcher Domain sie verwendet werden, und entlasten durch ihre Wiederverwendbarkeit Code in den Domains.

So weit, so gut. Allerdings wird es irgendwann passieren, dass wir auch domänenspezifische Presentational-Komponenten haben, also Komponenten, die sich zwar auf die Darstellung spezialisieren, jedoch einen Domain-Typ als @Input bekommen. Wir möchten natürlich auch hier sehr streng sein und den Presentational-Komponenten erst gar nicht den Zugriff auf Services erlauben, womit sie auf einmal mit dem API selbst eine Verbindung aufbauen und kommunizieren könnten. Das ist strikt den Containerkomponenten vorbehalten.

  • Mit den bestehenden type:feature und type:data haben wir nun ein Problem. Wir führen als neuen Typ und neues Modul type:ui ein. Darin platzieren wie die domainspezifischen Presentational-Komponenten. Allerdings ist das Problem damit noch nicht gelöst. Wir müssen auch unser type:data, also das Kernmodul, aufteilen. Die Typen wandern in das neue Modul type:modeltype:data behält seine Services. Nun kann man type:ui den Zugriff auf die Domain-Modelle erlauben, ohne Gefahr zu laufen, dass es gleichzeitig auch die Services oder das State Management verwendet. Das bleibt nach wie vor type:feature vorbehalten. Schematisch sind wir nun innerhalb einer Domain bei der Struktur angelangt, die Abbildung 6 zeigt. Die angepasste Konfiguration würde damit aussehen, wie in Listing 3 zu sehen – etwas mehr Code, aber unter dem Gesichtspunkt, dass wir es hier bereits mit einer größeren Anwendung zu tun haben, durchaus noch übersichtlich.
Software Architecture Abb. Blogartikel
Abb. 6: Vier Typen pro Domain

Listing 3: sheriff.config.ts

import { SheriffConfig } from '@softarc/sheriff-core';
export const config: SheriffConfig = {
  version: 1,
  tagging: { //...
  },
  depRules: {
    'domain:*': ({ from, to }) => from === to,
    'type:feature': ({ to }) => to.startsWith('type:'),
    'type:data': 'type:model',
    'type:ui': 'type:model',
    'type:model': [],
  },
};

Weitere Ausbaustufen

Wie kann es nun weitergehen? Man kann natürlich mehr und mehr Featuremodule bzw. auch UI-Module hinzufügen. Das Problem an der Sache ist, dass damit sehr bald das Kernmodul überfordert werden würde. Es muss nun nämlich Funktionen für jedes Featuremodul bereitstellen. Und dadurch, dass Features meistens auch mit neuen Domain-Modellen bzw. neuen APIs einhergehen, wächst das Kernmodul zu einem Moloch heran, über den man dann keinen Überblick mehr hat. Ein möglicher Lösungsweg wäre hier, ein Kernmodul type:data zur Verfügung zu stellen, das die Typen verwaltet, die von allen Features benötigt werden.

Bei einer Domain Holidays wäre es natürlich das Modul Holidays, das dafür infrage käme. Danach würde man den Featuremodulen ein eigenes type:data-Modul zuordnen, das die featurespezifischen Domain-Typen verwaltet. Man käme damit zu sogenannten Featurepäckchen, in denen sich jeweils ein type:featuretype:datatype:model sowie möglicherweise sogar ein type:ui in Miniaturform wiederfindet. Alle wären jedoch um das Kernmodul der Domain angeordnet. Grob würde demnach ein derartiges Domainmodul aussehen, wie in Abbildung 7 gezeigt.

Software Architecture Abb. Blogartikel
Abb. 7: Multiple Data-Module per Domain

APIs

Mit den bis dato vorgestellten Architekturmodellen sollte man sehr gut skalieren können. Ein wichtiger Aspekt blieb bisher jedoch unbehandelt: Wie soll man vorgehen, wenn eine Domain eine Abhängigkeit zu einer anderen hat? Im Domain-driven Design spricht man hier von der sogenannten Context Map, wobei es verschiedene Typen gibt. Nehmen wir jedoch an, wir hätten eine neue Domain domain:bookings, die Zugriff auf die Kundendaten benötigt. Das heißt, sie müsste einen Zugriff auf type:data in domain:customers bekommen. Mit den generischen Abhängigkeitsregeln ist das leider nicht so einfach. Da Sheriff noch in einem frühen Stadium ist, kann es durchaus sein, dass es einmal eine Lösung für spezifische Module gibt. Bis dahin müssen wir jedoch mit den generischen Regeln zurechtkommen.

Wir lösen einen API-Anwendungsfall, indem wir in unserem Domain-Ordner ein neues Verzeichnis und somit auch ein neues Modul namens API erstellen. In der index.ts-Datei werden diejenigen Elemente von den Domain-spezifischen Modulen exportiert, die von außen erreichbar sind. In unserem Fall wäre das ein einziger Service aus dem Kernmodul.

Des Weiteren weisen wir dem API-Modul einen neuen Tag zu: type:api. Das ist jedoch nicht alles. Es bekommt auch mit domain:customers:api einen zweiten Domain-Tag. Damit unser API-Modul den Zugriff auf das Kernmodul und weitere bekommt, geben wir type:api Zugriff auf alle anderen type:*-Module.

Zur Klarstellung: Das bedeutet nach wie vor, dass das API nur auf alle Module innerhalb der eigenen Domain zugreifen kann. Der Zugriff über die Domain hinaus ist bis auf shared nach wie vor verboten.

Als Nächstes erfolgt nun die individuelle Freigabe für domain:bookings. Und zu guter Letzt muss natürlich type:feature auch noch der Zugang für type:api gegeben werden. Das bedeutet, dass jedes type:feature in domain:bookings auf das API von Customers zugreifen kann. Anderen Domains wird der Zugang verweigert.

Man sieht bereits, dass hier sehr viel Aufwand getrieben werden muss. Viele APIs werden mit der Zeit auch zu Unübersichtlichkeit führen. Dafür gibt es zwei Lösungen. Man versucht, die Abhängigkeiten zwischen den Domains auf ein absolut notwendiges Minimum zu beschränken. Im Notfall kann man sogar teilweise Code duplizieren. So könnte in domain:bookings ein Service das Backend der Customers direkt anfragen. Aber auch das ist mit Vorsicht zu genießen.

Sollte trotz aller Bemühungen eine hohe Anzahl von Abhängigkeiten zwischen den Domains vorhanden sein, kann man sich der hexagonalen Architektur zuwenden. Dabei werden wir untersuchen, wie wir einige Ideen aus diesem Modell in das Frontend überführen können.

Hexagonale Architektur

Wir schon beim Domain-driven Design soll die hexagonale Architektur hier nur ganz grob skizziert werden und wie sie im Rahmen einer Angular-Anwendung eingesetzt werden kann. Alles andere würde hier zu weit führen.

Bei der hexagonalen Architektur versucht man, die Domain-Logik von allen „störenden“ Einflussfaktoren unabhängig zu machen. Das heißt, die Logik soll keinerlei Abhängigkeiten zu irgendwelchen technischen APIs, Datenbanken etc. haben und sich auf die eigentliche Arbeit ohne Einschränkungen fokussieren können.

Irgendwie muss aber doch eine Persistierung gegen die Datenbank erfolgen oder – im Frontend – ein HTTP-Request abgesetzt werden. Damit die Domain-Logik dennoch nicht die Abhängigkeit und damit die Regeln beispielsweise von JPA oder aber der HTTP-Bibliothek beachten muss, definiert man in der Logik ganz einfach ein Interface. Dieses kann man zum Persistieren auffordern und das Domain-Objekt übergeben. Wie das nun geschieht, ob ein Mapping erforderlich ist oder ob hier mehrere Requests abgeschickt werden müssen, ist nicht mehr die Angelegenheit der Domain-Logik. Im Jargon der hexagonalen Architektur wird dieses Interface dann Port genannt.

Für die Implementierung der Ports gibt es spezielle Module. Diese verwenden bspw. den HTTP Client bei Angular. Die Module werden dann Adapter genannt. Dadurch, dass es unterschiedliche Ports gibt und sich dadurch unterschiedliche Adapter an die Domain-Logik anhängen, entsteht bei der grafischen Darstellung eine Struktur, die einem Hexagon ähnelt. Daher stammt auch der Name hexagonale Architektur (Abb. 8).

Software Architecture Abb. Blogartikel
Abb. 8: Hexagonale Architektur

Einsatz im Frontend

Die hexagonale Architektur wird mittlerweile im Backend-Bereich sehr gut angenommen. Im Frontend-Bereich ist sie eher Mangelware. Dafür sind zwei Gründe anzuführen. Zum Ersten ist die hexagonale Architektur besonders dann geeignet, wenn man wirklich viel fachliche Logik hat. Man bezahlt nämlich die Freiheit der Domain-Logik mit sehr viel Mapping-Code. Das ist auch der Grund, dass es nicht in jedem Backend vorkommt. Wenn man ausschließlich CRUD-Anwendungen hat, steht dieser Mehraufwand durch Mappings einfach nicht im Verhältnis.

Was teilweise bereits auf das Backend zutrifft, ist beim Frontend natürlich erst recht der Fall. Das Wenige an Logik, das man – wenn überhaupt – hat, rechtfertigt den Aufwand meistens nicht. Zum anderen gibt es bereits im Frontend ein Pattern, das die Fachlichkeit vom Rest der Anwendung – was im Endeffekt Komponenten wären – isoliert. Das ist das bereits genannte State Management. Es ist zwar nicht eins zu eins hexagonale Architektur, aber man kann Gemeinsamkeiten erkennen. So ist zum Beispiel der Logikcode in den Reducern hinterlegt, was bedeutet, dass er komplett abgeschottet vom Rest der Anwendung läuft. Im Fall von NgRx geht das sogar so weit, dass hier nicht einmal eine Abhängigkeit zu Angular vorliegt. Diese muss indirekt in Form sogenannter Effects, die wiederum außerhalb der Logik agieren, durchgeführt werden (Abb. 9).

Software Architecture Abb. Blogartikel
Abb. 9: NgRx

Ports und Adapter als Glue-Code zwischen Domains

Man kann allerdings dennoch Konzepte aus der hexagonalen Architektur im Frontend wiederverwenden. Weiter oben wurde gezeigt, dass das Einrichten von APIs sehr umständlich ist.

Wenn wir somit noch einmal auf die Ausgangsbedingungen zurückkommen, wo domain:bookings auf domain:customers zugreifen wollte, dann kann man das über den Ports- und Adapter-Einsatz sehr elegant lösen (Abb. 10).

Bookings definiert sich einen Service, der eine Methode hat, die die entsprechenden Daten aus Customers zurückliefert. Die Implementierung dieses Services übernimmt jedoch weder domain:bookings noch domain:customersDomain:customers muss allerdings ein type:api haben, das zumindest einen Service bereitstellt, der diese Daten bereitstellt. Dabei ist wichtig, dass domain:bookings und domain:customers voneinander nichts wissen und auch die beiden Services sowie deren Typen keine Abhängigkeit zueinander haben.

Es ist die App selbst, die hier in Aktion tritt. Sie hat grundsätzlich Zugriff auf alle type:api aller Domänen. Demnach würde die App einen eigenen Service in der Dependency Injection bereitstellen, der den angeforderte Service von domain:bookings implementiert, und würde gleichzeitig den Service des API von domain:customers aufrufen. Auch hier kann man das an ein eigenes Spezialmodul auslagern, das dieselben Berechtigungen hat wie die App. Somit würde die App schlicht gehalten werden und lediglich diesen Adapter providen.

Software Architecture Abb. 1 Blogartikel
Abb. 10: Ports und Adapter in Angular

Zusammenfassung

Wir haben uns gängige Architekturmodelle angesehen und wie man sie auf einfache sowie auf große Applikationen anwenden kann. Dabei bauen die einzelnen Modelle aufeinander auf und es ist bei Bedarf ohne größere Schwierigkeiten ein Upgrade möglich. So vermeidet man, dass man bereits frühzeitig viele zu große Strukturen schafft und somit in das klassische Overengineering hineinläuft.

Für die automatische Überprüfung der Architektur wurde das neue Tool Sheriff verwendet. Es ist aktuell noch in der Betaphase, wodurch sich bis zum Erscheinen dieses Artikels Änderungen ergeben können. Alle besprochenen Abhängigkeitsregeln sind auch mit dem Tool Nx durchführbar. Allerdings ist es im Gegensatz zu Sheriff nicht so leichtgewichtig und es muss für jedes Modul ein eigenes npm Package gebaut werden.