
Architecting Angular Applications - Teil 5: Beyond Angular
Veröffentlicht am 17. August 2025 - ⏱️ Ca. (wird berechnet) min. Lesezeit
In den ersten vier Teilen dieser Serie haben wir uns intensiv mit den sichtbaren Bausteinen von Angular beschäftigt: Komponenten, Direktiven und Services. Wir haben gelernt, wie schnell sich UI‑Logik und Geschäftslogik vermischen können und wie wichtig klare Verantwortlichkeiten sind. Genau deshalb folgt nun der Schritt über das Framework hinaus.
Kleiner Einschub vorneweg: Dieser Teil beschäftigt sich vor allem mit Massnahmen, die im Enterprise Bereich an Bedeutung gewinnen. Es gibt nicht den einen Weg und gerade für kleine Projekte ist es oft nicht sinnvoll, eine derart komplexe Architektur aufzubauen!
Warum überhaupt „Beyond Angular“?
Angular ist ein hervorragendes Werkzeug, um UIs zu bauen. Solange eine Anwendung klein ist, kann man Regeln, Berechnungen und Validierungen in Components und Services unterbringen. Doch mit wachsender Komplexität entsteht schnell ein technischer Schuldenberg. Oberflächenlogik beginnt unmerklich, Geschäftsprozesse zu steuern. Eine ganz neue Dimension entsteht, wenn ein zweites oder drittes Frontend hinzukommt, bei grösserem Refactoring oder bei der Wiederverwendung von Logik im Backend / Skripten oder anderen Plattformen.
Ein Beispiel verdeutlicht die Richtung: Rabatte hängen von mehreren Faktoren ab. Zunächst implementiert in einer Komponente, später benötigt im Admin‑Panel, danach in einer mobilen App und in einem Cronjob. Kopieren führt zu Divergenzen. Zentralisieren im Backend macht das Frontend abhängig und langsamer. Besser ist es, die Regeln in einen Kern auszulagern, der unabhängig von Angular ist. Tests können dann ohne die UI laufen, und die Logik lässt sich in verschiedenen UIs wiederverwenden. Auch wenn dieser Gedanke zunächst selbstverständlich klingt, so wird er doch in Angular‑Projekten häufig übersehen.
Clean Architecture, Domain‑Driven Design und die Hexagonale Architektur trennen das Fachliche radikal von Implementierungsdetails. Diese Ideen werden vor allem in Backend Systemen angewendet, im Frontend aber oft vernachlässigt. Tatsächlich liest man sogar häufig, das seien "esoterische" Ansätze, die kaum praxis relevant seien. Nachfolgend möchte adressieren, dass man diese Prinzipien sehr wohl erfolgreich in der Praxis anwenden kann und im Enterprise Bereich zumindest in Erwägung ziehen und kennen sollte.
Clean Architecture und Ports & Adapters
Erfolgreiche Architekturmodelle teilen Code entlang fachlicher Grenzen – und das gerne in Schichten, von innen nach aussen. Clean Architecture unterscheidet zwischen Domäne (Geschäftsregeln), Anwendungsfällen und technischen Details. Je weiter innen, desto stabiler und framework‑unabhängiger. Die Schichten im Überblick:
- Domain: Zentrale Konzepte wie Order, Customer, DiscountPolicy. Keine Abhängigkeiten zu Angular oder HTTP. Reine Geschäftslogik.
- Application: Anwendungsfälle wie Bestellung auslösen oder Rabatt berechnen. Orchestrierung mehrerer Domänenobjekte. Kommunikation mit der Aussenwelt über abstrakte Ports.
- Infrastructure und Presentation: Datenbanken, APIs, Dateisysteme, Oberflächen. Diese Schichten implementieren Ports und binden den Kern an Technik und UI. Angular‑Komponenten gehören in die Präsentation.
Dieses Modell ist keineswegs nur akademisch. Die Port & Adapter‑Architektur (auch Hexagonale Architektur genannt) beschreibt denselben Gedanken. Sie sorgt dafür, dass die Applikation über klar definierte Ports mit der Aussenwelt spricht, während Adapter die technische Umsetzung liefern. Der Kern bleibt stabil und kann von Tests, Skripten oder verschiedenen UIs gleichermassen benutzt werden. Schon Cockburn betonte: Ziel ist es, die Anwendung so zu bauen, dass sie ohne UI oder Datenbank lauffähig ist.
Ports und Adapter im Angular‑Umfeld
Wie sieht das konkret in Angular aus? Das Schöne an TypeScript ist, dass es Interface‑Typen bietet. Ein Port ist letztlich nichts anderes als ein Interface. Ein Beispiel wäre ein PaymentGateway. Die Aufgabe dieser Klasse / Funktion ist es, dass ein Zahlungsauftrag ausgelöst wird. Also es soll Geld fliessen. Der Port ist nur eine Beschreibung, ein Interface. Wie und wer das dann tatsächlich implementiert, ist hier nicht wichtig. Wichtig ist erstmal nur, dass die Geschäftslogik das verwenden kann und definieren kann, dass mit dieser Schnittstelle eine Zahlung ausgelöst wird:
charge(amount: Money, reference: string): Promise<AuthId>
An keiner Stelle taucht Angular oder RxJS auf. In Tests kann man eine Fake‑Implementierung bereitstellen.
Ein Adapter implementiert dieses Interface. In Angular kann das ein Service sein, der HttpClient
injiziert und Requests an einen Zahlungsdienst sendet. In Anwendungsfällen wird nur der Port verwendet, nicht der
konkrete Adapter. Der Kern bleibt framework‑agnostisch. Adaptervarianten wie GraphQL, In‑Memory oder
Plattformwechsel betreffen nur die Aussenwelt.
Wiring an der Kante: Dependency Injection ohne Framework‑Abhängigkeit im Kern
Use‑Cases / Anwendungsfälle im Kern sind Plain‑TypeScript. Sie kennen Angular nicht. Die Verdrahtung der Ports mit konkreten Adaptern geschieht am Rand der Anwendung. In Angular übernimmt dies die Dependency Injection. Das erfordert etwas Provider‑Code, schafft aber klare Kanten: Der Kern bleibt sauber. Tests und Skripte können die gleichen Use‑Cases ohne Angular verwenden.
Hierzu muss man sich einmal mit dem Angular Dependency System auseinandersetzen, aber es ist nicht so schwer. Ein minimales Muster zeigt die Komposition. Zunächst der abstrakte Port, dann der Use-Case, der ihn verwendet.
// core/application/ports/order-gateway.port.ts
export interface OrderGateway {
deploy(order: Order): Promise<OrderId>;
}
// core/application/use-cases/deploy-order.usecase.ts
import type { OrderGateway } from '../ports/order-gateway.port';
export class DeployOrderUseCase {
constructor(private readonly gateway: OrderGateway) {}
async execute(order: Order): Promise<OrderId> {
return this.gateway.deploy(order);
}
}
Ganz simpel: Es gibt ein Interface, den Port. Und der Use-Case verwendet diesen, indem er davon ausgeht, dass er im constructor irgendetwas bekommt, das diesem Interface entspricht. Gemeint ist der Adapter. Das entspricht der Angular Dependency Injection, welche sich so einfach einbinden lässt. Schauen wir, wie es nun im Angular konkret weitergeht. Zuerst brauchen wir den besagten Adapter:
// infrastructure/http/order-gateway.adapter.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { OrderGateway } from '../../core/application/ports/order-gateway.port';
@Injectable({ providedIn: 'root' })
export class HttpOrderGateway implements OrderGateway {
constructor(private readonly http: HttpClient) {}
async deploy(order: Order) {
const dto = await firstValueFrom(
this.http.post<{ id: string }>('/api/orders/deploy', order)
);
return OrderId.from(dto.id);
}
}
// infrastructure/tokens/order-gateway.token.ts
import { InjectionToken } from '@angular/core';
import type { OrderGateway } from '../../core/application/ports/order-gateway.port';
export const ORDER_GATEWAY = new InjectionToken<OrderGateway>('ORDER_GATEWAY');
Jetzt können wir das in Angulars DI ganz einfach verbinden mit dem Use-Case:
// infrastructure/di/order.providers.ts
import { makeEnvironmentProviders, EnvironmentProviders, inject } from '@angular/core';
import { HttpOrderGateway } from '../http/order-gateway.adapter';
import { ORDER_GATEWAY } from '../tokens/order-gateway.token';
import { DeployOrderUseCase } from '../../core/application/use-cases/deploy-order.usecase';
export function bindOrderGatewayToHttp(): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: ORDER_GATEWAY, useExisting: HttpOrderGateway },
]);
}
export function provideDeployOrder(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: DeployOrderUseCase,
useFactory: () => new DeployOrderUseCase(inject(ORDER_GATEWAY)),
},
]);
}
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { bindOrderGatewayToHttp, provideDeployOrder } from './infrastructure/di/order.providers';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
bindOrderGatewayToHttp(),
provideDeployOrder(),
],
};
Fertig. Der restliche Angular Code fühlt sich dafür komplett nativ an, hier die Checkout Component:
// ui/feature/checkout/checkout.component.ts
@Component({
selector: 'app-checkout',
templateUrl: './checkout.component.html',
})
export class CheckoutComponent {
constructor(private readonly deployOrder: DeployOrderUseCase) {}
async submit(order: Order) {
const result = await this.deployOrder.execute(order);
// handle result
}
}
Diese Entkopplung macht den Kern framework‑agnostisch. Ein GraphQL‑Adapter, eine In‑Memory‑Variante oder ein Adapter für eine andere Plattform lassen sich später hinzufügen, ohne den Kern zu ändern. Gleichzeitig können wir Tests schreiben, die nur die Geschäftsregeln prüfen und für die ein einfacher Fake‑Adapter genügt. Das ist der Gewinn: Unsere Logik wird tragbar, austauschbar und testbar.
Ein framework‑agnostischer Kern in der Praxis
Wie lässt sich dieser Ansatz ohne Ballast in einem Projekt umsetzen? Eine klare Ordnerstruktur reicht. Es braucht nicht zwingend Tools wie Nx – auch wenn diese schnell sehr nützlich werden:
src/ ├─ core/ │ ├─ domain/ │ │ ├─ entities/ # Order.ts, User.ts, DiscountPolicy.ts │ │ └─ value-objects/ # EmailAddress.ts, Money.ts │ ├─ application/ │ │ ├─ use-cases/ # place-order.usecase.ts │ │ └─ ports/ # payment-gateway.port.ts, order-repository.port.ts │ └─ shared-kernel/ # domain helpers, errors ├─ infrastructure/ │ ├─ http/ # http-payment-gateway.adapter.ts │ └─ persistence/ # in-memory-order-repository.adapter.ts ├─ ui/ │ ├─ pages/ # checkout.page.ts │ ├─ components/ # checkout.component.ts │ └─ state/ # shopping-cart.store.ts └─ main.ts # Angular bootstrap
Die Business‑Logik liegt in core/
. Use‑Cases orchestrieren Ports wie PaymentGateway
und
OrderRepository
. Tests ersetzen Adapter durch Fakes und prüfen Regeln ohne Browser oder TestBed. infrastructure/
enthält Adapter. ui/
hält Komponenten und Stores. Validierung, Rabatte und Orchestrierung verbleiben im
Kern. Die Komponente ruft den Use‑Case auf und zeigt Ergebnisse an.
utils/
als Sammelbecken vermeiden. Entweder domainnahe Unterordner oder ein klar
umrissener shared-kernel/
mit allgemeinen, fachlich motivierten Hilfen wie Result
,
Fehlern, Parsern.
Diese Struktur ist nur ein Vorschlag, aber sie zeigt den Kern der Idee: Trennen zwischen Fachlichem, Technik und Darstellung. Jeder Ordner hat eine klare Verantwortung. Die Ebenen können auch weniger formal aufgeteilt sein, aber am Prinzip festhalten: Das Fachliche darf nichts von Angular wissen.
Was ist mit Dependency Injection und lokalen Stores?
Die Abstraktion lebt nur im Kern, aber die Implementierungen nutzen weiterhin Angulars DI. Die Ports werden über Injection‑Tokens oder abstrakte Klassen injiziert. Die Adapter werden als @Injectable bereitgestellt und sind Teil des DI‑Systems.
UI‑Stores gehören nicht in den Kern. Sie sind Infrastruktur der Präsentation. Für lokale Stores nutzt Angular Provider auf Komponentenebene.
@Component({
selector: 'cart-widget',
providers: [provideShoppingCartStore()],
templateUrl: './cart-widget.component.html',
})
export class CartWidgetComponent {
constructor(
private readonly cartStore: ShoppingCartStore,
private readonly placeOrder: PlaceOrderUseCase
) {}
checkout() {
this.placeOrder.execute(this.cartStore.items());
}
}
Stores, Use Cases und Zustandsmanagement
Nur fachlicher Zustand gehört in die Domäne. Er manifestiert sich in Entitäten und Value‑Objects. Ein Store ist eine technische Hülle für UI‑Zustand. Filter, aktuelle Seite, temporärer Input und Anzeigezustände werden dort gehalten. Die Quelle der Wahrheit verbleibt in der Domäne und den Anwendungsfällen.
Greifen mehrere Komponenten auf denselben Zustand zu, lohnt ein globaler Store mit klarer Regelung konkurrierender Updates. Die Geschäftslogik im Use‑Case arbeitet mit unveränderlichen Datenstrukturen und bleibt vor Race‑Conditions geschützt. UI‑Stores dürfen Daten cachen, sind jedoch nicht die Quelle der Wahrheit. Sie leiten ihren Zustand aus dem fachlichen Modell ab und können jederzeit neu aufgebaut werden.
Dienste richtig einordnen (ASU‑D revisited)
In Teil 4 wurden Angular‑Services nach ASU‑D kategorisiert. Diese Kategorien helfen weiterhin, die richtigen Ebenen zu finden:
- Adapter‑Services: Implementierungen von Ports; leben in infrastructure/.
- Store‑Services: UI‑zustandsbezogene Services; leben in ui/state/.
- Utility‑Services: Technische Helfer wie Logger; ihre Platzierung hängt vom Kontext ab.
- Domain‑Services: Fachliche Services; gehören in den core/‑Ordner.
Diese Einordnung schärft das Bewusstsein dafür, wo Logik hingehört. Spätestens wenn ein Service DOM‑Manipulationen und Geschäftsregeln mischt, sollte man innehaltend nachdenken.
Tests und Austauschbarkeit
Use‑Cases werden ohne Angular TestBed getestet. Ports erhalten Fakes oder In‑Memory‑Adapter. Contract‑Tests prüfen
dasselbe Port‑Verhalten sowohl gegen Fakes als auch gegen echte Adapter. Zeit und IDs werden über Ports wie Clock
und IdGenerator
kontrolliert.
// core/application/use-cases/place-order.usecase.spec.ts
it('applies VIP discount with a hard cap', async () => {
const orders = new InMemoryOrders();
const payments = new FakePayments();
const clock = new FixedClock(new Date('2025-01-01T00:00:00Z'));
const uc = new PlaceOrderUseCase(orders, payments, clock);
const id = await uc.execute(/* command */);
expect(id).toBeInstanceOf(OrderId);
});
Vorteile und Wirtschaftlichkeit
Warum den Aufwand betreiben? Weil man damit Systeme bauen, die länger leben als ihr UI‑Framework bzw. ihre momentane Applikation. Ein framework‑agnostischer Kern ist leicht testbar – Wir müssen nur Ports mocken und können ohne Browser testen. Er ist wiederverwendbar – die gleiche Logik lässt sich in einem Admin‑Panel, einer Mobil‑App oder als Batch‑Job nutzen. Und er ist zukunftssicher – sollte Angular eines Tages durch ein anderes Frontend ersetzt werden, oder weitere Anwendungen hinzukommen, bleiben die Geschäftsprozesse erhalten.
Natürlich kostet diese Strukturierung Disziplin. Für kleine Hobbyprojekte reicht eine einfache Schichtung. Doch sobald mehrere Teams, unterschiedliche UIs oder geschäftskritische Regeln im Spiel sind, zahlt sich die zusätzliche Ebene aus. Geschaffen wurde ein Lego-Baukasten mit maximaler Flexibilität.
Fazit: Jenseits des Frameworks beginnt die Architektur
„Beyond Angular“ heisst nicht, Angular zu verlassen. Es bedeutet, sich die Frage zu stellen: Was bleibt, wenn Angular morgen nicht mehr da wäre? Wenn die Antwort „unser Kern“ lautet, wurde die Architektur stark aufgebaut. Diese Serie begann mit einfachen Bausteinen wie Components und Directives und endet nun mit dem Blick auf das Herz einer Anwendung. Baut man dieses Herz sauber, unabhängig und stark – dann kann ein Frontend kommen und gehen, ohne dass das Geschäft darunter leidet.
Ich hoffe, dieser abschliessende Beitrag ermutigt, Business‑Logik aus dem Framework zu befreien und damit die Lebensdauer von Anwendungen zu verlängern. Feedback, Erfahrungen aus Projekten und konstruktive Diskussionen sind wie immer sehr willkommen.