Startseite / Artikel / Strukturierung von Node.js-Diensten mit domaingetriebenen Modulen und sauberen Schichten

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.

1300 Wörter

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

  1. 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.
  2. 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.
  3. Hohe kognitive Belastung: Ab 50 Modellen wird ein monolithischer controllers/- oder models/-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 Orders zusammenhä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:

  • createOrderService wird 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 Tools req oder res bereitstellt.
  • 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, res oder next?
  • Eindeutige Schnittstellen: Bieten gemeinsame Module eine bewusst eingeschränkte öffentliche Schnittstelle, sei es durch eine index.js-Datei oder Exporte aus package.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

  • Warum das Dekodieren von Puffer Blöcken als Text Failloads von Dateien verursacht — Erklärt, wie das Behandeln binärer Pufferdaten als UTF-8-Text heimlich hochgeladene Dateien beschädigt, und zeigt die richtige Handhabung auf Byte-Ebene, um dies zu verhindern.