Kodokon kodokon.com

Eine API in Schichten aufbauen: Routen, Controller, Services

Strukturiere deine API in Schichten mit einer einzigen Verantwortung und verbinde sie über leichtgewichtige Injektion, damit sie testbar wird.

10 Min. · 3 Fragen

Diese Lektion in Kodokon öffnen

Eine einzige Datei, die Routing, Geschäftslogik und Datenzugriff vermischt, wird am Ende immer teuer: Du kannst die Geschäftsregeln nicht testen, ohne einen Server hochzufahren, und du kannst die Infrastruktur nicht austauschen, ohne alles neu zu schreiben. Die Aufteilung in drei Schichten löst beide Probleme. Routen deklarieren die URLs und delegieren. Controller übersetzen HTTP in Geschäftsaufrufe: die Anfrage lesen, den Statuscode wählen, die Antwort serialisieren. Services halten die Geschäftsregeln und wissen überhaupt nichts von Express. Die Abhängigkeitsregel ist streng: Jede Schicht kennt nur die darunterliegende, niemals umgekehrt.

JAVASCRIPT
import { Router } from 'express';

export function createUserRouter(controller) {
  const router = Router();
  router.get('/', controller.list);
  router.post('/', controller.create);
  return router;
}
Die Route deklariert, der Controller handelt: hier keine Logik.

Der Controller ist eine Factory, die den Service als Argument bekommt. Er enthält keine Geschäftsregel: Seine Aufgabe beschränkt sich darauf, Daten aus req zu ziehen, den Service aufzurufen und das Ergebnis in eine HTTP-Antwort zu verwandeln. Jeder Fehler geht an next, damit die zentrale Fehler-Middleware ihn behandelt - niemals ein res.status(500), das über die Controller verstreut ist.

JAVASCRIPT
export function createUserController(service) {
  return {
    async list(req, res, next) {
      try {
        const users = await service.listUsers();
        res.json(users);
      } catch (err) {
        next(err);
      }
    },
    async create(req, res, next) {
      try {
        const user = await service.createUser(req.body);
        res.status(201).json(user);
      } catch (err) {
        next(err);
      }
    },
  };
}
Reine HTTP-Übersetzung: Statuscodes, Serialisierung, Delegation.

Der Service bündelt die Entscheidungen: Eindeutigkeit der E-Mail, Regeln für die Erstellung. Er wirft fachliche Fehler - höchstens um ein Feld status angereichert - und weiß dabei nichts von Express. Auch er bekommt seine Abhängigkeit, das Daten-Repository, als Argument. Das ist leichtgewichtige Injektion: schlichte Factory-Funktionen, die einmal beim Start aufgerufen werden, ersetzen schwere Injektions-Container und machen jede Schicht in Tests austauschbar.

JAVASCRIPT
export function createUserService(repository) {
  return {
    listUsers() {
      return repository.findAll();
    },
    createUser(input) {
      const found = repository.findByEmail(input.email);
      if (found) {
        const err = new Error('Email already used');
        err.status = 409;
        throw err;
      }
      return repository.insert(input);
    },
  };
}
Die Geschäftslogik lebt hier, ohne req, res oder SQL.

Bleibt der Composition Root: die einzige Stelle, an der die Factories zusammengesteckt werden. createApp bekommt die Infrastruktur-Abhängigkeiten (hier die Datenbank, Thema der nächsten Lektion) und liefert eine vollständige Express-Anwendung zurück, die noch nicht lauscht. Den Aufbau vom Lauschen zu trennen wirkt banal; genau das erlaubt es aber Tests, die API auf einem flüchtigen Port zu starten, während der Produktionsserver drei Zeilen bleibt: Datenbank erstellen, App erstellen, lauschen.

JAVASCRIPT
import express from 'express';
import { createUserRepository } from './db/users.js';
import { createUserService } from './services/users.js';
import {
  createUserController,
} from './controllers/users.js';
import { createUserRouter } from './routes/users.js';

export function createApp({ db }) {
  const app = express();
  app.use(express.json());
  const repository = createUserRepository(db);
  const service = createUserService(repository);
  const controller = createUserController(service);
  app.use('/api/users', createUserRouter(controller));
  return app;
}
app.js: der einzige Teil der Anwendung, der alle kennt.

Wissenscheck

Stelle sicher, dass du die wichtigsten Punkte dieser Lektion behalten hast.

  1. Wo gehört in dieser Schichtung die Regel "eine E-Mail darf nur einmal verwendet werden" hin?
    • In die Route, so nah wie möglich an der URL
    • In den Controller, der Zugriff auf req.body hat
    • In den Service, der die Geschäftsregeln hält
    • In eine globale Express-Middleware
  2. Was ist der Hauptvorteil davon, Abhängigkeiten als Factory-Argumente zu übergeben, statt sie direkt in jedes Modul zu importieren?
    • Factories beschleunigen das Laden der Module
    • Du kannst eine Abhängigkeit austauschen (Datenbank im Arbeitsspeicher, Fake-Repository), ohne das Modul anzufassen, das sie nutzt
    • Express verlangt diesen Stil für seine Middleware
    • Es erspart das Schreiben separater Dateien
  3. Warum beginnt createApp nicht selbst damit, im Netzwerk zu lauschen?
    • Weil Express verbietet, listen innerhalb einer Funktion aufzurufen
    • Damit Tests die Anwendung instanziieren können, ohne einen festen Port zu öffnen, und der Einstiegspunkt des Servers trivial bleibt
    • Weil listen asynchron ist und die Factory blockieren würde
    • Um den Speicherverbrauch beim Start zu senken