Die hexagonale Architektur (oder Ports & Adapters) geht von einer einfachen Idee aus: Die Regeln der Anwendung sollen nicht wissen müssen, woher die Daten kommen oder wie sie angezeigt werden. Die Domäne sitzt in der Mitte, geschrieben in reinem TypeScript. Um sie herum liegen die Use Cases, die mit der Außenwelt nur über Ports sprechen, also über Verträge. Am Rand sitzen die Adapter: HTTP, localStorage, Angular-Komponenten. Du kannst sie austauschen, ohne den Kern anzufassen.
In Angular passt die Idee sehr gut zu DI: Eine abstrakte Klasse ist zugleich der Typ des Ports und das Injection-Token. { provide: TaskRepository, useClass: HttpTaskRepository } ist die ganze „Verdrahtung“, die du brauchst.
HttpClient. Und die Geschäftslogik verteilt sich nicht mehr über die Komponenten, wo sie schwer zu finden und schwer wiederzuverwenden ist.Die Schichten und die Dependency Rule
Es gibt vier Schichten, und Imports zeigen nur nach innen. Die Domäne importiert nichts von außerhalb. Die Application-Schicht kennt die Domäne und die Ports, aber nicht die Adapter. Adapter implementieren die Ports. Die einzige Stelle, die einen Port an einen konkreten Adapter bindet, ist die Composition Root: app.config.ts oder die providers einer Route.
┌───────────────────────── außen: Adapter ────────────────────────────┐
│ presentation (driving) infrastructure (driven) │
Nutzer ──────▶ │ TasksPage, TaskItem HttpTaskRepository, TaskDto │ ──▶ server
│ │ ┌──────────── application ─────────────┐ ▲ │
│ └──▶│ TaskBoard / AddTask ──▶ TaskRepository│───┘ implementiert│
│ │ │ ┌──── domain ────┐ │ │
│ │ └─────▶│ Task, createTask│ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Regel: Import-Pfeile zeigen NUR nach innen. Die Domäne importiert nichts von außen.
app.config.ts (Composition Root) ist die einzige Stelle, die TaskRepository an einen Adapter bindet.| Schicht | Enthält | Darf importieren |
|---|---|---|
| domain | Entitäten, Regeln, Domänenfehler | nichts (nur TypeScript) |
| application | Ports (abstrakte Klassen), Use Cases, Zustand für die UI | domain, @angular/core (DI, Signals) |
| infrastructure | Adapter: HTTP, Storage, DTOs, Mapping | application (die Ports), domain, HttpClient |
| presentation | Smart- und Dumb-Komponenten | application, domain (Typen) — niemals infrastructure |
1. Wähle die Datei, die importiert.
Domain: reines TypeScript
Die Domäne enthält die Entitäten und die Regeln: „Ein Titel hat mindestens 3 Zeichen“, „zwei offene Aufgaben dürfen nicht denselben Titel haben“. Sie importiert weder Angular noch RxJS noch Browser-APIs. Sogar die Uhr und die ID-Erzeugung kommen als Parameter herein, damit die Regeln deterministisch bleiben. Der Hauptgewinn: Die Domäne testest du mit einfachen Funktionen, ohne TestBed und ohne Mocks.
// tasks/domain/task.ts — reines TypeScript: kein Import aus Angular, RxJS oder dem Browser
export type TaskId = string & { readonly __brand: 'TaskId' };
export interface Task {
readonly id: TaskId;
readonly title: string;
readonly done: boolean;
readonly createdAt: Date;
}
export class DomainError extends Error {
override readonly name = 'DomainError';
}
export function normalizeTitle(raw: string): string {
return raw.trim().replace(/\s+/g, ' ');
}
// `now` und `id` kommen als Parameter herein: Die Domäne liest weder die Uhr noch erzeugt sie selbst IDs
export function createTask(raw: string, existing: readonly Task[], now: Date, id: TaskId): Task {
const title = normalizeTitle(raw);
if (title.length < 3) throw new DomainError('Der Titel braucht mindestens 3 Zeichen.');
if (existing.some((t) => !t.done && t.title.toLowerCase() === title.toLowerCase())) {
throw new DomainError('Es gibt bereits eine offene Aufgabe mit diesem Titel.');
}
return { id, title, done: false, createdAt: now };
}
export function toggleTask(task: Task): Task {
return { ...task, done: !task.done };
}Ports: abstrakte Klassen als Tokens
Ein Port beschreibt, was die Anwendung braucht („auflisten, speichern, löschen“), nicht wie. Du schreibst ihn als abstrakte Klasse: Sie existiert zur Laufzeit, also funktioniert inject(TaskRepository) direkt. Ein Interface würde beim Kompilieren verschwinden, und du bräuchtest ein separates InjectionToken (siehe InjectionToken). Fehler gehören zum Vertrag: Adapter übersetzen ihre technischen Fehler (Timeout, 503) in RepositoryUnavailableError, damit die UI nicht von HttpErrorResponse abhängt.
// tasks/application/task-repository.ts — der PORT
import { Task, TaskId } from '../domain/task';
// Abstrakte Klasse, kein Interface: Sie existiert zur Laufzeit und ist daher direkt ein DI-Token.
// Sie hat kein @Injectable und kennt keine Implementierung.
export abstract class TaskRepository {
abstract list(): Promise<readonly Task[]>;
abstract save(task: Task): Promise<void>; // upsert
abstract remove(id: TaskId): Promise<void>;
}
// Teil des Vertrags: Adapter übersetzen ihre technischen Fehler hierin.
export class RepositoryUnavailableError extends Error {
override readonly name = 'RepositoryUnavailableError';
}Use Cases
Ein Use Case orchestriert: Er liest über einen Port, wendet die Domänenregeln an und speichert über einen Port. In Angular ist er ein gewöhnlicher Service, der den Port injiziert, nicht den Adapter. Für die UI ist ein „Board“ praktisch, das den Zustand in Signals hält (tasks, error, open). Komponenten lesen es nur, genau wie beim State Management.
// tasks/application/add-task.ts — ein Use Case = eine Klasse mit einer einzigen öffentlichen Methode
import { Injectable, inject } from '@angular/core';
import { Task, TaskId, createTask } from '../domain/task';
import { TaskRepository } from './task-repository';
@Injectable({ providedIn: 'root' })
export class AddTask {
private readonly repo = inject(TaskRepository); // der Port — er weiß nicht, welcher Adapter antwortet
async execute(title: string): Promise<Task> {
const existing = await this.repo.list();
const task = createTask(title, existing, new Date(), crypto.randomUUID() as TaskId); // die Regel: in der Domäne
await this.repo.save(task); // I/O: über den Port
return task;
}
}Adapter
Ein Adapter extends TaskRepository und erledigt die gesamte I/O: HTTP, localStorage, Speicher. Auch das Mapping von DTO auf Domäne liegt hier (is_done: 1 wird zu done: true, der ISO-String wird zu einem Date; siehe Interfaces & Modelle). Das Format des Servers kommt nicht über den Adapter hinaus.
// tasks/infrastructure/http-task-repository.ts — der HTTP-ADAPTER
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, firstValueFrom } from 'rxjs';
import { Task, TaskId } from '../domain/task';
import { RepositoryUnavailableError, TaskRepository } from '../application/task-repository';
import { TaskDto, taskFromDto, taskToDto } from './task-dto';
@Injectable()
export class HttpTaskRepository extends TaskRepository {
private readonly http = inject(HttpClient);
private readonly url = '/api/tasks';
async list(): Promise<readonly Task[]> {
const dtos = await this.call(this.http.get<TaskDto[]>(this.url));
return dtos.map(taskFromDto); // DTO → Domäne, hier und nirgends sonst
}
async save(task: Task): Promise<void> {
await this.call(this.http.put<TaskDto>(`${this.url}/${task.id}`, taskToDto(task)));
}
async remove(id: TaskId): Promise<void> {
await this.call(this.http.delete<void>(`${this.url}/${id}`));
}
// HttpErrorResponse verlässt den Adapter nicht: Der Kern erhält den Fehler des Ports
private async call<T>(request: Observable<T>): Promise<T> {
try {
return await firstValueFrom(request);
} catch (e) {
if (e instanceof HttpErrorResponse) throw new RepositoryUnavailableError(`HTTP ${e.status}`);
throw e;
}
}
}{ provide: TaskRepository, useClass: InMemoryTaskRepository } — Eine Map im Speicher — die Daten verschwinden, sobald der Injector zerstört wird.
TasksPanel → TaskBoard → TaskRepository- Keine Aufgaben.
- 11:30.3DI TaskRepository → InMemoryTaskRepository
- 11:30.4InMemoryTaskRepository list() → 0 (Map)
Composition Root: wo du den Adapter wählst
Die Bindung von Port an Adapter sitzt an einer einzigen Stelle. Global in app.config.ts. Lokal in den providers einer Route, wo der Adapter so lange lebt wie die Route (siehe Providers & Injector-Hierarchie). Damit die Details des Features privat bleiben, stellt es eine Funktion provideTasks() bereit. Die Composition Root wählt nur „http“ oder „local“.
// app.config.ts — COMPOSITION ROOT: die einzige Datei, die weiß, welchen Adapter wir nutzen
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: TaskRepository, useClass: HttpTaskRepository },
],
};Testing: hier zeigt sich der Gewinn
Du hast drei Testebenen, jede davon günstig:
- die Domäne, mit reinen Funktionen;
- die Use Cases, mit dem In-Memory-Adapter anstelle von HTTP;
- die Adapter, mit einem Contract Test: dieselbe Suite, ausgeführt auf jeder Implementierung des Ports.
Der Contract Test fängt genau die Fehler ab, die sonst durchrutschen würden: einen Adapter, der DTOs statt Entitäten zurückgibt, oder einen, der dupliziert, statt zu aktualisieren.
// Use-Case-Test: der In-Memory-Adapter statt HTTP
import { TestBed } from '@angular/core/testing';
describe('AddTask', () => {
let addTask: AddTask;
let repo: InMemoryTaskRepository;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{ provide: TaskRepository, useClass: InMemoryTaskRepository }],
});
addTask = TestBed.inject(AddTask);
repo = TestBed.inject(TaskRepository) as InMemoryTaskRepository;
});
it('speichert über den Port', async () => {
await addTask.execute('Tests schreiben');
expect((await repo.list()).map((t) => t.title)).toEqual(['Tests schreiben']);
});
it('speichert nichts, wenn eine Domänenregel verletzt wird', async () => {
await expect(addTask.execute('ab')).rejects.toThrow(DomainError);
expect(await repo.list()).toEqual([]);
});
});| Test (für alle gleich) | In-Memory | localStorage | HTTP | HTTP ohne Mapping |
|---|---|---|---|---|
| ein neues Repository ist leer | · | · | · | · |
| save() und danach list() liefert die Aufgabe | · | · | · | · |
| save() mit derselben id aktualisiert, dupliziert nicht | · | · | · | · |
| remove() löscht nur die angeforderte Aufgabe | · | · | · | · |
| done ist ein Boolean, createdAt ein Date | · | · | · | · |
| liefert Domänenentitäten, keine DTOs | · | · | · | · |
Wie du die Regel im Team durchsetzt
Eine Architekturregel, die nur in deinem Kopf existiert, überlebt die erste Deadline nicht. Packe sie ins Linting. Mit dem no-restricted-imports von ESLint, aufgeteilt nach Schichten, scheitert jeder falsche Import in der CI. In einem Nx-Monorepo erreichst du dasselbe mit Tags und @nx/enforce-module-boundaries (siehe Monorepo, Libraries & Grenzen und Barrels, Aliase & Grenzen).
// Die Dependency Rule, bei jedem Commit geprüft (ESLint no-restricted-imports mit Regex)
export default [
{
files: ['src/app/features/*/domain/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '^(@angular/|rxjs)', message: 'Die Domäne ist reines TypeScript: kein Angular, kein RxJS.' },
{ regex: '/(application|infrastructure|ui)/', message: 'Die Domäne hängt nicht von den äußeren Schichten ab.' },
] }],
},
},
{
files: ['src/app/features/*/application/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/(infrastructure|ui)/', message: 'Application hängt von Ports ab, nicht von Adaptern oder der UI.' },
{ regex: '^@angular/common/http', message: 'HTTP nur in infrastructure.' },
] }],
},
},
{
files: ['src/app/features/*/ui/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/infrastructure/', message: 'Die UI injiziert den Port oder den Use Case, nicht den Adapter.' },
] }],
},
},
];
// In einem Nx-Monorepo: domain/application/infrastructure als separate Libraries, mit Tags
// und @nx/enforce-module-boundaries (siehe die Seite Monorepo).Wann ja, wann nein
- Echte Geschäftsregeln (Preise, Validierungen, Abläufe), die getestet und wiederverwendet werden müssen.
- Ein instabiles oder sich änderndes Backend: eine neue API, eine Migration von REST auf GraphQL, ein Offline-Modus.
- Dieselbe Logik über mehrere Datenquellen (Server, lokaler Cache, ein Mock für Demos).
- Große, langlebige Anwendungen, in denen mehrere Teams am selben Feature arbeiten.
- CRUD-Screens, die nur anzeigen, was der Server liefert. Ein Service mit
httpResourcereicht. - Prototypen und kleine Apps: Vier Schichten für zwei Formulare bremsen das Team aus.
- Wenn der „Port“ nur eine einzige Implementierung hat und du ihn nicht separat testest. Die Abstraktion bringt dann nichts.
- Reine UI-Logik (Hover, Aufklappen): Sie bleibt in der Komponente.
Eine pragmatische Variante funktioniert in den meisten Projekten: eine reine Domäne für die Regeln, die zählen, einen Port nur dort, wo du mehrere Implementierungen hast oder haben wirst, und den Rest in der üblichen data-access- / ui-Struktur (siehe Ordnerstruktur).
Häufige Stolperfallen
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) auf der abstrakten Klasse wirkt bequem, aber der Port hängt dann von der Infrastruktur ab, und HttpClient landet im Bundle, auch dort, wo du ihn nicht nutzt. Die Wahl des Adapters gehört in die Composition Root.// ✗ „Standardimplementierung“ am Port
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) // der Port importiert den Adapter!
export abstract class TaskRepository { /* ... */ }
// Application → infrastructure: Die Dependency Rule ist verletzt, und HttpClient
// landet im Bundle, selbst für die App, die nur localStorage nutzt.
// ✓ Der Port bleibt leer; die Wahl liegt in der Composition Root
export abstract class TaskRepository { /* ... */ }
providers: [{ provide: TaskRepository, useClass: HttpTaskRepository }]- DTOs, die in die Domäne durchsickern. Wenn der Typ des Servers bis in die Komponenten gelangt, breitet sich jede API-Änderung überall aus. Das Mapping gehört in den Adapter.
- Komponenten, die den konkreten Adapter injizieren (
inject(HttpTaskRepository)). Dann hast du gar keinen Port. Injiziere den Port oder den Use Case. - Eine „anämische“ Domäne. Entitäten ohne Regeln, mit der gesamten Logik in Services: Du hast die Schichten, aber nicht den Nutzen.
- Technische Fehler, die bis zur UI durchdringen. Eine
HttpErrorResponsein einer Komponente bedeutet, dass die UI weiß, dass es HTTP ist. Übersetze die Fehler im Adapter. - Eine Uhr und IDs, die innerhalb der Domäne erzeugt werden (
new Date(),crypto.randomUUID()). Die Regeln werden schwer testbar. Reiche sie als Parameter herein.
Spickzettel
SCHICHTEN (Imports zeigen nur nach innen)
domain Task, createTask() reines TypeScript — kein Angular, RxJS, Browser
application TaskRepository (Port) abstrakte Klasse = Typ + DI-Token
AddTask / TaskBoard inject(TaskRepository), Signals für die UI
infrastructure HttpTaskRepository extends TaskRepository, DTO ↔ Domäne, Fehler übersetzt
presentation TasksPage, TaskItem injiziert application, niemals infrastructure
composition app.config / route.providers { provide: TaskRepository, useClass: Http… }
REZEPTE
Adapter tauschen ein anderes useClass in der Composition Root (oder provideTasks('local'))
Use-Case-Test providers: [{ provide: TaskRepository, useClass: InMemoryTaskRepository }]
Adapter-Test gemeinsamer Contract + HttpTestingController für das HTTP-Mapping
DTO nur in infrastructure; der Port spricht in Entitäten
Fehler der Adapter wirft RepositoryUnavailableError, nicht HttpErrorResponse
WANN NICHT
CRUD ohne Regeln, kleine App, Prototyp → httpResource in der Komponente + Mapping in parse