Startseite / Artikel

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.

Verlag: REACTAPP.TOP10 Min. Lesezeit2199 Wörter

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
  • Benachrichtigungen
  • Ausführung von Hintergrundaufgaben
  • Caching basierend auf Redis
  • 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:

    1. Die Analyse der eingehenden Anfrage
    2. Die Überprüfung der Eingaben
    3. Die Überprüfung der Authentifizierung
    4. Die Ausführung der Geschäftslogik
  • Sprache mit der Datenbank
  • Formung der Antwort
  • Auffangen und Behandlung von Fehlern
  • 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
  • Wie Passwörter gehässt werden
  • Ob ein Bestätigungs-E-Mail versandt werden muss
  • Ob Redis irgendwo involviert ist
  • 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
  • Zulässige Werte
  • 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.“