Strukturiere deine API in Schichten mit einer einzigen Verantwortung und verbinde sie über leichtgewichtige Injektion, damit sie testbar wird.
Diese Lektion in Kodokon öffnenEine 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.
import { Router } from 'express';
export function createUserRouter(controller) {
const router = Router();
router.get('/', controller.list);
router.post('/', controller.create);
return router;
}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.
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);
}
},
};
}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.
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);
},
};
}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.
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;
}createApp nicht selbst damit, im Netzwerk zu lauschen?