Blog

Strategic Design mit Sheriff und Standalone

24 Nov 2023

Moderne Architekturen mit Angular – Teil 1: Angular kommt häufig für das Frontend großer, unternehmenskritischer Lösungen zum Einsatz. Gerade in diesem Umfeld ist besonders auf eine gut wartbare Architektur zu achten. Gleichzeitig soll aber auch Over-Engineering vermieden werden. Aktuelle Features wie Standalone Components und Standalone APIs helfen dabei.

In dieser zweiteiligen Artikelserie zeige ich Ihnen, wie Sie beide Anforderungen unter einen Hut bekommen. Der erste Teil beleuchtet die Umsetzung Ihres strategischen Designs auf der Basis von Standalone Components und Standalone APIs. Die vorgegebene Architektur wird mit dem Open-Source-Projekt Sheriff umgesetzt. Die verwendeten Beispiele finden sich in meinem GitHub-Konto unter [1].

Die Leittheorie: Strategic Design aus DDD

Als Leittheorie für die Strukturierung moderner Frontends hat sich Strategic Design, eine der beiden ursprünglichen Disziplinen von Domain-Driven Design (DDD), bewährt. Im Kern geht es dabei darum, ein Softwaresystem in verschiedene fachliche Teilbereiche (Subdomänen) zu zerlegen. Bei einer Fluggesellschaft könnte man zum Beispiel die in Abbildung 1 dargestellten Subdomänen finden.

steyer_sheriff_1.tif_fmt1.jpg
Abb. 1: Domänenschnitt

 

Die Identifizierung der einzelnen Domänen erfordert einen Blick auf die zu unterstützenden Geschäftsprozesse. Das Zusammenspiel zwischen Entwickler:innen sowie Architekt:innen auf der einen und Domänenexpert:innen auf der anderen Seite ist dabei essenziell. Workshopformate wie Event Storming [2], die DDD mit Ideen der agilen Softwareentwicklung verbinden, bieten sich hierfür an.

Die Beziehungen und Abhängigkeiten zwischen einzelnen Domänen werden in einer Context Map dargestellt (Abb. 2). Ziel ist es, die einzelnen Domänen voneinander zu entkoppeln. Je weniger sie voneinander wissen, desto besser. Das verhindert, dass sich Änderungen an einem Anwendungsteil auf andere Teile auswirken, und verbessert so die Wartbarkeit. Bei größeren Projekten ist es üblich, jedem Teilteam eine oder mehrere Domänen zuzuweisen (Kasten: „Domäne vs. Bounded Context“).

steyer_sheriff_2.tif_fmt1.jpg
Abb. 2: Eine einfache Context Map

 

Um die gewünschte Entkopplung zu erreichen, könnte Booking im gezeigten Beispiel nur einige ausgewählte Services veröffentlichen. Alternativ dazu könnten Informationen über gebuchte Flüge per Messaging im Backend verteilt werden. Darüber hinaus bietet Strategic Design zahlreiche weitere Muster und Überlegungen, die bei der Umsetzung einer losen Kopplung helfen.

 

BRINGEN SIE LICHT INS ANGULAR-DUNKEL

Die ersten Schritte in Angular geht man am besten im Basic Camp.
→ Nächster Termin: 17. - 19. Juni, online

Domäne vs. Bounded Context

Streng genommen wird eine Domäne im Rahmen der Implementierung auf einen oder mehrere Bounded Contexts abgebildet, und jeder Bounded Context kann wiederum eine oder mehrere Domänen enthalten. Somit spiegelt der Bounded Context die Lösungssicht wider, während die Domäne einen Teil der Problemsicht darstellt.

Jeder Bounded Context hat ein Domänenmodell, das die jeweilige Fachlichkeit widerspiegelt, z. B. die Struktur und Handhabung von Flügen und Tickets. Dieses Domänenmodell ist nur innerhalb des Bounded Context sinnvoll. Auch wenn die gleichen Begriffe in anderen Kontexten verwendet werden, ist es sehr wahrscheinlich, dass diese Kontexte eine andere Sichtweise darauf haben. Ein Flug sieht aus Booking-Sicht anders aus als aus Sicht des Boarding. Diese beiden Sichten werden bewusst getrennt dargestellt. Damit vermeidet man sowohl eine Vermischung der Kontexte als auch ein unübersichtliches Modell, das versucht, zu viel auf einmal zu beschreiben.

Der Einfachheit halber gehen die Ausführungen in diesem Artikel davon aus, dass pro Domäne ein Bounded Context vorherrscht.

Übergang zum Quellcode: die Architekturmatrix

Für die Umsetzung im Quellcode bietet es sich an, die einzelnen Domänen weiter in verschiedene Module zu untergliedern (Abb. 3).

steyer_sheriff_3.tif_fmt1.jpgAbb. 3: Architekturmatrix

 

Eine Kategorisierung dieser Module erhöht die Übersichtlichkeit. In [3] werden unter anderem folgende Kategorien vorgeschlagen, die sich in unserer täglichen Arbeit bewährt haben:

  • Feature: Ein Feature-Modul implementiert einen Anwendungsfall (oder auch ein technisches Feature) mit sogenannten Smart Components. Durch die Fokussierung auf ein Feature sind solche Komponenten wenig wiederverwendbar. Ein weiteres Merkmal ist, dass Smart Components mit dem Backend kommunizieren. Typischerweise erfolgt diese Kommunikation in Angular über einen Store oder Services.

  • UI: UI-Module enthalten sogenannte Dumb oder Presentational Components. Dabei handelt es sich um wiederverwendbare Komponenten, die die Implementierung einzelner Features zwar unterstützen, sie aber nicht direkt kennen. Die Umsetzung eines Designsystems besteht aus solchen Komponenten. UI-Module können aber auch allgemeine fachliche Komponenten enthalten, die anwendungsfallübergreifend zum Einsatz kommen. Ein Beispiel hierfür wäre eine Ticketkomponente, die sicherstellt, dass Tickets in verschiedenen Features einheitlich dargestellt werden. Häufig kommunizieren solche Komponenten nur über Eigenschaften und Events mit ihrer Umgebung. Sie haben keinen Zugriff auf das Backend oder einen Store.

  • Data: Data-Module enthalten das jeweilige Domänenmodell (eigentlich die clientseitige Sicht darauf) sowie Services, die darauf operieren. Solche Services validieren z. B. Entitäten und kommunizieren mit dem Backend. Auch das State Management inkl. der Bereitstellung von ViewModels kann in Data-Modulen untergebracht werden. Das ist vor allem dann sinnvoll, wenn mehrere Features derselben Domäne auf denselben Daten basieren.

  • Util: Allgemeine Hilfsfunktionen werden in Utility-Modulen untergebracht. Beispiele dafür sind Logging, Authentifizierung oder das Arbeiten mit Datumswerten.

Eine weitere Besonderheit bei der Umsetzung im Code ist der shared-Bereich, der Code für alle Domänen bereitstellt. Er sollte in erster Linie technischen Code enthalten – fachlicher Code ist in der Regel in den einzelnen Domänen zu finden.

Die hier dargestellte Struktur bringt Ordnung in das System: Es gibt weniger Diskussionen darüber, wo bestimmte Codeabschnitte zu finden bzw. zu platzieren sind. Außerdem lassen sich auf Basis dieser Matrix zwei einfache, aber wirkungsvolle Regeln einführen:

  • Jede Domäne darf im Sinne des Strategic Design nur mit ihren eigenen Modulen kommunizieren. Eine Ausnahme bildet der shared-Bereich, auf den jede Domäne Zugriff hat.

  • Jedes Modul darf nur auf Module in tieferen Schichten der Matrix zugreifen. Dabei geht aus jeder Modulkategorie eine eigene Schicht hervor.

Beide Regeln unterstützen die Entkopplung der einzelnen Module bzw. Domänen und helfen, Zyklen zu vermeiden.

Projektstruktur für die Architekturmatrix

Die Architekturmatrix kann im Quellcode in Form von Ordnern abgebildet werden: Jede Domäne bekommt einen eigenen Ordner, der wiederum für jedes seiner Module einen Unterordner erhält (Abb. 4).

steyer_sheriff_4.tif_fmt1.jpg
Abb. 4: Ordnerstruktur mit Domänen

 

Die Modulnamen haben den Namen der jeweiligen Modulkategorie als Präfix. So ist auf den ersten Blick ersichtlich, wo in der Architekturmatrix sich das jeweilige Modul befindet. Innerhalb der Module gibt es typische Angular Building Blocks wie Komponenten, Direktiven, Pipes oder Services.

Der Einsatz von Angular-Modulen ist seit der Einführung von Standalone Components (Dirketiven und Pipes) nicht mehr notwendig. Stattdessen wird die Flag standalone auf true gesetzt (Listing 1). Bei Komponenten ist außerdem der sogenannte Compilation Context zu importieren. Dabei handelt es sich um alle weiteren Standalone Components, Direktiven und Pipes, die im Template zum Einsatz kommen.

Listing 1

@Component({
  selector: 'app-flight-booking',
  standalone: true,
  imports: [CommonModule, RouterLink, RouterOutlet],
  templateUrl: './flight-booking.component.html',
  styleUrls: ['./flight-booking.component.css'],
})
export class FlightBookingComponent {
}
Zur Definition der öffentlichen Schnittstelle bekommt jedes Modul eine index.ts-Datei. Das ist ein sogenanntes Barrel, das festlegt, welche Modulbestandteile auch außerhalb des Moduls genutzt werden dürfen:
export * from './flight-booking.routes';

Bei der Wartung der veröffentlichten Konstrukte ist Vorsicht geboten, da sich Breaking Changes tendenziell auch auf andere Module auswirken. Alles jedoch, was hier nicht veröffentlicht wird, ist ein Implementierungsdetail des Moduls. Änderungen an diesen Teilen sind daher weniger kritisch.

Domänenschnitt mit Sheriff erzwingen

Die bisher diskutierte Architektur basiert auf mehreren Konventionen:

  • Module dürfen nur mit Modulen derselben Domäne sowie shared kommunizieren.

  • Module dürfen nur mit Modulen tieferliegender Schichten kommunizieren.

  • Module dürfen nur auf die öffentliche Schnittstelle anderer Module zugreifen.

Mit dem Open-Source-Projekt Sheriff [4] können diese Konventionen via Linting erzwungen werden. Bei Nichteinhaltung wird eine Fehlermeldung in der IDE (Abb. 5) oder auf der Konsole (Abb. 6) ausgegeben.

steyer_sheriff_5.tif_fmt1.jpg
Abb. 5: Sheriff in der IDE

 

steyer_sheriff_6.tif_fmt1.jpg
Abb. 6: Sheriff auf der Konsole

 

Ersteres liefert unmittelbares Feedback während der Entwicklung, wohingegen Letzteres im Build-Prozess automatisiert werden kann. Dadurch lässt sich zum Beispiel verhindern, dass Quellcode, der gegen die definierte Architektur verstößt, im main– bzw. dev-Branch des Quellcode-Repos landet.

Um Sheriff einzurichten, müssen die folgenden beiden Pakete über npm bezogen werden:

npm i @softarc/sheriff-core @softarc/eslint-plugin-sheriff -D
Das Erste beinhaltet Sheriff selbst, das zweite ist die Anbindung an eslint. Diese ist in der Datei .eslintrc.json im Projekt-Root zu registrieren (Listing 2).

 

Listing 2
{
  [...],
  "overrides": [
    [...]
    {
      "files": ["*.ts"],
      "extends": ["plugin:@softarc/sheriff/default"]
    }
  ]
}
Sheriff betrachtet jeden Ordner, der eine index.ts-Datei enthält, als ein Modul. Ein Umschiffen dieser index-Datei und somit ein Zugriff auf Implementierungsdetails durch andere Module wird von Sheriff standardmäßig verhindert. Die im Root des Projektes einzurichtende Datei sheriff.config.ts legt Kategorien (tags) für die einzelnen Module fest und definiert darauf aufbauend Abhängigkeitsregeln (depRules). Listing 3 zeigt eine Sheriff-Konfiguration für die oben diskutierte Architekturmatrix.

 

Listing 3

import { noDependencies, sameTag, SheriffConfig } from '@softarc/sheriff-core';
 
export const sheriffConfig: SheriffConfig = {
  version: 1,
 
  tagging: {
    'src/app': {
      'domains/<domain>': {
        'feature-<feature>': ['domain:<domain>', 'type:feature'],
        'ui-<ui>': ['domain:<domain>', 'type:ui'],
        'data': ['domain:<domain>', 'type:data'],
        'util-<ui>': ['domain:<domain>', 'type:util'],
      },
    },
  },
  depRules: {
    root: ['*'],
 
    'domain:*': [sameTag, 'domain:shared'],
 
    'type:feature': ['type:ui', 'type:data', 'type:util'],
    'type:ui': ['type:data', 'type:util'],
    'type:data': ['type:util'],
    'type:util': noDependencies,
  },
};
Die Tags beziehen sich auf Ordnernamen. Ausdrücke wie <domain> oder <feature> sind Platzhalter. Jedes Modul unterhalb von src/app/domains/<domain>, dessen Ordnername mit feature- beginnt, bekommt demnach die Kategorien domain:<domain> sowie type:feature zugewiesen. Im Fall von src/app/domains/booking wären das die Kategorien domain:booking und type:feature.

Die Abhängigkeitsregeln unter depRules greifen die einzelnen Kategorien auf und legen zum Beispiel fest, dass ein Modul nur auf Module derselben Domäne und auf domain:shared zugreifen darf. Weitere Regeln definieren, dass jede Schicht nur auf darunterliegende Schichten zugreifen darf. Dank der Regel root: [‚*‘] dürfen alle nicht explizit kategorisierten Ordner im Root-Ordner und darunter auf sämtliche Module zugreifen. Das betrifft vor allem die Shell der Anwendung.

 

ABTAUCHEN IM DEEP DIVE

Im Fortgeschrittenen Camp tauchen Sie ab unter die Oberfläche einer modernen Angular-Anwendung.
→ Nächster Termin: 13. - 15. Mai, München

Leichtgewichtige Path-Mappings

Um unleserliche relative Pfade innerhalb der Imports zu vermeiden, bieten sich Path Mappings an. Diese erlauben es zum Beispiel, statt

import { FlightBookingFacade } from '../../data';

folgende Formulierung zu verwenden:

import { FlightBookingFacade } from '@demo/ticketing/data';
Solche dreistelligen Importe setzen sich aus dem Projekt- bzw. Workspace-Namen (z. B. @demo), dem Domänennamen (z. B. ticketing) sowie einem Modulnamen (z. B. data) zusammen und spiegeln damit die gewünschte Position in der Architekturmatrix wider. Diese Notation lässt sich unabhängig von der Anzahl der Domänen und Module mit einem einzigen Path Mapping innerhalb der Datei tsconfig.json im Projekt-Root ermöglichen (Listing 4).

 

Listing 4
{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    [...]
    "paths": {
      "@demo/*": ["src/app/domains/*"],
    }
  },
  [...]
}
IDEs wie Visual Studio Code sollten nach dieser Änderung neu gestartet werden. Dadurch wird sichergestellt, dass sie die Änderung berücksichtigen.

Standalone APIs

Da Standalone Components die umstrittenen Angular-Module optional machen, stellt das Angular-Team nun sogenannte Standalone APIs zur Registrierung von Bibliotheken zur Verfügung. Bekannte Beispiele sind provideHttpClient und provideRouter (Listing 5).

 

Listing 5

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)),
 
    importProvidersFrom(NextFlightsModule),
    importProvidersFrom(MatDialogModule),
 
    provideLogger({
      level: LogLevel.DEBUG,
    }),
  ],
});
Im Wesentlichen handelt es sich dabei um Funktionen, die Provider für die benötigten Services zurückliefern. Die Auswahl dieser Provider und damit das Verhalten der Bibliothek lässt sich durch die Übergabe eines Konfigurationsobjekts beeinflussen. Ein Beispiel hierfür ist die Routenkonfiguration, die provideRouter entgegennimmt.

Aus Architektursicht erfüllen Standalone-APIs noch einen weiteren Zweck: Sie erlauben es, einen Systembestandteil als Blackbox zu betrachten, die unabhängig weiterentwickelt werden kann. Aus der Blackbox kann durch die Übergabe eines Konfigurationsobjekts auch eine Graybox werden. In diesem Fall kann das Verhalten des genutzten Systembestandteils über wohldefinierte Einstellungen angepasst werden, ohne die lose Kopplung aufgeben zu müssen. Man spricht hier auch vom Open/Closed-Prinzip: offen für Erweiterungen (durch Konfiguration bzw. Polymorphismus), geschlossen für Modifikationen durch User.

Als Beispiel für ein eigenes Standalone API, das einen Logger einrichtet, wird in Listing 5 die Funktion provideLogger aufgerufen. Ihre Implementierung ist in Listing 6 zu sehen. Die Funktion provideLogger nimmt ein partielles LoggerConfig-Objekt entgegen. Der Aufrufer muss sich also nur um die Parameter kümmern, die für den aktuellen Fall relevant sind. Um eine vollständige LoggerConfig zu erhalten, führt provideLogger die übergebene Konfiguration mit einer Standardkonfiguration zusammen. Darauf aufbauend werden verschiedene Provider zurückgegeben. Die Funktion makeEnvironmentProviders aus @angular/core umhüllt das erzeugte Providerarray mit einem Objekt vom Typ EnvironmentProviders. Dieser Typ lässt sich beim Bootstrappen der Anwendung sowie innerhalb von Routingkonfigurationen nutzen. Er erlaubt somit die Bereitstellung von Providern für die gesamte Anwendung oder einzelne Teile.

 

Listing 6

export function provideLogger(
  config: Partial<LoggerConfig>
): EnvironmentProviders {
  const merged = { ...defaultConfig, ...config };
 
  return makeEnvironmentProviders([
    LoggerService,
    {
      provide: LoggerConfig,
      useValue: merged,
    },
    {
      provide: LOG_FORMATTER,
      useValue: merged.formatter,
    },
    merged.appenders.map((a) => ({
      provide: LOG_APPENDERS,
      useClass: a,
      multi: true,
    })),
  ]);
}
Im Gegensatz zu einem herkömmlichen Providerarray können EnvironmentProviders nicht innerhalb von Komponenten verwendet werden. Diese Einschränkung ist durchaus gewollt, denn die meisten Bibliotheken wie der Router sind für den komponentenübergreifenden Einsatz konzipiert.

Zusammenfassung

Strategic Design unterteilt ein System in verschiedene Teile, die möglichst unabhängig voneinander implementiert werden. Diese Entkopplung verhindert, dass sich Änderungen in einem Anwendungsbereich auf andere auswirken. Der vorgestellte Architekturansatz unterteilt die einzelnen Domänen in verschiedene Module, wobei das quelloffene Projekt Sheriff sicherstellt, dass die einzelnen Module nur nach den aufgestellten Regeln miteinander kommunizieren.

Dieser Ansatz ermöglicht die Umsetzung großer und langfristig wartbarer Frontend-Monolithen. Wegen ihres modularen Aufbaus spricht man auch von Modulithen. Ein Nachteil solcher Architekturen sind erhöhte Build- und Testzeiten. Dieses Problem kann durch inkrementelle Builds und Tests gelöst werden. Der zweite Teil dieser Artikelserie beschäftigt sich mit diesem Thema.

 

Newsletter

Jetzt anmelden & regelmäßig wertvolle Insights in Angular sowie aktuelle Weiterbildungen erhalten!