BLOG
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.
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.
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:
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:
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.
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:
@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';
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
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:
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/': {
'feature-': ['domain:', 'type:feature'],
'ui-': ['domain:', 'type:ui'],
'data': ['domain:', 'type:data'],
'util-': ['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.
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';
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/*"],
}
},
[...]
}
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
): 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.
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.
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
„*“ zeigt erforderliche Felder an
TechTalk GmbH
Leonard-Bernstein-Straße 10, 1220 Wien