Schichtbasiertes Node.js-API-Design: Vom umfangreichen Controller bis zur sauberen Architektur
Erfahren Sie, wie Sie eine Node.js-API in Controller-, Service- und Datenzugriffsschichten umstrukturieren können, um verwickelte Geschäftslogik, inkonsistente Fehler sowie Skalierungsprobleme zu beheben.
Quellen: I Built a Node.js API. Then I Realized I Was Doing It Wrong.
Praktische Lektionen zur ordnungsgemäßen Strukturierung, Skalierung und Wartung eines Node.js-Backends, basierend auf der Entwicklung von Produktions-APIs.
Wenn man erst anfängt, Node.js-APIs zu schreiben, kann die Arbeit am Backend täuschend einfach erscheinen.
Eine Route definieren.
Diese mit der Datenbank verbinden.
Etwas JSON zurücksenden.
Das ist’s.
Und eine Weile lang funktioniert dieser Ansatz tatsächlich.
Dann beginnt die Anwendung zu wachsen.
S plötzlich steht man vor folgenden Problemen:
- Betriebsregeln, die direkt in den Controllern eingebettet sind
- Datenbankaufrufe, die in jeder Route-Datei vorkommen
- Wiederholte Validierungslogik
- Fehlerbehandlung, die von Endpunkt zu Endpunkt variiert
- Überdimensionierte Dateien
- Code, der sich gegen automatisierte Tests wehrt
- Authentifizierungslogik, die mit der Domänenlogik vermischt ist
- Kleine Änderungen, die Funktionen beeinträchtigen, die ursprünglich unabhängig schienen
Die API versagt technisch gesehen nicht ganz.
Etwas Subtileres stimmt nicht mehr:
Jegliche Änderungen an ihr sind mittlerweile äußerst schwierig.
Dann wird klar, dass es einen echten Unterschied zwischen einer API gibt, die einfach nur funktioniert, und einer, die kontinuierlicheren, realen Entwicklungsanforderungen standhalten kann.
1. Meine anfängliche Node.js-Architektur
Eine typische API in der Anfangsphase könnte so aussehen:
const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users"); res.json(users);
});app.post("/users", async (req, res) => {
const { name, email } = req.body; const user = await db.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
[name, email]
); res.json(user);
});app.listen(3000);
Kein Teil dieses Codes ist an sich falsch.
Für ein schnelles Prototypenprojekt ist es ein völlig vernünftiger Ausgangspunkt.
Probleme entstehen erst, wenn das Projekt an Größe gewinnt.
Bilden Sie sich Funktionsebenen wie folgt vor:
- Authentifizierung
- Berechtigungskontrolle
- Eingabenvalidierung
- E-Mail-Verifizierung
- Zahlungsabwicklung
- Gutscheinverwaltung
- Bestellmanagement
- Lagerverfolgung
Stellen Sie sich nun vor, all diese Aufgaben direkt in Ihre Route-Handler zu packen.
Dann haben Sie keine echte Architektur mehr.
Was Sie stattdessen haben, ist eine Menge an Code, der einfach ausgeführt wird.
2. Das Problem lag nicht bei Node.js
Das ist ein Fehler, den man früh im Projekt leicht machen kann.
Der Instinkt ist es, zu denken:
"Meine Codebasis wird unübersichtlich, nur weil die Anwendung größer wird."
Das ist nur ein Teil der Geschichte.
Die eigentliche Ursache war ein Mangel an getrennten Verantwortlichkeiten.
Ein einziger Controller kümmerte sich um viel zu viele Aufgaben.
Er war für Folgendes verantwortlich:
- Die Analyse der eingehenden Anfrage
- Die Überprüfung der Eingaben
- Die Überprüfung der Authentifizierung
- Die Ausführung der Geschäftslogik
Das sind sechs oder sieben unterschiedliche Aufgaben, die an einem Ort zusammengefasst sind.
Jedes Mal, wenn sich eine Anforderung änderte, musste man dasselbe Datei erneut bearbeiten.
Mehrmals.
3. Die Architektur, die ich jetzt verwende
Eine sauberere Strategie besteht darin, die Anwendung in separate Schichten aufzuteilen.
Zum Beispiel:
src/
│
├── controllers/
│ └── user.controller.ts
│
├── services/
│ └── user.service.ts
│
├── repositories/
│ └── user.repository.ts
│
├── dto/
│ └── create-user.dto.ts
│
├── middleware/
│ └── auth.middleware.ts
│
├── routes/
│ └── user.routes.ts
│
├── utils/
│ └── error-handler.ts
│
└── app.ts
Das zugrundeliegende Prinzip ist einfach:
Jede Schicht sollte eine klare Verantwortung haben.
4. Controller sollten keine Geschäftslogik enthalten
Die Aufgabe eines Controllers sollte auf HTTP-bezogenen Aspekten beschränkt sein.
Etwas in dieser Richtung:
export class UserController {
constructor(
private readonly userService: UserService
) {}
async createUser(req: Request, res: Response) {
try {
const user = await this.userService.createUser(req.body); return res.status(201).json({
success: true,
data: user,
});
} catch (error) {
return res.status(500).json({
success: false,
message: error.message,
});
}
}
}
Der Controller muss nicht wissen:
- Wo oder wie die Benutzerdaten gespeichert sind
- Welche Datenbanktechnologie verwendet wird
Davon ist es nicht verantwortlich.
Seine einzige Aufgabe besteht darin, die eingehende Anfrage zu verarbeiten und die ausgehende Antwort zu gestalten.
5. Unterbringung der Geschäftslogik in Services
In der Service-Schicht befindet sich die eigentliche Anwendungslogik.
export class UserService {
constructor(
private readonly userRepository: UserRepository
) {}
async createUser(data: CreateUserDto) {
const existingUser =
await this.userRepository.findByEmail(data.email); if (existingUser) {
throw new Error("User already exists");
} const hashedPassword = await hashPassword(
data.password
); return this.userRepository.create({
...data,
password: hashedPassword,
});
}
}
Dadurch wird die Logik wiederverwendbar.
Ein anderer Teil des Systems könnte beispielsweise einfach aufrufen:
userService.createUser(data);
ohne überhaupt eine HTTP-Anfrage simulieren zu müssen.
Diese Wiederverwendbarkeit ist sowohl für Tests als auch für die langfristige Wartbarkeit von großer Bedeutung.
6. Der Datenbankzugriff benötigt seine eigene Schicht
Die Geschäftslogik sollte nicht mit rohen SQL-Anweisungen oder überall verstreuten ORM-Aufrufen überladen sein.
Genau dieses Problem lösen Repositorien.
export class UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: {
email,
},
});
} async create(data: CreateUserInput) {
return prisma.user.create({
data,
});
}
}
Durch diese Struktur wird die Trennung der Aufgaben deutlich.
Request
↓
Controller
↓
Service
↓
Repository
↓
Database
Jede Schicht kennt nur das, was sie unbedingt wissen muss, und nichts weiter.
7. Warum DTOs wichtiger sind, als man denkt
Ein häufiger früher Fehler besteht darin, rohe Anfragedaten direkt in die Anwendung einzuspeisen.
Etwas wie dieses:
const user = await userService.createUser(req.body);
Das ist riskant.
Was befindet sich eigentlich in req.body?
Kann man da sicher sein?
Eine DTO schafft einen klaren, definierten Vertrag dafür, was erwartet wird.
class CreateUserDto {
name: string;
email: string;
password: string;
}
Daraufhin werden die eingehenden Daten geprüft, bevor sie in die Anwendungslogik gelangen dürfen.
Zum Beispiel ein Payload wie:
{
"name": "Mit",
"email": "mit@example.com",
"password": "secret123"
}
kann gegen Regeln wie folgt überprüft werden:
- Pflichtfelder
- E-Mail-Format
- Länge des Passworts
- Datentypen
Das grundlegende Prinzip ist hier einfach:
Vertrauen Sie niemals auf Eingaben vom Client.
Die Frontend-Schicht dient nicht als Sicherheitsgrenze.
8. Konsistenz bei der Fehlerbehandlung ist wichtig
Ein damit verbundenes Problem in frühen API-Entwicklungsstufen sind inkonsistente Antwortstrukturen.
Ein bestimmter Endpunkt könnte folgende Antwort senden:
{
"error": "User not found"
}
Ein anderer antwortet mit:
{
"message": "Not found"
}
Wieder ein anderer sieht so aus:
{
"success": false,
"data": null
}
Für alle, die ein Frontend für diese API entwickeln, ist diese Inkonsistenz äußerst frustrierend.
Durch die Anwendung eines zentralisierten Ansatzes zur Fehlerbehandlung wird die API erheblich benutzerfreundlicher.
Zum Beispiel:
{
"success": false,
"message": "User not found",
"code": "USER_NOT_FOUND"
}
Die genaue Struktur, die gewählt wird, spielt keine große Rolle.
Wichtig ist, dass sie immer gleich ist.
9. Authentifizierung ist nicht Autorisierung
Das ist ein Unterschied, der viele Entwickler frühzeitig in Verwirrung bringt.
Die Authentifizierung beantwortet eine Frage:
Wer sind Sie?
Die Autorisierung beantwortet eine andere Frage:
Was dürfen Sie tun?
Im Überblick sieht der Ablauf ungefähr so aus:
Login
↓
Authentication
↓
JWT / Session
↓
User identified
↓
Authorization
↓
Can this user delete the product?
Nur weil ein Benutzer authentifiziert ist, bedeutet das noch nicht, dass er berechtigt ist, jede beliebige Aktion auszuführen.
Deshalb müssen Überprüfungen von Rollen und Berechtigungen getrennt von der grundlegenden Anmeldungs-/Authentifizierungslogik erfolgen.
10. Irgendwann muss man an Skalierbarkeit denken
Sobald der Code recht gut strukturiert ist, taucht eine neue Frage auf:
Was passiert, wenn der Datenverkehr zunimmt?
Genau an diesem Punkt werden Lösungen wie Redis, Job-Queues und Datenbankindizes wichtig.
Stellen Sie sich einen Endpunkt vor, der bei jeder Anfrage eine teure Datenbankabfrage ausführt.
Anstatt:
Request
↓
Database
↓
Response
könnte das System schließlich auf etwas wie folgendes umsteigen:
Request
↓
Redis Cache
↓
Cache Hit → Response
│
└── Cache Miss
↓
Database
↓
Redis
↓
Response
Und bei allem, was die Antwort nicht blockieren sollte:
API
↓
Queue
↓
Worker
↓
Email / Notification / Processing
So wird verhindert, dass langsame, nicht kritische Aufgaben die eigentliche Anfrage verzögern.
Aber hier ist die Warnung, die es zu wiederholen lohnt:
Nehmen Sie nicht einfach aus Gründen der Eindrucksvollheit Redis, Warteschlangen, Kafka, Microservices oder Kubernetes in Betracht.
Architekturen dienen dazu, echte Probleme zu lösen.
Sie sollten keine neuen erzeugen.
11. Die wichtigste Lektion hier
Der eigentliche Denkwechsel lässt sich wie folgt zusammenfassen:
Hören Sie auf, sich darauf zu konzentrieren, wie schnell Code geschrieben werden kann.
Konzentrieren Sie sich stattdessen darauf, wie sicher er später geändert werden kann.
Beim Starten eines ersten Projekts scheint es effizient zu sein, alles in eine einzige Datei zu packen.
Zu Beginn ist das tatsächlich der Fall.
Doch die Erstellung der ersten Version macht nur einen kleinen Teil der gesamten Arbeit aus.
Der größte Teil der Arbeiten an einer Codebasis erfolgt danach.
Mit der Zeit muss ein Projekt folgendes tun:
- Funktionen hinzufügen
- Bugs beheben
- Sich an sich ändernde Anforderungen anpassen
- Bibliotheken austauschen
- Datenbank-Schemata aktualisieren
- Authentifizierungssysteme einbauen
- Erlaubnissysteme einführen
- Schleppende Abfragen beschleunigen
- Sich mit Drittanbieterdiensten verbinden
Falls die Architektur jede dieser Aufgaben zu einer Herausforderung macht, war die anfängliche Geschwindigkeitssteigerung eigentlich kein Vorteil.
12. Ein besseres Ausgangspunkt
Ein neuer Node.js-Backend kann heutzutage vernünftigerweise etwa so strukturiert werden:
src/
│
├── modules/
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.dto.ts
│ │ └── user.routes.ts
│ │
│ ├── products/
│ │ ├── product.controller.ts
│ │ ├── product.service.ts
│ │ ├── product.repository.ts
│ │ └── product.dto.ts
│
├── middleware/
├── config/
├── database/
├── utils/
└── app.ts
In größeren Anwendungen funktioniert es in der Regel besser, den Code nach Modulen oder Funktionen zu organisieren, anstatt alles in riesige gemeinsame Ordner mit unzusammenhängenden Controllern und Services zu packen.
Die genaue Ordnerstruktur ist nicht das Wichtigste.
Wesentlich ist, dass die Verantwortung jedes Bestandteils klar bleibt.
13. Sieben Regeln, denen man folgen sollte
Kurz gesagt, lässt sich das auf sieben Regeln reduzieren:
1. Halten Sie Controller schlank
Controller dienen dazu, HTTP-Anfragen zu verarbeiten – nichts weiter.
2. Legen Sie die Geschäftslogik in Services
Dort sollte das tatsächliche Verhalten einer Anwendung liegen.
3. Isolieren Sie Datenbankoperationen
Vermeiden Sie es, dass Datenbankabfragen in alle Bereiche der Codebasis eindringen.
4. Überprüfen Sie externe Eingaben
Gehen Sie nicht davon aus, dass req.body das enthält, was erwartet wird.
5. Standardisieren Sie Fehler
Sorgen Sie dafür, dass Fehlerreaktionen vorhersehbar und einheitlich sind.
6. Fügen Sie Infrastruktur nur dann hinzu, wenn sie benötigt wird
Redis, Warteschlangen und Microservices sind Werkzeuge, die nur dann eingesetzt werden sollten, wenn es gerechtfertigt ist – nicht als Zeichen für höhere Kompetenz.
7. Entwerfen Sie für Veränderungen
Jeder, der diesen Code wartet, wird ihn weitaus häufiger ändern müssen, als dass er ihn von Grund auf neu schreiben muss.
Fazit
Eine Node.js-API zum Laufen zu bringen, ist nicht schwierig.
Sie monatelang verständlich zu halten, während weiterhin neue Funktionen entwickelt werden, ist eine weitaus größere Herausforderung.
Es ist einfach, gute Backend-Entwicklung mit dem Wissen gleichzusetzen, wie man bestimmte Komponenten verbindet:
GET
POST
PUT
DELETE
Das ist eigentlich nur der Ausgangspunkt.
Die eigentliche Fähigkeit liegt darin zu wissen, wo jeder logische Bestandteil platzfinden sollte, wie die verschiedenen Systemteile miteinander kommunizieren und wie man das System weiterentwickeln kann, ohne dabei alles andere zu stören.
Nicht jedes Projekt benötigt eine ausgefeilte Architektur.
Was es braucht, ist eine Architektur, die der Komplexität des tatsächlichen Problems entspricht.
Denn das Ziel war niemals:
„Es zum Laufen bringen.“
Das eigentliche Ziel ist:
„Es so einfach wie möglich zu anpassen, falls sich die Anforderungen unweigerlich ändern.“