REST API mit Express in Node.js schreiben
1. Überblick
Dieser Artikel zeigt dir, wie du eine REST-API zur Verwaltung von Order mit Express aufbaust — vom Projektsetup, der Definition von Routes, Middleware, Datenvalidierung bis zur zentralisierten Fehlerbehandlung. Nach dem Lesen kannst du selbst eine vollständige CRUD-API erstellen, sie mit Jest und Supertest testen, und die häufigsten Anfängerfehler mit Express vermeiden.
Laufendes Beispiel: Eine API zur Verwaltung von Bestellungen (Order) für einen Customer mit Funktionen zum Erstellen, Abrufen, Aktualisieren und Löschen.
2. Projektsetup
Erstelle ein neues Node.js-Projekt und installiere Express:
mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest
Füge type: module hinzu, um die import/export-Syntax zu verwenden, und definiere das Test-Skript in package.json:
{
"name": "order-api",
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest"
}
}
Hinweis: Jest mit ES-Modulen benötigt das Flag
--experimental-vm-modules. Wenn du dir das ersparen möchtest, kannst du"type": "module"auslassen undrequire/module.exports(CommonJS) verwenden — beide Ansätze sind gültig und je nach Team-Vorliebe.
3. Grundlegender Express Server
// src/app.js
import express from 'express';
const app = express();
app.use(express.json()); // parst JSON-Body zu req.body
app.get('/health', (req, res) => {
res.json({ status: 'UP' });
});
export default app;
// src/server.js
import app from './app.js';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Order API läuft auf Port ${PORT}`);
});
express.json()ist vordefinierte Middleware, die Express hilft,Content-Type: application/jsonautomatisch in einreq.body-Objekt zu parsen. Ohne diese Zeile istreq.bodyundefined.- Das Trennen von
app.jsundserver.jsermöglicht es Tests, ohne einen echten Port zu öffnen — dies ist eine wichtige Praxis für REST-APIs in Node.js.
Teste es:
node src/server.js
curl http://localhost:3000/health
{"status":"UP"}
4. Order-Ressource definieren
Wir verwenden ein in-Memory-“Database” mit einem Array, um uns auf die API zu konzentrieren, unabhängig von einer echten Datenbank.
// src/orders/orderRepository.js
let orders = [];
let nextId = 1;
export function findAll() {
return orders;
}
export function findById(id) {
return orders.find((o) => o.id === id);
}
export function create(data) {
const order = { id: nextId++, ...data, status: 'PENDING' };
orders.push(order);
return order;
}
export function update(id, data) {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return null;
orders[index] = { ...orders[index], ...data };
return orders[index];
}
export function remove(id) {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return false;
orders.splice(index, 1);
return true;
}
Dieses Repository mimitiert die CRUD-Operationen, die du in jedem ORM sehen wirst (Sequelize, Prisma…), speichert aber im RAM anstelle einer Datenbank.
5. Routing mit Express Router
Anstatt alle Routes direkt in app zu definieren, verwenden wir express.Router(), um nach Ressourcen zu modulieren:
// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';
const router = Router();
router.get('/', (req, res) => {
res.json(repo.findAll());
});
router.get('/:id', (req, res, next) => {
const order = repo.findById(Number(req.params.id));
if (!order) return next({ status: 404, message: 'Order existiert nicht' });
res.json(order);
});
router.post('/', (req, res) => {
const order = repo.create(req.body);
res.status(201).json(order);
});
router.put('/:id', (req, res, next) => {
const order = repo.update(Number(req.params.id), req.body);
if (!order) return next({ status: 404, message: 'Order existiert nicht' });
res.json(order);
});
router.delete('/:id', (req, res, next) => {
const deleted = repo.remove(Number(req.params.id));
if (!deleted) return next({ status: 404, message: 'Order existiert nicht' });
res.status(204).end();
});
export default router;
Binde den Router an die App mit dem Präfix /orders:
// src/app.js (aktualisiert)
import orderRouter from './orders/orderRouter.js';
app.use('/orders', orderRouter);
router.get('/:id', ...)—:idist ein Route-Parameter, abrufbar überreq.params.id(ist immer ein String, benötigtNumber()zum Vergleichen).- Aufruf von
next({ status: 404, message: ... })übergibt den Fehler an die Error-Handling-Middleware (Abschnitt 7), anstattres.status(404)...in jeder Route zu wiederholen. 204 No Contentfür erfolgreiche DELETE — ohne Body.
6. Datenvalidierung
Manuelle Validierung mit if/else ist fehleranfällig. Verwenden Sie express-validator für Klarheit und Prägnanz:
npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';
export const validateCreateOrder = [
body('customerName').isString().notEmpty().withMessage('customerName ist erforderlich'),
body('amount').isFloat({ gt: 0 }).withMessage('amount muss größer als 0 sein'),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
];
Binde den Validator an die POST-Route:
// src/orders/orderRouter.js (aktualisiert)
import { validateCreateOrder } from './orderValidator.js';
router.post('/', validateCreateOrder, (req, res) => {
const order = repo.create(req.body);
res.status(201).json(order);
});
- Jeder
body(...)ist eine Regel, die ein Feld überprüft; bei Fehler werden Fehler invalidationResult(req)gesammelt. - Die letzte Middleware im Array überprüft
errors.isEmpty()— bei Fehlern wird400mit Details zurückgegeben, ruftnext()nicht auf, um die Request zu blockieren.
Request ohne amount senden:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"customerName": "Nam"}'
{"errors":[{"type":"field","msg":"amount muss größer als 0 sein","path":"amount",...}]}
7. Zentralisierte Fehlerbehandlung
Express erlaubt die Definition von Error-Handling-Middleware — eine Middleware mit 4 Parametern (err, req, res, next), platziert nach allen Routes:
// src/errorHandler.js
export function errorHandler(err, req, res, next) {
const status = err.status || 500;
const message = err.message || 'Systemfehler';
if (status === 500) {
console.error(err); // logge den echten Fehler zum Debuggen, gib keine Stack Trace zum Client zurück
}
res.status(status).json({ message });
}
// src/app.js (aktualisiert, am Ende der Datei hinzufügen)
import { errorHandler } from './errorHandler.js';
app.use(errorHandler);
- Express erkennt diese Middleware als Error Handler anhand der Anzahl der Parameter (4), nicht am Namen der Funktion.
- Muss
app.use(errorHandler)zuletzt setzen, nach allenapp.use(router)— Express verarbeitet Middleware in der Reihenfolge der Deklaration. - Alle
next(err)oder Fehler, die in der Route geworfen werden (wennexpress-async-errorsoder try/catch verwendet wird), werden hier behandelt.
Häufiger Fehler: Vergessen,
next(err)in async-Code aufzurufen (z.B.await db.query(...)wirft einen Fehler), sodass Express den Fehler nicht abfängt, was zu Server-Absturz oder Request-Hang führt. Mit Express 5 (neue Versionen) werden von async-Handlern geworfene Fehler automatisch abgefangen — bei Express 4 müssen Sie try/catch verwenden undnext(err)manuell aufrufen.
8. Service-Layer hinzufügen
Wenn die Logik komplexer wird (z.B.: keine Bestellung erstellen, wenn amount das Kundenlimit überschreitet), alles nicht in den Router packen. Trennen Sie es in orderService:
// src/orders/orderService.js
import * as repo from './orderRepository.js';
const MAX_AMOUNT = 100_000_000;
export function createOrder(data) {
if (data.amount > MAX_AMOUNT) {
const err = new Error('Limit überschritten');
err.status = 422;
throw err;
}
return repo.create(data);
}
// src/orders/orderRouter.js (aktualisiert, POST-Route)
import * as orderService from './orderService.js';
router.post('/', validateCreateOrder, (req, res, next) => {
try {
const order = orderService.createOrder(req.body);
res.status(201).json(order);
} catch (err) {
next(err);
}
});
Der Router kümmert sich jetzt nur um HTTP (Status-Code, Parameter-Parsing), während orderService sich um Geschäftslogik kümmert. Dies ist ein Pattern ähnlich der Controller–Service-Schicht in anderen Frameworks, ermöglicht es, Geschäftslogik unabhängig von HTTP zu testen.
9. Tests mit Jest und Supertest schreiben
Supertest ermöglicht es, die API direkt auf dem app-Objekt (Express-Instanz) aufzurufen, ohne einen echten Port zu öffnen — deshalb trennen wir in Abschnitt 3 app.js von server.js.
// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';
describe('Order API', () => {
it('order erfolgreich erstellen, 201 und id zurückgeben', async () => {
const response = await request(app)
.post('/orders')
.send({ customerName: 'Nam', amount: 500000 });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
customerName: 'Nam',
amount: 500000,
status: 'PENDING',
});
expect(response.body.id).toBeDefined();
});
it('fehlendes amount gibt 400 zurück', async () => {
const response = await request(app)
.post('/orders')
.send({ customerName: 'Nam' });
expect(response.status).toBe(400);
});
it('nicht vorhandene order gibt 404 zurück', async () => {
const response = await request(app).get('/orders/9999');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Order existiert nicht');
});
});
Tests ausführen:
npm test
PASS tests/order.test.js
Order API
✓ order erfolgreich erstellen, 201 und id zurückgeben
✓ fehlendes amount gibt 400 zurück
✓ nicht vorhandene order gibt 404 zurück
request(app)erstellt einen virtuellen HTTP-Client direkt an die Express-App gebunden — schneller als Tests über echtes Netzwerk.toMatchObjectüberprüft nur die angegebenen Felder, ignoriert andere Felder (wie selbstgenerierteid) — praktisch für Tests mit dynamischen Werten in der Response.
Hinweis: Da
ordersein gemeinsames in-Memory-Array zwischen Tests ist, können Tests sich in ihrer Reihenfolge gegenseitig beeinflussen (z.B. die in Test 1 erstellte Order bleibt bestehen, wenn Test 2 läuft). Mit einer echten API, die eine Datenbank verbindet, sollten Sie die Daten inbeforeEachzurücksetzen oder Transaction-Rollback verwenden.
10. Wann sollte man Express nicht verwenden
- Große APIs mit komplexen Domains: Express hat keine obligatorischen Konventionen für die Struktur, neigt zu “Spaghetti-Code”, wenn das Team keine eigenen Konventionen setzt. Erwägen Sie NestJS (mit DI, Modulen, Dekoratoren wie Spring), wenn das Team mit klarer geschichteter Architektur vertraut ist.
- Benötigt starke Typsicherheit: Express + reines JavaScript fängt Typfehler zur Compile-Zeit nicht ab. Bei großen Projekten sollten Sie TypeScript von Anfang an verwenden, warten Sie nicht bis “später konvertieren” — die Konversionskosten später sind viel höher.
- High-Throughput, benötigt komplexes Streaming/WebSocket: Express kann zwar immer noch laufen, aber Fastify oder leistungsoptimierte Frameworks (JSON-Serialisierung ist schneller im Benchmark) könnten für Systeme mit extrem hohem Traffic besser geeignet sein.
Für kleine bis mittlere APIs mit klarer CRUD wie in dem obigen Beispiel ist Express immer noch eine schnelle und elegante Wahl, mit großer Community und leicht vorgefertigter Middleware zu finden (Auth, Rate-Limiting, CORS…).
11. Fazit
Mit diesem Artikel hast du eine vollständige REST-API für Order mit Express erstellt: Routing mit Router, Validierung der Eingabe mit express-validator, zentralisierte Fehlerbehandlung mit 4-Parameter-Middleware, und Trennung der Geschäftslogik in einen Service-Layer, um leicht zu testen. Der Test-Teil mit Jest + Supertest gibt dir Vertrauen zu refaktorisieren, ohne Angst, das API-Verhalten zu beschädigen. Von dieser Grundlage aus kannst du echte Datenbankverbindungen hinzufügen (Prisma, Mongoose), Authentifizierung (JWT), oder zu TypeScript wechseln, wenn das Projekt größer wird.