Wenn du diesen Artikel liest, kennst du wahrscheinlich bereits die hexagonale Architektur – ein bekanntes Designprinzip, das dabei hilft, die Kern-Business-Logik von externen Abhängigkeiten zu entkoppeln. Während dieser Ansatz im Backend-Development weit verbreitet ist, sieht man ihn im Frontend-Bereich eher selten.
Einleitung
Ich denke, das liegt an der Natur von Frameworks wie Angular, bei denen die Business-Logik oft eng mit eingebauten Mechanismen der Technologie verknüpft ist, wie Komponenten, Services, Pipes, Direktiven und Validatoren. Dadurch neigen Frontend-Frameworks eher dazu, sich an MVVM, MVC oder MVP auszurichten.
In den letzten Jahren hatte ich die Gelegenheit, die Architektur eines Angular-Projekts zu entwerfen. Da ich aus einem backend-orientierten Hintergrund komme, wollte ich herausfinden, ob sich die hexagonale Architektur auch für die Frontend-Entwicklung eignet – ähnlich wie wir sie bei WATA Factory bereits erfolgreich in vielen Backend-Projekten eingesetzt haben. Anfangs war ich besorgt, dass dieser Ansatz übertrieben oder unnatürlich wirken könnte. Doch mit der Zeit zeigte sich, dass die Vorteile überwogen.
Einer der größten Vorteile war die strukturierte Organisation der Dateien, die eine klare und konsistente Architektur ermöglichte. Trotz des zusätzlichen Boilerplates und der Schnittstellen wurde das Projekt für sowohl Frontend- als auch Backend-Entwickler leichter zugänglich. Die vertraute Struktur erleichterte das Onboarding und die Zusammenarbeit erheblich.
Inzwischen wird dieser architektonische Stil auch in anderen Projekten bei WATA Factory übernommen, da er sich als skalierbar und gut testbar erwiesen hat.
Projektstruktur-Übersicht
Unser Projekt ist nach dem Vertical-Slicing-Ansatz organisiert. Das bedeutet, dass jede Funktionalität in sich geschlossen ist und sowohl die Business-Logik als auch die Präsentation umfasst.
Die Hauptverzeichnisse sind:
app/
│── core/ # Cross-cutting concerns (DI, external libs, etc.)
│── shared/ # Shared logic across features
│ |── domain/ # Shared business logic, domain models, validation
│ |── application/ # Shared application use cases
│ |── infrastructure/ # Shared infrastructure (APIs, repositories, utilities, adapters)
│ |── presentation/ # Shared presentation logic (UI components, directives, pipes)
│── authentication/ # Feature: Logic for Authentication (example)
│ |── domain/ # Feature: Business logic, domain models, validation
│ |── application/ # Feature: Application use cases
│ |── infrastructure/ # Feature: External integrations (APIs, DB, services)
│ |── presentation/ # Feature: UI components, directives, services, styles
│── other-feature/ # Another feature (follows the same structure)
|── domain/
|── application/
|── infrastructure/
|── presentation/
Core-Verzeichnis (app/core)
Dieses Verzeichnis enthält querschnittliche Anliegen wie die Konfiguration der Dependency Injection und die Einrichtung externer Bibliotheken (z. B. JWT, Übersetzungsdienste).
Dependency Injection (app/core/di)Das DI-System von Angular arbeitet nahtlos mit abstrakten Klassen, aber nicht mit Interfaces.
Obwohl ich Interfaces bevorzuge, um eine strikte Vertragserfüllung ohne gemeinsam genutzte Logik zu gewährleisten (und somit die Implementierung vollständig zu entkoppeln), gibt es dabei einen Kompromiss:
Die Verwendung von Interfaces erfordert zusätzlichen Boilerplate-Code, insbesondere weil InjectionToken genutzt werden muss, um sie in Angulars DI-System zu integrieren.
Um dies zu konfigurieren, verwenden wir eine Datei namens injection.ts, in der wir Standardimplementierungen mithilfe von InjectionToken definieren – ein zentraler Bestandteil des DI-Container-Patterns
export const AUTH_REPOSITORY_TOKEN = new InjectionToken<AuthRepository>(
'AuthRepository',
);
export const DI_PROVIDERS = [
{
provide: AUTH_REPOSITORY_TOKEN,
useClass: ApiAuthRepository,
},
// other providers
];
Diese Provider werden dann zur app.config.ts hinzugefügt.
Shared-Verzeichnis (app/shared)
Dieses Verzeichnis ist ebenfalls strukturiert, um wiederverwendbare Logik über verschiedene Features hinweg bereitzustellen, dabei aber eine klare Trennung der Verantwortlichkeiten aufrechtzuerhalten.
Es ist wichtig sicherzustellen, dass alle Inhalte im shared-Ordner der entsprechenden Ebene zugeordnet sind – sei es Domain, Application, Infrastructure oder Presentation.
Feature-Verzeichnisse (app/authentication)
Jede Funktionalität folgt einem Vertical-Slicing-Ansatz, das bedeutet, dass sie ihre eigenen Schichten enthält.
Wir verwenden Authentication nur als Beispiel für ein Feature. Dieselbe vertikale Slicing-Strategie gilt auch für andere Features, um eine einheitliche, wartbare und skalierbare Architektur im gesamten Projekt zu gewährleisten.
Domain Layer (app/authentication/domain)
Enthält die zentrale Business-Logik, Domain-Modelle und Validierungsregeln.
Domain-Modelle (app/authentication/domain/models)
Die Domain-Modelle kapseln die Validierungslogik und das domänenspezifische Verhalten:
export class UsernameModel {
readonly value: string;
constructor(value: string) {
if (!value) {
throw new EmptyUsernameError();
}
const usernameRegex = /^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$/;
if (!usernameRegex.test(value)) {
throw new InvalidUsernameError();
}
this.value = value;
}
}
Domain Exceptions (app/authentication/domain/errors)
This directory contains domain-specific exceptions that represent business rule violations.
Domain Repository Interfaces (app/authentication/domain/repositories)
Dieses Verzeichnis enthält Abstraktionen für Daten-Repositories und definiert die Verträge, die Repository-Implementierungen einhalten müssen.
export interface AuthRepository {
loginAttempt(loginModel: LoginModel): Observable<AuthTokenModel>;
}
Application Layer (app/authentication/application)
Diese Schicht enthält Anwendungsservices, die Use Cases orchestrieren.
Hier integrieren wir auch NGRX Stores zur Zustandsverwaltung, wobei eine detaillierte Betrachtung von NGRX einen separaten Beitrag erfordern würde.
Use Cases (app/authentication/application/use-cases
)
Use Cases implementieren die Geschäftslogik und interagieren mit Repositories.
Sie folgen einer konsistenten Schnittstelle UseCase<S, T>, wobei S das Eingabemodell und T das Ausgabemodell ist.
export class LoginUseCase implements UseCase<LoginModel, UserStatusModel> {
constructor(private authRepository: AuthRepository) {
}
execute(loginModel: LoginModel): Observable<UserStatusModel> {
return this.authRepository.loginAttempt(loginModel);
}
}
The interface is defined in app/shared/application/use-cases/base/use-case.ts
import { Observable } from 'rxjs';
export interface UseCase<S, T> {
execute(params: S): Observable<T>;
}
Infrastructure Layer (app/authentication/infrastructure
)
Diese Schicht verwaltet Implementierungen für externe Integrationen wie API-Aufrufe, Datenbankinteraktionen und andere externe Dienste.
Presentation Layer (app/authentication/presentation)
Diese Schicht ist eng mit Angular verknüpft und umfasst Komponenten, Direktiven, Services und Styles.
Use Cases werden über Schnittstellen in Komponenten injiziert, um die Trennung von Application- und Infrastructure-Layer aufrechtzuerhalten.
Fazit
Bei WATA Factory setzen wir diese Architektur in unseren Angular-Projekten ein und haben zahlreiche Vorteile daraus gezogen.
Durch die Kombination von hexagonaler Architektur mit Vertical Slicing konnten wir unsere Anwendungen wartbarer, skalierbarer und testbarer machen.
Einer der größten Vorteile ist, dass unsere Business-Logik unabhängig von Angular und anderen externen Abhängigkeiten bleibt.
Das macht unseren Code modular, wiederverwendbar und viel einfacher anpassbar, wenn sich Anforderungen ändern.