07/07/2025

Architektur einer Angular Anwendung nachhaltig mit Verticals verbessern

Einleitung

Die Architektur einer Anwendung ist langfristig ein wichtiger Faktor für die Qualität und Wartbarkeit einer Anwendung. Viel zu häufig wird das Thema allerdings viel zu stiefmütterlich behandelt und selten mit geeigneten Tools unterstützt.

In dieser Case-Study möchte ich aufzeigen welche Probleme u. Tradeoffs ich bei einer Angular Anwendung eines Kunden von uns vorgefunden habe und wie ich diese mit Hilfe von Verticals und geeigneten Tooling nachhaltig lösen konnte.

Ausgangssituation

Die Ausgangssituation ist eine Angular Anwendung, die nach einem horizontalen Schnitt ihre Module organisiert hatte. Das bedeutet, dass die Module nicht nach Fachlichkeit, sondern nach technischen Aspekten bzw. Schichten organisiert waren. Vereinfacht dargestellt waren die Module in der Anwendung wie folgt organisiert:

Horizontaler Schnitt
Initiale Architektur - horizontaler Schnitt

Häufig findet man in Angular Anwendungen, die nach diesem Prinzip organisiert sind Module wie Core, Shared, Components, Services, Directives, Pipes usw.

Dies führte zu einer Vielzahl von Problemen:

  • Fehlende Kapselung von Fachlichkeit: Die Module waren nicht in der Lage, ihre eigene Fachlichkeit zu kapseln. Dies führte dazu, dass Änderungen an einem Modul oft auch Änderungen an anderen Modulen erforderten.
  • Fehlende Code-Colocation: Die Module waren nicht in der Lage, ihren eigenen Code zu kapseln. Dies führte dazu, dass der Code für ein Feature oft über mehrere Module verteilt war.
  • Fehlende Modularisierung: Die Module waren nicht in der Lage, ihre eigene Modularität zu kapseln. Dies führte dazu, dass die Anwendung schwer zu warten und zu erweitern war.
  • Fehlende Architekturautomatisierung: Durch die ungünstige Organisation der Module war es nicht möglich Architekturvorgaben automatisiert durchzusetzen.
  • Schlechte Developer Experience: Die Entwickler mussten ständig zwischen verschiedenen Modulen hin- und herspringen, um den Code für ein Feature zu finden. Dies führte zu einer schlechten Developer Experience und machte es schwierig, neue Features zu entwickeln oder bestehende Features zu ändern. Besonders für neue Entwickler war es daruch sehr schwierig, sich in die Anwendung einzuarbeiten.

In der konkreten Kundenanwendung lag vereinfacht folgende Ordnerstruktur vor:

src/
└── app/
    ├── feature/
    │   ├── detail/
    │   │   ├── product-detail.component.ts
    │   │   └── services-detail.component.ts
    │   └── overview/
    │       ├── product-overview.component.ts
    │       └── services-overview.component.ts
    ├── shared/
    │   ├── models/
    │   │   ├── customer.ts
    │   │   ├── product.ts
    │   │   └── service.ts
    │   ├── services/
    │   │   ├── products-api.service.ts
    │   │   └── services-api.service.ts
    │   └── utils/
    │       ├── create-log-entry.ts
    │       └── list-all-products.ts
    ├── state/
    │   ├── products/
    │   │   ├── products.actions.ts
    │   │   ├── products.effects.ts
    │   │   ├── products.reducer.ts
    │   │   ├── products.selectors.ts
    │   │   └── products.state.ts
    │   └── services/
    │       ├── services.actions.ts
    │       ├── services.effects.ts
    │       ├── services.reducer.ts
    │       ├── services.selectors.ts
    │       └── services.state.ts

Ziele

Ausgehend von den oben genannten Problemen ergeben sich schlussendlich die folgenden Ziele: Kapselung von Fachlichkeit/Features, Code-Colocation, Modularisierung, Architektur automatisiert erzwingen und eine gute Developer Experience. Durch die Umsetzung mit Verticals ergibt sich auf natürliche Weise ein homogener Zielzustand, in dem die Ziele gegenseitig zur Erreichung beitragen und nicht im Konflikt zueinander stehen.

Theorie

Was sind Verticals?

Verticals genauer gesagt Vertical Slice Architecture (VSA) ist ein Architekturansatz, der darauf abzielt, die Anwendung nach vertikalen Schnitten zu organisieren, die jeweils eine bestimmte Funktionalität oder ein bestimmtes Feature kapseln. Dies bedeutet, dass jede vertikale Schicht alle notwendigen Komponenten enthält, um eine bestimmte Funktionalität bereitzustellen. Horizontale Schnitte hingegen organisieren die Anwendung nach technischen Aspekten, was zu den oben genannten Problemen führt.

Types und Scopes

Ein Type und ein Scope sind zwei wichtige Konzepte im Zusammenhang mit Verticals, die helfen, die Verticals nach Modulen zu organisieren und zu strukturieren. Sie decken jeweils eine Dimension ab, die für die Organisation der Verticals wichtig ist.

  • Scope: Ein Scope ist ein Bereich oder eine Domäne, die eine bestimmte Funktionalität oder ein bestimmtes Feature kapselt. Es definiert den Kontext, in dem die Vertical operiert. Zum Beispiel könnte ein Scope für eine E-Commerce-Anwendung “Bestellungen” sein, was bedeutet, dass der gesamte Code, der sich auf Bestellungen bezieht, in dieser Vertical enthalten ist.
  • Type: Der Type ist ähnlich einer Schicht aus der Schichtenarchitektur und definiert die Art der Funktionalität, z.B. Models bzw. ein concern. Im Zusammenhang mit Angular und auch durch den Einfluss von Nx, sind folgende Types gängig:
    • Feature: Enthält die Logik und die Komponenten für ein bestimmtes Feature. Insbesondere Smart Components.
    • UI: Enthält die UI-Komponenten (Dumb Components), die für das Feature verwendet werden.
    • Data Access: Enthält die Logik für den Zugriff auf Daten, z.B. Services, die HTTP-Anfragen durchführen, sowie Modelle, die die Datenstruktur definieren. Kann auch State Management beinhalten.
    • Utils: Utility-Funktionen.

Abweichend von den genannten Types, welche an Nx anglehnt sind, werden in dieser Case Study zusätzlich die folgenden Types verwendet:

  • Types: Enthält die Modelle, die für das Feature verwendet werden, z.B. Interfaces oder Enums. Dieser Concern wird aus dem Type Data Access herausgelöst, um eine klare Trennung zwischen den Datenstrukturen und der Logik für den Datenzugriff zu gewährleisten.
  • State: Enthält den State für das Feature, z.B. den NgRx Signal Store.
  • Events: Enthält die (Store) Events für das Feature. Im Falle eines NgRx Signal Stores sind das die Actions.

Zugriffsregeln

Unter Zugriffsregeln versteht man die Regeln, die definieren, wie die Module aufeinander zugreifen dürfen. Die Regeln sind wichtig, um die Kapselung der Verticals zu gewährleisten, sodass die Verticals unabhänig voneinander sind bzw. eine loose Kopplung haben. Innerhalb eines Verticals sind Zugriffsregeln wichtig, um die Modularität innerhalb des Verticals zu gewährleisten und seine gewünschte Architektur einzuhalten. D.h. die Zugriffsregeln regeln Zugriffe in den Dimensionen Scope und Type.

Die Zugriffsregeln sind auch unter dem Begriff Module Boundaries bekannt.

Module Boundaries in der Dimension Scope

Module Boundaries Scope
Boundaries zwischen Scopes

In der Dimension Scope werden die Module Boundaries üblicherweise so definiert, dass Zugriffe zwischen den Scopes nicht erlaubt sind mit Außnahme des shared-Scope.

Durch diese Regelung wird sichergestellt, dass die Verticals unabhängig voneinander sind und nur über definierte Schnittstellen miteinander kommunizieren. Dies fördert die Kapselung der Fachlichkeit und verhindert, dass Änderungen in einem Scope Auswirkungen auf andere Scopes haben.

Außerdem könnte man so auch einfach ein Vertical/Feature aus der Anwendung in eine separate Anwendung auslagern oder auch zusammenführen.

Module Boundaries in der Dimension Type

In der Dimension Type werden die Module Boundaries üblicherweise anhand der Aufgabe des Moduls definiert und beinflussen maßgeblich die Architektur innerhalb eines Verticals (Scope).

Module Boundaries Types
Boundaries zwischen den Typen innerhalb eines Scopes

In der Regel sind die Zugriffsregeln in der Dimension Type so definiert, dass:

  • Feature- darf auf die Module UI, Data Access, Types, Utils, Events und State zugreifen.
  • UI darf auf die Module Types, Utils, Events und Types zugreifen.
  • Data Access darf auf die Module Types und Utils zugreifen.
  • Types darf auf kein weiteres Modul zugreifen.
  • Utils darf auf das Modul Types zugreifen.
  • State darf auf die Module Types, Data Access, Events und Utils zugreifen.
  • Events darf auf das Modul Types zugreifen.

Tooling

Um die Architektur-Vorgaben umzusetzen gibt es im TypeScript und Angular Ökosystem vor allem drei Tools, die sich dafür eigenen: NX Module Boundaries, ESLint Plugin Boundaries und Sheriff. Alle Tools bauen letztlich auf ESLint auf bzw. können mit Eslint integriert werden.

In der Case Study wird Sheriff verwendet.

Sheriff

Sheriff ist ein Tool, das darauf spezialisiert ist Module Boundaries und Zugriffsregeln bzw Dependency Rules zu definieren und via ESLint zu überprüfen.

Sheriff ist ein Open Source Projekt, das von softarc-consulting entwickelt wird. Es ist in TypeScript geschrieben und kann in jede TypeScript-basierte Anwendung integriert werden, einschließlich Angular-Anwendungen. Sheriff bietet eine einfache Möglichkeit, die Architekturvorgaben zu definieren und zu überprüfen, ohne dass manuelle Überprüfungen erforderlich sind. Maßgeblich wird Sheriff von Rainer Hahnekamp entwickelt und vorangetrieben.

Einen einführenden Arktikel zu Sheriff habe ich hier geschrieben: Scalable Architecture with Sheriff

Umsetzung im Praxisbeispiel

Module Boundaries Types
Boundaries zwischen den Typen innerhalb eines Scopes

Integration von Sheriff

Als erstes wurde sheriff integriert und mit Hilfe der modules und dependency rules die entsprechenden Architekturvorgaben umgesetzt:

import { SheriffConfig, sameTag } from '@softarc/sheriff-core';

export const config: SheriffConfig = {
  enableBarrelLess: true,
  modules: {
    'src/app/<feature>/<type>': [
      'scope:<feature>',
      'type:<type>',
    ],
  }, 
  depRules: {
    root: 'noTag',
    noTag: 'noTag',
    'scope:*': [sameTag, 'scope:shared'],
    'app:*': ({ from, to }) => from === to,
    'type:feature': ['type:ui', 'type:types', 'type:data-access', 'type:utils', 'type:state', 'type:events'],
    'type:ui': ['type:ui', 'type:types', 'type:utils'],
    'type:utils': ['type:types', 'type:utils'],
    'type:types': ['type:types'],
    'type:data-access': ['type:types', 'type:utils', 'type:data-access'],
    'type:state': ['type:data-access', 'type:types', 'type:utils', 'type:events'],
    'type:events': ['type:types', 'type:events'],
  },
};

Die Sheriff Konfiguration definiert die Module und die Zugriffsregeln für die Verticals. Die Module werden anhand des Scopes und des Types definiert. Die Zugriffsregeln werden anhand der Tags definiert, die den Modulen zugewiesen sind. Die Tags sind in diesem Fall die Scopes und Types, die den Modulen zugewiesen sind. Die Zugriffsregeln definieren, welche Module auf welche anderen Module zugreifen dürfen.

Die Sheriff Konfiguration wird in der Datei sheriff.config.ts im Root-Verzeichnis der Anwendung gespeichert. Sheriff anschließend mit ESLint integriert werden. Werden die dependency rules verletzt, erhält man entsprechend Fehlermeldungen nach folgendem Muster, wenn ng lint ausgeführt wird:


1:1  error    
module src/app/shared/data-access cannot access src/app/admin/data-access. 

Tag scope:shared has no clearance for tags scope:admin, type:data-access  @softarc/sheriff/dependency-rule

Umsetzung der Verticals

Anschließend wurden die Verticals umgesetzt. Dabei wurde die Ordnerstruktur der Anwendung angepasst, um die Verticals zu repräsentieren. Die Verticals wurden anhand der Scopes und Types organisiert. Die Ordnerstruktur sieht nun wie folgt aus:

src/
└── app/
    ├── products/
    │   ├── data-access/
    │   │   └── products-api.service.ts
    │   ├── events/
    │   │   └── products.actions.ts
    │   ├── feature/
    │   │   └── product-overview.component.ts
    │   ├── state/
    │   │   ├── products.effects.ts
    │   │   ├── products.reducer.ts
    │   │   ├── products.selectors.ts
    │   │   └── products.state.ts
    │   ├── types/
    │   │   └── product.ts
    │   ├── ui/
    │   │   └── product-detail.component.ts
    │   └── utils/
    │       └── list-all-products.ts
    └── services/
        ├── data-access/
        │   └── services-api.service.ts
        ├── events/
        │   └── services.actions.ts
        ├── feature/
        │   └── services-overview.component.ts
        ├── state/
        │   ├── services.effects.ts
        │   ├── services.reducer.ts
        │   ├── services.selectors.ts
        │   └── services.state.ts
        └── types/
            └── service.ts
        └── ui/
            └── services-detail.component.ts
    └── shared/
        ├── types/
        │   └── customer.ts
        └── utils/
            └── create-log-entry.ts

Wie man beim Vergleich der Ordnerstrukturen sieht, wurde die Anwendung nun nach Verticals organisiert. Jede Vertical enthält alle notwendigen Komponenten, um eine bestimmte Funktionalität bereitzustellen. Die Verticals sind unabhängig voneinander und können leicht erweitert oder geändert werden, ohne dass dies Auswirkungen auf andere Verticals hat.

Gleichzeitig könnte man potentiell jedes einzelne Vertical jederzeit in eine separate Anwendung auslagern, da die Verticals unabhängig voneinander sind und nur über definierte Schnittstellen bzw. entkoppelte events miteinander kommunizieren.

Zusammenfassung & Ausblick

In dieser Case Study wurde gezeigt, wie man die Architektur einer Angular Anwendung nachhaltig verbessern kann, indem Verticals umgesetzt werden und mit ESLint die Einhaltung der Architetur autmatisiert überprüft wird.

Im realen Projekt wurde dies mit einem Big-Bang Refactoring umgesetzt. Das heißt, die gesamte Anwendung wurde in einem Rutsch auf die neue Architektur umgestellt. Dieser Ansatz war nur möglich, da die Anwendung eine überschaubare Größe hatte und die Features bereits recht gut voneinander entkoppelt waren.

Meistens ist dieser Ansatz allerdings nicht möglich und die Umstellung muss inkrementell in kleineren Schritten erfolgen. Auch diese Vorgehensweise ist mit Sheriff möglich - wie? Das werde ich in einem zukünftigen Artikel zeigen.

Im Kundenprojekt haben sich nach der Umstellung auf Verticals und der Integration von Sheriff die Developer Experience, Wartbarkeit und Modularität der Anwendung deutlich verbessert. Die Entwickler konnten sich schneller in die Anwendung einarbeiten und neue Features entwickeln, ohne dass sie ständig zwischen verschiedenen Modulen hin- und herspringen mussten. Auch die Kapselung von Fachlichkeit wurde verbessert, da die Verticals nun in der Lage sind, ihre eigene Fachlichkeit zu kapseln. Die Architekturautomatisierung wurde durch die Integration von Sheriff ebenfalls deutlich verbessert, da nun automatisch überprüft wird, ob die Architekturvorgaben eingehalten werden.