BLOG

Moderne Angular Architekturen

Strategisches Design mit Sheriff und Standalone Components

Erfahren Sie, wie Sie eine wartbare Angular-Architektur umsetzen und mit den neuesten Features von Angular Überengineering vermeiden können.

Angular kommt häufig im Frontend großer, geschäftskritischer Anwendungen zum Einsatz. Gerade in diesem Umfeld ist es besonders wichtig, eine gut wartbare Architektur sicherzustellen – ohne dabei in Over-Engineering zu verfallen. Aktuelle Features wie Standalone Components und Standalone APIs unterstützen genau dabei. In diesem Artikel zeige ich, wie sich beide Anforderungen miteinander vereinbaren lassen. Im Fokus steht die Umsetzung eines strategischen Designs mit Hilfe von Standalone Components und Standalone APIs. Die definierte Architektur wird dabei durch das Open-Source-Projekt Sheriff durchgesetzt. Die gezeigten Beispiele funktionieren sowohl mit einem klassischen Angular-CLI-Projekt als auch mit Nx. Für den Einsatz mit Nx empfehlen wir, mit einer Nx Standalone Application zu starten, um die Architektur schlank und ordnerbasiert zu halten.

Der Mehrwert unserer Expertentipps

Bei TechTalk sehen wir Software als Werkzeug, das in den Händen von Menschen und Teams das Potenzial hat, wahre Veränderungen zu bewirken.

Wir bringen nicht nur mehr als drei Jahrzehnte praktische Erfahrung in der Softwareentwicklung mit, sondern zeichnen uns auch durch unsere tiefe Verwurzelung in agilen Methoden und unsere ganzheitliche Projektumsetzung aus. Dabei verstehen wir uns nicht nur als Berater, sondern als Partner auf Augenhöhe für unsere Kund:innen, mit denen wir echte Wirkung in der Zusammenarbeit erzielen. Auf dieser Grundlage haben wir unsere Tipps zusammengestellt.

Inhaltsverzeichnis

Die Leittheorie
Strategisches Design aus dem Domain-driven Design (DDD)

Strategisches Design – eine der beiden ursprünglichen Disziplinen des Domain-driven Design (DDD) – hat sich als bewährter Ansatz zur Strukturierung moderner Frontends etabliert. Ziel ist es, ein Softwaresystem in verschiedene Subdomains (Teilbereiche) zu untergliedern. In einer Fluggesellschaft könnten das beispielsweise folgende Subdomains sein:

Blogpost Moderne Angular Architekturen: Subdomain Beispiel für eine Airline

Um diese Subdomains zu identifizieren, muss man sich die unterstützten Geschäftsprozesse genau ansehen. Besonders wichtig ist dabei die enge Zusammenarbeit zwischen Entwickler:innen und Architekt:innen auf der einen Seite sowie Fachexpert:innen auf der anderen. Workshop-Formate wie Event Storming, die DDD mit Ideen aus der agilen Softwareentwicklung verbinden, sind dafür besonders geeignet.

Eine Context Map stellt die Beziehungen und Abhängigkeiten zwischen den einzelnen Subdomains dar:

Blogpost Moderne Angular Architekturen: – Context Map Beispiel

Die getrennte Umsetzung einzelner Domains führt zu einer geringen Kopplung untereinander. Das verhindert, dass Änderungen in einem Bereich unbeabsichtigt andere beeinflussen – und verbessert damit die Wartbarkeit. Bei größeren Projekten ist es üblich, einzelnen Subteams jeweils eine oder mehrere Domains zuzuordnen.

Im gezeigten Beispiel könnte etwa der Bereich Booking nur ausgewählte Services veröffentlichen. Alternativ könnten Informationen über gebuchte Flüge im Backend per Messaging verteilt werden. Darüber hinaus bietet das Strategische Design zahlreiche weitere Muster und Ansätze, um eine lose Kopplung systematisch umzusetzen.

Übergang zum Code:
Die Architektur-Matrix

Für die Abbildung im Quellcode ist es sinnvoll, die einzelnen Domains weiter in verschiedene Module zu unterteilen:
Blogpost Moderne Angular Architekturen: Matrix

Eine Kategorisierung dieser Module erhöht die Übersichtlichkeit. Nrwl schlägt unter anderem folgende Kategorien vor (ursprünglich für Libraries), die sich auch in unserer täglichen Arbeit bewährt haben:

Ein weiterer spezieller Bereich im Code ist der Shared-Bereich, der Code zur Verfügung stellt, der domänenübergreifend genutzt werden kann. Dabei sollte es sich in erster Linie um technischen Code handeln – anwendungsfallspezifischer Code gehört in die jeweiligen Domains.

Die hier gezeigte Struktur bringt Ordnung ins System: Es gibt weniger Diskussionen darüber, wo man Code findet oder unterbringt. Darüber hinaus lassen sich auf Basis dieser Matrix zwei einfache, aber wirksame Regeln aufstellen:

Aus Sicht des Strategischen Designs darf jede Domain nur mit ihren eigenen Modulen kommunizieren. Eine Ausnahme bildet lediglich der Shared-Bereich, auf den alle Domains zugreifen dürfen.
Jedes Modul darf nur auf Module zugreifen, die in der Matrix darunter liegen – jede Modulkategorie bildet also eine Ebene.
Beide Regeln fördern die Entkopplung der einzelnen Module bzw. Domains und helfen dabei, Zyklen zu vermeiden.

Projektstruktur für die Architektur-Matrix

Die Architektur-Matrix kann im Quellcode in Form von Ordnerstrukturen abgebildet werden: Jede Domain besitzt einen eigenen Ordner, der wiederum einen Unterordner für jedes ihrer Module enthält:
Blogpost Moderne Angular Architekturen: – Context Map Beispiel – Ordner-Struktur-Beispiel
Die Modulnamen beginnen mit dem Namen der jeweiligen Modulkategorie. So erkennt man auf den ersten Blick, wo sich das Modul innerhalb der Architektur-Matrix befindet. In den Modulen befinden sich typische Angular-Bausteine wie Komponenten, Direktiven, Pipes oder Services. Seit der Einführung von Standalone-Komponenten (sowie Direktiven und Pipes) ist die Nutzung von Angular-Modulen nicht mehr notwendig. Stattdessen wird das standalone-Flag auf true gesetzt:
				
					@Component({
  selector: 'app-flight-booking',
  standalone: true,
  imports: [CommonModule, RouterLink, RouterOutlet],
  templateUrl: './flight-booking.component.html',
  styleUrls: ['./flight-booking.component.css'],
})
export class FlightBookingComponent {
}
				
			

Bei Komponenten muss zusätzlich der sogenannte Compilation Context importiert werden. Das sind alle anderen Standalone-Komponenten, Direktiven und Pipes, die im Template verwendet werden.

Ein index.ts definiert das öffentliche Interface eines Moduls. Dabei handelt es sich um ein sogenanntes Barrel, das festlegt, welche Bestandteile eines Moduls auch außerhalb verwendet werden dürfen:

				
					export * from './flight-booking.routes';

				
			

Erste Schritte mit Angular oder
direkt abtauchen ins Detail?

Ob Sie neu einsteigen oder Ihr Know-how auf das nächste Level bringen wollen –
unsere zwei Trainings liefern genau das, was Sie gerade brauchen.

Jetzt das passende Training finden

Architektur durchsetzen mit Sheriff

Die bisher beschriebene Architektur beruht auf mehreren Konventionen:

Das Open-Source-Projekt Sheriff ermöglicht es, diese Konventionen per Linting technisch durchzusetzen. Verstöße werden mit einer Fehlermeldung in der IDE oder auf der Konsole angezeigt:

Blogpost Grafik: Sheriff
linter

Ersteres liefert direktes Feedback während der Entwicklung, letzteres kann in den Build-Prozess integriert werden. So lässt sich verhindern, dass Code, der gegen die definierte Architektur verstößt, etwa im Main– oder Dev-Branch des Repositories landet.

Zur Einrichtung von Sheriff müssen zwei Pakete über npm installiert werden:

				
					npm i @softarc/sheriff-core @softarc/eslint-plugin-sheriff -D

				
			

Das erste Paket enthält Sheriff selbst, das zweite ist die Anbindung an ESLint.
Letzteres muss in der .eslintrc.json im Projekt-Root registriert werden.

				
					{
  [...],
  "overrides": [
    [...]
    {
      "files": ["*.ts"],
      "extends": ["plugin:@softarc/sheriff/default"]
    }
  ]
}
				
			

Sheriff betrachtet jeden Ordner mit einer index.ts als Modul. Standardmäßig verhindert Sheriff, dass dieser index.ts umgangen und somit auf Implementierungsdetails anderer Module zugegriffen wird. In der im Projekt-Root anzulegenden Datei sheriff.config.ts werden Kategorien (Tags) für die einzelnen Module definiert und darauf basierende Abhängigkeitsregeln (depRules) festgelegt. Die folgende Konfiguration zeigt eine Sheriff-Einrichtung für die zuvor besprochene Architektur-Matrix:

				
					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 unter src/app/domains/<domain>, dessen Ordnername mit feature-* beginnt, erhält somit die Kategorien domain:<domain> und type:feature. Im Fall von src/app/domains/booking wären das die Kategorien domain:booking und type:feature.

Die Abhängigkeitsregeln unter depRules greifen diese Kategorien auf und legen z. B. fest, dass ein Modul nur auf Module derselben Domain und auf domain:shared zugreifen darf. Weitere Regeln definieren, dass jede Schicht nur auf darunterliegende Schichten zugreifen darf. Dank der Regel root: [‚*‘] erhalten alle nicht explizit kategorisierten Ordner im Root-Verzeichnis und darunter Zugriff auf alle Module – das betrifft in der Regel die Shell der Anwendung.

Leichte Path Mappings

Path Mappings können verwendet werden, um unleserliche relative Pfade innerhalb der Imports zu vermeiden.
Diese erlauben es zum Beispiel, anstelle von

				
					import { FlightBookingFacade } from '../../data';
				
			
folgendes zu verwenden:
				
					import { FlightBookingFacade } from'@demo/ticketing/data' ;

				
			

Solche dreiteiligen Importe bestehen aus dem Projektnamen bzw. dem Namen des Workspaces (z. B. @demo), dem Domain-Namen (z. B. ticketing) und dem Modulnamen (z. B. data) – und spiegeln somit die gewünschte Position innerhalb der Architektur-Matrix wider.

Diese Notation kann unabhängig von der Anzahl der Domains und Module mit einer einzigen Path Mapping-Zuordnung in der tsconfig.json im Projekt-Root aktiviert werden:

				
					
{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    [...]
    "paths": {
      "@demo/*": ["src/app/domains/*"],
    }
  },
  [...]
}
				
			
IDEs wie Visual Studio Code sollten nach dieser Änderung neu gestartet werden, damit sie die Anpassung korrekt übernehmen.

Standalone APIs

Da Standalone-Komponenten die umstrittenen Angular-Module optional machen, stellt das Angular-Team nun sogenannte Standalone-APIs zur Verfügung, um Bibliotheken zu registrieren. Bekannte Beispiele sind provideHttpClient und provideRouter:
				
					bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)),

    importProvidersFrom(NextFlightsModule),
    importProvidersFrom(MatDialogModule),

    provideLogger({
        level: LogLevel.DEBUG,
    }),
  ],
});
				
			

Im Wesentlichen handelt es sich hierbei um Funktionen, die Provider für die erforderlichen Services zurückgeben. Die Auswahl dieses Providers und somit das Verhalten der Bibliothek kann durch die Übergabe eines Konfigurationsobjekts beeinflusst werden. Ein Beispiel dafür ist die Routen-Konfiguration, die provideRouter akzeptiert.

Aus architektonischer Sicht dienen Standalone-APIs noch einem weiteren Zweck: Sie ermöglichen es, ein Systemkomponent als Black Box zu betrachten, die unabhängig weiterentwickelt werden kann. Die Black Box kann auch zu einer Gray Box werden, indem ein Konfigurationsobjekt übergeben wird. In diesem Fall kann das Verhalten der verwendeten Systemkomponente über gut definierte Einstellungen angepasst werden, ohne dass die lose Kopplung aufgegeben werden muss. Auch hier wird das Open/Closed-Prinzip angewendet: Offen für Erweiterungen (durch Konfiguration oder Polymorphismus), geschlossen für Änderungen durch den Verbraucher.

Als Beispiel für eine benutzerdefinierte Standalone-API, die einen Logger einrichtet, dient das frühere Listing provideLogger:

				
					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,
    })),
  ]);
}
				
			

Die Funktion provideLogger nimmt ein teilweises LoggerConfig-Objekt entgegen. Der Aufrufer muss sich daher nur mit den Einstellungen befassen, die für den aktuellen Fall relevant sind. Um eine vollständige LoggerConfig zu erhalten, kombiniert provideLogger die übergebene Konfiguration mit einer Standardkonfiguration. Basierend darauf gibt sie verschiedene Provider zurück.

Die Funktion makeEnvironmentProviders aus @angular/core umschließt das erstellte Provider-Array mit einem Objekt des Typs EnvironmentProviders. Dieser Typ kann beim Starten der Anwendung und in Routing-Konfigurationen verwendet werden. Auf diese Weise können Provider für die gesamte Anwendung oder einzelne Teile bereitgestellt werden.

Im Gegensatz zu einem traditionellen Provider-Array können EnvironmentProviders nicht innerhalb von Komponenten verwendet werden. Diese Einschränkung ist absichtlich, da die meisten Bibliotheken, wie der Router, für die Verwendung über Komponenten hinweg ausgelegt sind.

Fazit

Strategic Design unterteilt ein System in verschiedene Bereiche, die möglichst unabhängig voneinander implementiert werden. Diese Entkopplung verhindert, dass Änderungen in einem Anwendungsbereich Auswirkungen auf andere haben. Der hier vorgestellte Architekturansatz unterteilt die einzelnen Domains in verschiedene Module – und das Open-Source-Projekt Sheriff sorgt dafür, dass diese Module nur unter Einhaltung definierter Regeln miteinander kommunizieren.

Auf diese Weise lassen sich große und langfristig wartbare Frontend-Monolithen umsetzen. Aufgrund ihrer modularen Struktur ist auch von sogenannten Modulithen die Rede. Ein Nachteil solcher Architekturen sind längere Build- und Testzeiten. Dieses Problem lässt sich mit inkrementellen Builds und Tests lösen.

Erste Schritte mit Angular oder
direkt abtauchen ins Detail?

Ob Sie neu einsteigen oder Ihr Know-how auf das nächste Level bringen wollen –
unsere zwei Trainings liefern genau das, was Sie gerade brauchen.

Jetzt das passende Training finden

Manfred Streyer

Autor

Manfred Steyrer

Newsletter

Melden Sie sich jetzt für unseren Newsletter an und erhalten Sie einen 100€-Gutschein auf alle Trainings!

*“ zeigt erforderliche Felder an

Name*