12k
All articles

Eine große Node.js-App mit NestJS strukturieren

Strukturieren Sie große NestJS-Apps nach Funktionen mit Modulen, Controllern, Services, DTO-Validierung, Guards, Konfiguration und Tests. Klare Abhängigkeiten durch explizite Imports und Exports.

OpenReplay Team
OpenReplay Team
Eine große Node.js-App mit NestJS strukturieren

Eine große NestJS-App sollte nach Features organisiert werden: Jede Domäne (Users, Orders, Billing) erhält einen eigenen Modulordner mit Controller, Service, DTOs und Tests, und Module teilen Code ausschließlich über explizite exports und imports.

Wenn Sie eine wachsende Express-API übernehmen, kennen Sie die Symptome. Eine einzige Route-Datei übernimmt Authentifizierung, Validierung und Datenbankzugriffe, und process.env wird an einem Dutzend Stellen ausgelesen. Niemand weiß genau, welcher Helper gefahrlos geändert werden kann.

Dieser Artikel baut eine kleine Users-und-Orders-API auf und ordnet jedes Element an der richtigen Stelle ein: Module, Controller, Provider, Validierung, Guards, Konfiguration, ein Shared Module und ein Unit-Test. Falls Sie noch bei der Wahl eines Frameworks sind, vergleicht Is There a Rails for JavaScript? die Alternativen.

Die wichtigsten Erkenntnisse

  • Ein NestJS-Modul hält seine Provider standardmäßig privat. Ein anderes Modul kann einen Provider nur injizieren, wenn das besitzende Modul ihn exportiert und das konsumierende Modul dieses Modul importiert.
  • Die Organisation nach Features hält Modul, Controller, Service, DTOs und Tests einer Domäne in einem Ordner zusammen, sodass eine Änderung an Orders innerhalb von src/orders/ bleibt.
  • Bei aktiviertem whitelist entfernt ValidationPipe Properties ohne Validierungs-Decorator. Mit zusätzlichem forbidNonWhitelisted schlagen solche Requests stattdessen fehl.
  • Gibt ein Guard false zurück, wirft Nest eine ForbiddenException. Auth-Prüfungen liegen damit in einer einzigen Klasse statt in jedem Handler.
  • Ein nicht aufgelöster zirkulärer Import zwischen Modulen bringt Nest beim Bootstrap zum Stillstand. Das gemeinsame Konzept auszulagern ist die bessere Lösung als forwardRef().

Das Problem: Eine Express-App ohne Regeln

Express gibt keine Regeln dafür vor, wo Code hingehört. Eine wachsende App ist deshalb so aufgebaut, wie die ersten Handler zufällig geschrieben wurden. Die meisten Projekte beginnen so, wie es eine Anleitung zum Einrichten von Node.js mit TypeScript und Express beschreibt: eine app.ts, ein Routes-Ordner und etwas Middleware. Nach ein paar Monaten sieht ein Handler so aus:

app.post('/orders', (req, res) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) return res.sendStatus(403);
  const { userId, items } = req.body;
  if (typeof userId !== 'number' || !Array.isArray(items)) return res.sendStatus(400);
  const user = users.find((u) => u.id === userId);
  if (!user) return res.sendStatus(404);
  orders.push({ id: orders.length + 1, userId, items });
  res.status(201).json(orders.at(-1));
});

Dieser Handler vermischt fünf Zuständigkeiten. NestJS gibt jeder davon einen festen Platz:

Gewohnheit in ExpressKonstrukt in NestJS
Route-DateiController
Helper-Modul mit Business-LogikProvider (Service)
Auth-Prüfung in Middleware oder HandlerGuard
Zugriffe auf process.envConfigService
Manuelle Prüfung von req.bodyDTO + Validation Pipe

Das alles hat seinen Preis. Bei einer API mit einer Handvoll Routen sind die Decorators, Module und der DI-Container von NestJS unnötiger Overhead. Ihren Nutzen entfalten sie, sobald mehrere Personen jeweils ein Feature ändern, ohne den Rest der Codebasis lesen zu müssen.

Wie sollte man ein großes NestJS-Projekt strukturieren?

Der Kern einer wartbaren NestJS-Projektstruktur ist ein Ordner pro Feature, jeweils mit eigenem Modul. Mit diesem Layout bedeutet eine Änderung an der Order-Logik, ein einziges Verzeichnis zu öffnen, statt sich durch getrennte controllers/-, services/- und dto/-Bäume zu suchen.

src/
├── users/
│   ├── dto/create-user.dto.ts
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.service.spec.ts
│   └── users.module.ts
├── orders/
│   ├── dto/create-order.dto.ts
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.service.spec.ts
│   └── orders.module.ts
├── shared/
│   ├── api-key.guard.ts
│   ├── audit-log.service.ts
│   └── shared.module.ts
├── app.module.ts
└── main.ts

NestJS-Module kapseln ihre Provider standardmäßig. OrdersService benötigt UsersService, also muss UsersModule ihn exportieren:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [SharedModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { UsersModule } from '../users/users.module.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [UsersModule, SharedModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

Der Abhängigkeitsgraph verläuft in eine Richtung: OrdersModule → UsersModule → SharedModule. Alles, was UsersModule nicht exportiert, bleibt intern – und genau das ermöglicht ein sicheres Refactoring eines Features.

Was sind Controller, Provider und Dependency Injection in NestJS?

Ein Controller bildet HTTP-Routen auf Methoden ab. Ein Provider ist jede mit @Injectable() markierte Klasse, die Nest erzeugen und an andere Klassen übergeben kann. Dependency Injection bedeutet, dass eine Klasse ihre Abhängigkeiten als Konstruktorparameter deklariert und das Framework die Instanzen bereitstellt. Derselbe Mechanismus erlaubt es einem Test, ein Fake-Objekt statt der echten Abhängigkeit zu übergeben.

// users/users.service.ts
import { Injectable } from '@nestjs/common';

export interface User { id: number; email: string }

@Injectable()
export class UsersService {
  private readonly users: User[] = [{ id: 1, email: 'ada@example.com' }];

  findOne(id: number): User | undefined {
    return this.users.find((u) => u.id === id);
  }
}
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';

@Injectable()
export class OrdersService {
  private readonly orders: Array<CreateOrderDto & { id: number }> = [];

  constructor(
    private readonly users: UsersService,
    private readonly audit: AuditLogService,
  ) {}

  create(dto: CreateOrderDto) {
    if (!this.users.findOne(dto.userId)) {
      throw new NotFoundException(`User ${dto.userId} not found`);
    }
    const order = { id: this.orders.length + 1, ...dto };
    this.orders.push(order);
    this.audit.record('order.created', order.id);
    return order;
  }
}
// orders/orders.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../shared/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';

@Controller('orders')
@UseGuards(ApiKeyGuard)
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.orders.create(dto);
  }
}

Der Controller übersetzt einen Request in einen einzigen Service-Aufruf. Die Regel „der User muss existieren“ liegt im Service, wo jeder andere Aufrufer sie wiederverwenden und ein Test sie erreichen kann. Eingebaute HTTP-Exceptions wie NotFoundException werden in die passende Fehlerantwort umgewandelt.

Wie funktionieren DTOs und Validation Pipes in NestJS?

Ein DTO (Data Transfer Object) ist eine Klasse, die die Struktur eines Request-Bodys beschreibt. In Kombination mit einer Validation Pipe weist es ungültige Eingaben zurück, bevor der Controller ausgeführt wird. Wenn Sie ein Beispiel-Payload haben, erzeugt der JSON-to-TypeScript-Konverter den Ausgangstyp, den Sie anschließend um Decorators aus class-validator ergänzen:

// orders/dto/create-order.dto.ts
import { ArrayNotEmpty, IsArray, IsInt, IsString } from 'class-validator';

export class CreateOrderDto {
  @IsInt()
  userId!: number;

  @IsArray()
  @ArrayNotEmpty()
  @IsString({ each: true })
  items!: string[];
}

Registrieren Sie die Pipe einmalig in main.ts:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  );
  await app.listen(3000);
}
bootstrap();

Die Validierungsdokumentation beschreibt, was jede Option bewirkt. whitelist entfernt Properties ohne Decorator. forbidNonWhitelisted macht aus diesem stillen Entfernen eine Fehlerantwort. transform wandelt das Payload in eine Instanz der DTO-Klasse um. ValidationPipe setzt class-validator und class-transformer voraus.

NestJS 12, veröffentlicht im August 2026, hat einen zweiten Ansatz eingeführt: StandardSchemaValidationPipe, die Zod-, Valibot- oder ArkType-Schemas validiert, die als @Body({ schema }) übergeben werden. Für sich allein hinterlegt die Option schema das Schema lediglich am Parameter. Validiert wird erst, wenn die Pipe registriert ist, zum Beispiel mit app.useGlobalPipes(new StandardSchemaValidationPipe()).

Ist keine Pipe registriert, merken Sie das am Frontend. Session Replays von API-gestützten UIs zeigen die Eingabe des Users, den fehlerhaften Request und die generische Fehlermeldung nebeneinander – so wird klar, dass die Korrektur an die DTO-Grenze gehört.

Wie handhaben Guards und Config Authentifizierung und Umgebungsvariablen?

Ein Guard ist eine @Injectable()-Klasse, die CanActivate implementiert. Er läuft vor dem Route-Handler und entscheidet, ob der Request weiterverarbeitet wird. Gibt er false zurück, wirft Nest eine ForbiddenException (403). In Kombination mit @nestjs/config liest der Guard sein Secret aus dem ConfigService statt aus process.env:

// shared/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private readonly config: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.headers['x-api-key'] === this.config.get<string>('API_KEY');
  }
}

Der Zugriff auf request.headers funktioniert sowohl mit dem Express- als auch mit dem Fastify-Adapter. Validieren Sie in AppModule die Umgebung beim Start, damit ein fehlender Key bereits beim Booten auffällt und nicht erst beim ersten Request. Seit NestJS 12 akzeptiert validationSchema jedes Standard-Schema-Objekt, etwa ein Zod-Schema. Frühere Versionen erwarteten ein Joi-Schema:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import { UsersModule } from './users/users.module.js';
import { OrdersModule } from './orders/orders.module.js';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: z.object({ API_KEY: z.string().min(1) }),
    }),
    UsersModule,
    OrdersModule,
  ],
})
export class AppModule {}

Die Konfiguration ist der einzige Bereich, in dem sich ein globales Flag wirklich lohnt, da nahezu jedes Modul darauf zugreift.

Ein Shared Module und Tests mit ausgetauschten Abhängigkeiten

Code, der featureübergreifend genutzt wird, gehört in ein Shared Module, das ihn exportiert. Jedes Feature importiert dann dieses Modul, sodass jede Abhängigkeit im imports-Array sichtbar ist. Module mit @Global() zu markieren verschleiert diese Verknüpfung:

// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { AuditLogService } from './audit-log.service.js';
import { ApiKeyGuard } from './api-key.guard.js';

@Module({
  providers: [AuditLogService, ApiKeyGuard],
  exports: [AuditLogService, ApiKeyGuard],
})
export class SharedModule {}

In Tests zahlt sich DI aus. Test.createTestingModule erstellt ein kleines Modul, in dem Sie echte Provider durch einfache Objekte ersetzen:

// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service.js';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';

describe('OrdersService', () => {
  it('throws when the user does not exist', async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        OrdersService,
        { provide: UsersService, useValue: { findOne: () => undefined } },
        { provide: AuditLogService, useValue: { record: () => {} } },
      ],
    }).compile();

    const service = moduleRef.get(OrdersService);
    expect(() => service.create({ userId: 99, items: ['book'] })).toThrow(NotFoundException);
  });
});

Seit NestJS 12 richtet nest new Vitest für ESM-Projekte und Jest für CommonJS-Projekte ein. Importieren Sie bei Vitest describe, it und expect aus vitest, sofern Globals nicht aktiviert sind. Für Integrationstests gegen eine echte Datenbank beschreibt NestJS mit Docker Compose betreiben das Setup.

Was sind typische Fehler in großen NestJS-Codebasen?

Die meisten strukturellen Probleme in großen NestJS-Apps gehen auf drei Muster zurück:

  • Zirkuläre Modulabhängigkeiten. Wenn UsersModule und OrdersModule sich gegenseitig importieren, schlägt Nest beim Bootstrap fehl, weil es die Modulinstanz nicht erzeugen kann. forwardRef() löst den Zyklus auf, doch das gemeinsame Konzept in ein eigenes Modul zu verschieben, beseitigt ihn.
  • Ein aufgeblähtes AppModule. Das Root-Modul sollte Feature-Module und die globale Konfiguration importieren, nicht selbst Dutzende Controller und Provider deklarieren.
  • Logik in Controllern. Sobald ein Controller Entscheidungen trifft, lassen sich diese Regeln weder wiederverwenden noch ohne HTTP per Unit-Test prüfen. Beschränken Sie jede Controller-Methode auf einen einzigen Service-Aufruf.

Fazit

Eine große NestJS-App bleibt beherrschbar, wenn jedes Feature in einem eigenen Modul lebt, Module ausschließlich über explizite Exports miteinander verbunden sind und Controller schlank bleiben. Nehmen Sie sich eine Route Ihrer aktuellen API vor, etwa POST /orders. Verschieben Sie ihre Regeln in einen Service, ihre Body-Prüfungen in ein DTO und ihre Authentifizierung in einen Guard – und wiederholen Sie dieses Muster Feature für Feature.

FAQs

Gehört Auth-Logik in NestJS in eine Middleware oder in einen Guard?

Routenspezifische Authentifizierung gehört in einen Guard. Middleware läuft, bevor Nest den Route-Handler auswählt, und sieht daher nur den rohen Request und die Response. Controller, Handler und Decorator-Metadaten sind für sie nicht sichtbar. Guards laufen, nachdem die gesamte Middleware abgeschlossen ist, und vor jedem Interceptor oder jeder Pipe. Sie erhalten einen ExecutionContext, der den Handler und seine Klasse bereitstellt. Middleware eignet sich weiterhin für Aufgaben, die jeden Request betreffen, etwa Logging.

Was verursacht den Fehler Nest can't resolve dependencies?

Der Fehler bedeutet, dass Nest in dem in der Meldung genannten Modul keinen Provider für einen der Konstruktorparameter findet. Meist fehlt dieser Provider im providers-Array des Moduls und wird auch von keinem Modul in dessen imports-Array exportiert. Zur Behebung exportieren Sie den Provider aus dem Modul, dem er gehört, und importieren dieses Modul. Prüfen Sie außerdem, dass keine mit @Injectable() markierte Klasse in einem imports-Array steht.

Warum schlägt ein numerischer String im Request-Body bei @IsInt fehl, obwohl transform aktiviert ist?

Mit transform: true wandelt ValidationPipe den JSON-Body in eine Instanz der DTO-Klasse um, ändert aber nicht den Typ primitiver Werte. Eine als '30' gesendete userId bleibt ein String und scheitert an @IsInt. Sie können vom Client eine echte Zahl senden oder enableImplicitConversion innerhalb von transformOptions auf true setzen. Die implizite Konvertierung hat allerdings einen eigenen Haken: Jeder nicht leere String, auch 'false', wird bei einem Boolean-Feld zu true.

Wie validiere ich verschachtelte Objekte oder Arrays von Objekten in einem NestJS-DTO?

Versehen Sie die Property mit @ValidateNested() aus class-validator und @Type(() => ItemDto) aus class-transformer. Ohne @Type bleibt der verschachtelte Wert ein einfaches Objekt, sodass die Validierungs-Decorators von ItemDto nie ausgeführt werden. Für ein Array von Objekten verwenden Sie @ValidateNested({ each: true }) zusammen mit demselben @Type-Decorator. Die verschachtelte DTO-Klasse benötigt eigene class-validator-Decorators, und whitelist gilt dann auch für ihre Properties.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.