Zum Inhalt springen

10. Mehrschichtarchitektur

Zu Zen-Modus wechseln

Die Route Handler des letzten Kapitels tun alles zugleich: HTTP lesen, validieren, Geschäftsregeln anwenden, Daten speichern, HTTP antworten. Bei drei Endpunkten geht das gut; bei dreißig wird jede Änderung zum Suchspiel, Regeln stehen doppelt, und nichts davon lässt sich ohne laufenden Server testen. Das Gegenmittel ist so alt wie der Bau großer Software und steht wörtlich im Lehrplan: die Mehrschichtarchitektur. Die Idee: Code wird nach Verantwortung in Schichten sortiert, mit klaren Regeln, wer wen kennen darf. Änderungen betreffen dann eine Schicht statt das ganze System, und genau das macht sie billig.

flowchart TB
    p["Präsentation<br>(Seiten, Komponenten, Route Handler)"]
    l["Geschäftslogik<br>(Services: Regeln und Abläufe)"]
    d["Datenzugriff<br>(Repositories: lesen und schreiben)"]
    p --> l --> d
  • Präsentation: alles, was mit der Außenwelt spricht. Auf der einen Seite die React-Komponenten und Seiten (für Menschen), auf der anderen die Route Handler (für Programme). Beide sind Adapter: Sie übersetzen zwischen Außenformat (HTML, HTTP, JSON) und den Aufrufen der Logikschicht, und sonst nichts.
  • Geschäftslogik (Services): die fachlichen Regeln und Abläufe. “Ein Job braucht einen nicht leeren Dateinamen”, “ein laufender Job kann nicht gelöscht werden”, “beim Anlegen wird der Zustand queued vergeben”: Solche Sätze stehen genau hier, und zwar genau einmal, egal wie viele Oberflächen und Endpunkte sie auslösen.
  • Datenzugriff (Repositories): das Lesen und Schreiben der Daten, hinter einer fachlichen Schnittstelle (findAll, findById, insert, …). Wie gespeichert wird (Speicher, Datei, Datenbank), weiß nur diese Schicht.

Dazu die eine Regel, die alles trägt: Abhängigkeiten zeigen nur nach unten. Die Präsentation kennt die Services, die Services kennen die Repositories; niemals umgekehrt. Ein Service, der HTML formatiert oder HTTP-Statuscodes kennt, verletzt die Regel ebenso wie ein Repository, das Geschäftsregeln prüft.

Schichten sind Konvention, kein Framework-Feature; im Projekt werden sie zur Ordnerstruktur plus Team-Regel:

render-farm/
├── app/ # PRESENTATION: pages and route handlers
│ ├── jobs/page.tsx
│ └── api/jobs/route.ts
└── lib/
├── types.ts # shared contracts (interfaces)
├── services/
│ └── job-service.ts # BUSINESS LOGIC
└── repository/
├── job-repository.ts # the interface (the contract of the layer)
└── memory-job-repository.ts

Die Importregeln als Tabelle, im Team-Wiki festgehalten und im Review geprüft:

Schichtdarf importierendarf nie importieren
app/ (Präsentation)Services, TypenRepositories direkt
lib/services/Repositories (als Interface), Typenirgendetwas aus app/, next/*, React
lib/repository/Typen, SpeichertechnikServices, app/

Der Kern in Code. Zuerst der Vertrag der Datenschicht und der Service:

lib/repository/job-repository.ts
import type { Job } from "@/lib/types";
export interface JobRepository {
findAll(): Promise<Job[]>;
findById(id: number): Promise<Job | null>;
insert(job: Omit<Job, "id">): Promise<Job>;
update(id: number, changes: Partial<Omit<Job, "id">>): Promise<Job | null>;
delete(id: number): Promise<boolean>;
}
lib/services/job-service.ts
import type { JobRepository } from "@/lib/repository/job-repository";
import type { Job, NewJob } from "@/lib/types";
export class ValidationError extends Error {}
export class ConflictError extends Error {}
export class JobService {
constructor(private repository: JobRepository) {}
async createJob(input: NewJob): Promise<Job> {
if (!input.filename || input.filename.trim() === "") {
throw new ValidationError("filename must not be empty");
}
return this.repository.insert({
filename: input.filename.trim(),
preset: input.preset ?? "web-1080p",
state: "queued",
progress: 0,
createdAt: new Date().toISOString(),
});
}
async deleteJob(id: number): Promise<void> {
const job = await this.repository.findById(id);
if (!job) throw new ValidationError(`job ${id} does not exist`);
if (job.state === "running") {
throw new ConflictError("a running job cannot be deleted");
}
await this.repository.delete(id);
}
}

Der Service erhält sein Repository über den Konstruktor (das Muster heißt Dependency Injection): Er hängt nur am Interface, nicht an einer konkreten Speichertechnik. Fehler meldet er als fachliche Exceptions; von HTTP weiß er nichts. Der Route Handler schrumpft damit zum Übersetzer:

// app/api/jobs/route.ts: presentation = translation only
import { jobService } from "@/lib/services/instances";
import { ValidationError } from "@/lib/services/job-service";
export async function POST(request: Request) {
try {
const job = await jobService.createJob(await request.json());
return NextResponse.json(job, { status: 201 });
} catch (error) {
if (error instanceof ValidationError) {
return NextResponse.json(
{ error: { code: "VALIDATION_FAILED", message: error.message } },
{ status: 400 });
}
throw error; // real server errors stay 500
}
}

Jede Schicht ist jetzt für sich lesbar: Der Handler übersetzt Exceptions in Statuscodes, der Service enthält die Regeln, das Repository die Speicherei. Und die Seite (app/jobs/page.tssx als Server-Komponente) ruft denselben Service auf wie die API; die Regel “Dateiname nicht leer” existiert genau einmal.

Das Versprechen der Architektur ist überprüfbar: Die Datenschicht lässt sich austauschen, ohne dass die anderen Schichten es bemerken. Neben MemoryJobRepository tritt eine Datei-Implementierung:

lib/repository/file-job-repository.ts
export class FileJobRepository implements JobRepository {
constructor(private path: string) {}
async findAll(): Promise<Job[]> {
try {
return JSON.parse(await readFile(this.path, "utf-8"));
} catch {
return []; // no file yet: empty list
}
}
// insert, findById, update, delete: read, modify, write back
}

Der Wechsel ist eine Zeile an der Stelle, die die Instanzen verdrahtet:

// lib/services/instances.ts: the single wiring point
const repository = new FileJobRepository("data/jobs.json");
// const repository = new MemoryJobRepository();
export const jobService = new JobService(repository);

Service, Route Handler, Seiten und Komponenten bleiben unberührt; implements JobRepository lässt TypeScript prüfen, dass die neue Implementierung den Vertrag vollständig erfüllt. Im nächsten Kapitel kommt als dritte Implementierung SQLite dazu, mit demselben Ein-Zeilen-Wechsel; das ist der praktische Beweis, den auch der Lehrplan mit “Austauschbarkeit” meint. Der zweite Gewinn zeigt sich beim Testen: new JobService(new MemoryJobRepository()) läuft in jedem Test ohne Server, ohne HTTP, ohne Dateien; die Geschäftsregeln sind pur prüfbar.

Schichten haben einen Preis, und er soll benannt werden: mehr Dateien, mehr Interfaces, mehr Indirektion. Für ein Wochenend-Skript mit einem Endpunkt ist eine Service-Klasse mit injiziertem Repository Overhead; dort ist der “alles im Handler”-Stil ehrlich angemessen. Die Abwägung kippt mit drei Faktoren: Größe (viele Endpunkte teilen Regeln), Team (mehrere Personen brauchen klare Zuständigkeiten), Lebensdauer (was Jahre lebt, wird sicher umgebaut). Das Abschlussprojekt dieses Jahres erfüllt alle drei; dort sind die Schichten verbindlich. Die Kompetenz ist beides: Schichten bauen können und begründen können, wann sie sich lohnen.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: die drei Schichten, ihre Verantwortungen und die Abhängigkeitsregel erklären.
  • Anwenden: ein Next.js-Projekt in Präsentation, Services und Repositories strukturieren, mit Interfaces als Schichtverträgen und Dependency Injection.
  • Anwenden: Route Handler und Seiten als dünne Adapter schreiben, die fachliche Exceptions in Statuscodes und Anzeigen übersetzen.
  • Analysieren: Schichtverletzungen in fremdem Code über die Importe aufspüren und begründen.
  • Anwenden: die Austauschbarkeit der Datenschicht praktisch nachweisen (Implementierungswechsel an einer Stelle).
  • Beurteilen: Nutzen und Kosten der Schichtung abwägen und die Entscheidung für ein konkretes Projekt begründen.
  • Aufgabe 17 - Schichten-Detektiv
  • Aufgabe 18 - Schichtwechsel