Strukturierung von Node.js-Diensten mit domaingetriebenen Modulen und sauberen Schichten
Erfahren Sie, wie man eine Node.js-Codebasis in domänenspezifische Komponenten organisiert, eine strenge 3-Tier-Architektur durchsetzt und gemeinsame Hilfsfunktionen über saubere öffentliche APIs bereitstellt.
Wenn man einen neuen Node.js-Dienst startet, entsteht schnell Dynamik. Frameworks wie Express, Fastify und NestJS ermöglichen es, innerhalb weniger Minuten einen REST- oder GraphQL-Endpunkt zum Laufen zu bringen. Doch wenn sich Teams erweitern und die Geschäftslogik zunimmt, tritt in solchen Codebasen oft ein bekanntes Problem auf: Sie verwandeln sich in ein verschlungenes, schwer verständliches Durcheinander, das oft als „Spaghetti-Backend“ bezeichnet wird.
In diesem Artikel wird ein vierteiliger Überblick über unternehmensreife Node.js-Praktiken gegeben, der darauf abzielt, eine Architekturgrundlage zu schaffen, die auch mit wachsenden Teams bestehen kann. Sie erfahren, wie man Code nach Geschäftsdomänen organisiert, eine klare Trennung zwischen den Schichten gewährleistet und gemeinsam genutzte Module entwickelt, die nicht zu einer Belastung werden.
1. Strukturierung nach Geschäftskomponenten (domain-getriebene Organisation)
Ein häufiger Fehler in Node.js-Projekten besteht darin, die Codebasis rein nach technischen Aufgabenbereichen auf der obersten Ebene des src-Ordners zu organisieren.
Das Anti-Muster: Technische Schichtung am Wurzelverzeichnis
❌ AVOID: Layered by technical type
src/
├── controllers/
│ ├── userController.js
│ ├── orderController.js
│ └── paymentController.js
├── models/
│ ├── userModel.js
│ ├── orderModel.js
│ └── paymentModel.js
├── services/
│ ├── userService.js
│ ├── orderService.js
│ └── paymentService.js
└── routes/
├── userRoutes.js
├── orderRoutes.js
└── paymentRoutes.js
Warum die technische Schichtung in großen Projekten versagt
- Schlechte Lokalität: Die Entwicklung einer einzigen Funktion – zum Beispiel eines „Order Refund“-Workflows – zwingt dazu, zwischen vier oder mehr unzusammenhängenden Ordnern hin- und herzuwechseln.
- Vage Grenzen: Da alles Technische zusammenliegt, müssen Entwickler direkt auf Services und Modelle zugreifen, was zu zyklischen Abhängigkeiten und engem Kopplungsgrad führt.
- Hohe kognitive Belastung: Ab 50 Modellen wird ein monolithischer
controllers/- odermodels/-Ordner tatsächlich schwer zu durchforsten.
Die Lösung: Modulare Domänenkomponenten
Ein besseres Vorgehen besteht darin, nach Geschäftsfähigkeiten zu organisieren – was das Domain-Driven Design als begrenzte Kontexte bezeichnet. Jeder Komponent wird zu einem selbstständigen Domänenbereich, der seine eigenen Controller, Services, Repositorien und Domainmodelle zusammenfasst.
✅ PREFER: Component-based architecture
src/
├── components/
│ ├── users/
│ │ ├── users.controller.js
│ │ ├── users.service.js
│ │ ├── users.repository.js
│ │ └── users.routes.js
│ ├── orders/
│ │ ├── orders.controller.js
│ │ ├── orders.service.js
│ │ ├── orders.repository.js
│ │ └── orders.routes.js
│ └── payments/
│ ├── payments.controller.js
│ ├── payments.service.js
│ └── payments.repository.js
└── shared/
├── logger/
└── database/
Vorteile:
- Selbstständigkeit: Alles, was mit
Orderszusammenhängt, bleibt in einer einzigen Dateiordnergruppe. - Bereitschaft für Microservices: Wenn die
Payments-Komponente zu groß oder komplex wird, ist es viel einfacher, sie in einen eigenen Microservice auszulagern, da ihre Abhängigkeiten bereits vom Rest der Anwendung isoliert sind.
2. Strikte 3-Schichten-Architektur innerhalb der Komponenten durchsetzen
In jeder Geschäftskomponente sollte eine klare Trennung zwischen drei Schichten gewährleistet werden:
┌─────────────────────────────────────────────────────────┐
│ 1. Entry Point / Transport Layer │
│ (Controllers, Event Subscribers, Route Handlers) │
└───────────────────────────┬─────────────────────────────┘
│ Calls with plain DTOs
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Domain / Business Layer │
│ (Services, Business Logic, Validation Rules) │
└───────────────────────────┬─────────────────────────────┘
│ Calls repository methods
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Data Access Layer │
│ (Repositories, ORMs, Database Queries) │
└─────────────────────────────────────────────────────────┘
Die goldene Regel: Halten Sie Web-Objekte außerhalb der Domänenlogik
Business-Dienste und Repositorien sollten niemals framework-spezifische Objekte erhalten – weder Express req oder res, noch eine Fastify-Anfrageinstanz.
Die falsche Vorgehensweise: Durchsickern von HTTP-Transport-Objekten
// orders.service.js
async function createOrderService(req, res) {
// BAD: Service knows about HTTP headers, status codes, and req.body
const userId = req.headers['x-user-id'];
const orderData = req.body;
if (!orderData.items || orderData.items.length === 0) {
return res.status(400).json({ error: "Cart cannot be empty" });
}
const newOrder = await db.orders.insert({ userId, ...orderData });
return res.status(201).json(newOrder);
}
Warum das schiefgeht:
createOrderServicewird außerhalb eines HTTP-Kontexts unbrauchbar – man kann es weder aus einer CLI-Skript, einem Kafka- oder RabbitMQ-Consumer noch aus einem Cron-Job aufrufen, da keines dieser Toolsreqoderresbereitstellt.- Tests müssen nun HTTP-Anfrage- und -Antwortobjekte emulieren anstelle normaler JavaScript-Werte zu übergeben.
Die richtige Vorgehensweise: Entkoppelte Service-Schicht
// orders.controller.js (Transport Layer)
const orderService = require('./orders.service');
async function handleCreateOrder(req, res, next) {
try {
// 1. Extract values from web context
const userId = req.headers['x-user-id'];
const { items, shippingAddress } = req.body;
// 2. Call domain service with pure primitives/DTOs
const order = await orderService.createOrder({
userId,
items,
shippingAddress
});
// 3. Format HTTP response
return res.status(201).json({ status: 'success', data: order });
} catch (err) {
next(err); // Defer error handling to central middleware
}
}
// orders.service.js (Domain Layer)
const orderRepo = require('./orders.repository');
async function createOrder({ userId, items, shippingAddress }) {
// 1. Pure business logic validation
if (!items || items.length === 0) {
throw new ValidationError('Order must contain at least one item.');
}
// 2. Business calculation
const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// 3. Persist via repository
const createdOrder = await orderRepo.saveOrder({
userId,
items,
shippingAddress,
totalAmount,
status: 'PENDING'
});
return createdOrder;
}
3. Modularisieren Sie Hilfsfunktionen und gewährleisten Sie saubere öffentliche APIs
Sobald eine Anwendung wächst, benötigen viele verschiedene Komponenten gemeinsame Funktionen wie Protokollierung, Authentifizierung, Hilfsfunktionen für Datenbankverbindungen sowie Clients zu externen APIs. Anstatt jedem Nutzer die direkte Zugriffsmöglichkeit auf tief liegende interne Dateipfade zu gewähren, sollten diese gemeinsamen Hilfsfunktionen als interne Module mit einem klar definierten öffentlichen Eingangspunkt verpackt werden.
Die Gefahr des tiefen Imports
// ❌ BAD: Tightly coupled to internal folder structures
const { formatLog } = require("../../shared/logger/utils/formatters/textFormatter.js");
Falls das Team, das für das Protokollierungssystem verantwortlich ist, später beschließt, die Struktur seiner internen Verzeichnisse umzustrukturieren, funktioniert jeder Nutzer, der von diesem tief liegenden Pfad aus importiert hat, sofort nicht mehr.
Lösung A: Export über index.js (CommonJS)
Erstellen Sie eine einzige Eingangsdatei, die nur die für den externen Gebrauch vorgesehenen Teile erneut exportiert und alles Weitere verbirgt.
// shared/logger/index.js
const { logger } = require('./loggerCore');
const { auditLog } = require('./auditLogger');
// Expose ONLY public functions
module.exports = {
logger,
auditLog
};
Dann können die Nutzer über diese saubere Schnittstelle importieren, anstatt auf interne Strukturen zuzugreifen:
// ✅ GOOD: Clean import via public interface
const { logger } = require('../../shared/logger');
Lösung B: Paketexporte mit ESM (Node.js Workspaces / Modernes ESM)
Falls Sie mit modernem Node.js ESM-Code oder -Paketen innerhalb eines Monorepos arbeiten, verwenden Sie das exports-Feld in package.json, um explizit festzulegen, welche Dateien von außerhalb des Pakets importiert werden dürfen.
// shared/logger/package.json
{
"name": "@my-app/logger",
"version": "1.0.0",
"main": "./src/index.js",
"exports": {
".": "./src/index.js"
}
}
Mit dieser Konfiguration scheitert jeder Versuch, einen privaten Pfad wie @my-app/logger/src/internal/formatter.js zu importieren, mit einem Laufzeitfehler. Dadurch entsteht eine klare Grenze, die ein versehentliches Verknüpfen mit Implementierungsdetails verhindert.
Architektur-Überprülliste für Teil 1
Bevor Sie zum Fehlerbehandlung und asynchronen Workflows übergehen, nutzen Sie diese Überprülliste, um sicherzustellen, dass Ihre Codebasis den oben genannten Prinzipien folgt:
- Organisation der Domain: Ist der Code nach Funktionskomponenten (
orders,users) strukturiert anstelle von allgemeinen technischen Verzeichnissen (controllers,models)? - Echte Geschäftsdienste: Enthalten Ihre Servicefunktionen keine Argumente der Übertragungsschicht wie
req,resodernext? - Eindeutige Schnittstellen: Bieten gemeinsame Module eine bewusst eingeschränkte öffentliche Schnittstelle, sei es durch eine
index.js-Datei oder Exporte auspackage.json? - Korrekter Datenfluss: Fließen die Daten streng nach unten, vom Controller über den Service zum Repository, ohne dass untere Schichten wieder zu höheren Schichten zurückgreifen, um dort etwas einzubinden?
Zusätzliche Literatur
- Layered Node.js API Design: Von umfangreichen Controllern 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.
- Sechs DDD-Regeln zur Strukturierung von Domänen in NestJS-Anwendungen — Lernen Sie sechs praktische Regeln des domain-driven designs, um NestJS-Module, Entitäten und Ereignisse so zu organisieren, dass Funktionen isoliert und wartbar bleiben.