NestJS: Eine vollstaendige REST-API von Grund auf erstellen

Schritt-fuer-Schritt-Anleitung zum Erstellen einer produktionsreifen REST-API mit NestJS, TypeScript, Prisma und class-validator. CRUD, Validierung, Fehlerbehandlung und Interceptors.

Anleitung zum Erstellen einer vollstaendigen REST-API mit NestJS und TypeScript

NestJS hat sich als das fuehrende Node.js-Framework fuer den Aufbau skalierbarer serverseitiger Anwendungen etabliert. Die modulare Architektur, das integrierte Dependency-Injection-System und die vollstaendige TypeScript-Unterstuetzung machen NestJS zur idealen Wahl fuer professionelle REST-APIs. Mit NestJS 11 kommen weitere Verbesserungen hinzu, die Entwicklungsgeschwindigkeit und Laufzeitperformance nochmals steigern.

Warum NestJS im Jahr 2026?

NestJS 11 bringt optimiertes Modul-Scanning, schnellere Startup-Zeiten und verbesserte Decorators. Das Framework kombiniert die besten Konzepte aus Angular (Decorators, Module, DI) mit der Flexibilitaet von Node.js und bietet damit eine erstklassige Entwicklererfahrung fuer Backend-Projekte.

Projektinstallation und Konfiguration

Die NestJS CLI erstellt ein vorkonfiguriertes Projekt mit TypeScript, ESLint und einer durchdachten Ordnerstruktur. Der Einstieg erfordert nur wenige Befehle.

bash
# terminal
# Global installation of the NestJS CLI
npm install -g @nestjs/cli

# Create a new project
nest new my-api

# Navigate to the project
cd my-api

# Start in development mode with hot-reload
npm run start:dev

Die Datei main.ts dient als Einstiegspunkt der Anwendung. Hier werden globale Pipes, Praefix und weitere Konfigurationen festgelegt.

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

async function bootstrap() {
  // Create the NestJS application
  const app = await NestFactory.create(AppModule);

  // Enable global validation
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,           // Removes non-decorated properties
    forbidNonWhitelisted: true, // Rejects requests with unknown properties
    transform: true,            // Transforms payloads to DTO instances
  }));

  // Configure global prefix for all routes
  app.setGlobalPrefix('api');

  await app.listen(3000);
}
bootstrap();

Die ValidationPipe mit whitelist: true entfernt automatisch Felder, die nicht im DTO definiert sind. Mit forbidNonWhitelisted: true wird sogar ein Fehler geworfen, wenn unbekannte Felder gesendet werden. Das schuetzt die API vor unerwuenschten Daten.

Modulare Architektur verstehen

NestJS organisiert Code in Modulen. Jedes Modul kapselt zusammengehoerige Controller, Services und Entitaeten. Das Root-Modul importiert alle Feature-Module und bildet den Ausgangspunkt der Anwendung.

src/app.module.tstypescript
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { ProductsModule } from './products/products.module';
import { AuthModule } from './auth/auth.module';

// The root module imports all application modules
@Module({
  imports: [
    UsersModule,    // User management
    ProductsModule, // Product catalog
    AuthModule,     // Authentication
  ],
})
export class AppModule {}

Jedes Feature-Modul folgt demselben Muster: Controller fuer HTTP-Anfragen, Services fuer Geschaeftslogik und Exports fuer moduluebergreifende Nutzung.

src/users/users.module.tstypescript
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  // Controllers handle HTTP requests
  controllers: [UsersController],
  // Providers are injectable throughout the module
  providers: [UsersService],
  // Exports make providers available to other modules
  exports: [UsersService],
})
export class UsersModule {}

Diese Struktur erzwingt eine klare Trennung der Verantwortlichkeiten. Der UsersService wird ueber Dependency Injection in den Controller eingebunden. Durch den exports-Array kann er auch in anderen Modulen genutzt werden, etwa im AuthModule.

Einen vollstaendigen CRUD-Controller erstellen

Der Controller definiert die HTTP-Endpunkte und delegiert die Geschaeftslogik an den Service. NestJS-Decorators wie @Get(), @Post(), @Put() und @Delete() mappen die Methoden auf HTTP-Verben.

src/users/users.controller.tstypescript
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

// Route prefix: /api/users
@Controller('users')
export class UsersController {
  // Inject service via constructor
  constructor(private readonly usersService: UsersService) {}

  // POST /api/users - Create a user
  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() createUserDto: CreateUserDto): Promise<User> {
    // The DTO is automatically validated before reaching here
    return this.usersService.create(createUserDto);
  }

  // GET /api/users - List with pagination
  @Get()
  async findAll(
    @Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
    @Query('limit', new ParseIntPipe({ optional: true })) limit: number = 10,
  ): Promise<{ data: User[]; total: number }> {
    return this.usersService.findAll(page, limit);
  }

  // GET /api/users/:id - Retrieve by ID
  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number): Promise<User> {
    // ParseIntPipe automatically converts and validates the parameter
    return this.usersService.findOne(id);
  }

  // PUT /api/users/:id - Full update
  @Put(':id')
  async update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ): Promise<User> {
    return this.usersService.update(id, updateUserDto);
  }

  // DELETE /api/users/:id - Deletion
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
    await this.usersService.remove(id);
  }
}
Eingebaute Validation Pipes

Die ParseIntPipe konvertiert String-Parameter automatisch in Zahlen und gibt einen 400-Fehler zurueck, falls der Wert keine gueltige Zahl ist. NestJS bietet weitere eingebaute Pipes wie ParseBoolPipe, ParseArrayPipe und ParseUUIDPipe fuer gaengige Konvertierungen.

Implementierung des Business-Service

Der Service enthaelt die gesamte Geschaeftslogik und ist durch den @Injectable()-Decorator als Provider markiert. In dieser ersten Version wird ein In-Memory-Array als Datenspeicher verwendet, bevor spaeter die Integration mit Prisma folgt.

src/users/users.service.tstypescript
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  // In-memory database simulation
  private users: User[] = [];
  private idCounter = 1;

  async create(createUserDto: CreateUserDto): Promise<User> {
    // Create the user entity
    const user: User = {
      id: this.idCounter++,
      ...createUserDto,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    this.users.push(user);
    return user;
  }

  async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
    // Calculate pagination
    const start = (page - 1) * limit;
    const end = start + limit;

    return {
      data: this.users.slice(start, end),
      total: this.users.length,
    };
  }

  async findOne(id: number): Promise<User> {
    const user = this.users.find(u => u.id === id);

    // Throw exception if user doesn't exist
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }

    return user;
  }

  async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
    const user = await this.findOne(id);

    // Merge existing data with updates
    Object.assign(user, updateUserDto, { updatedAt: new Date() });

    return user;
  }

  async remove(id: number): Promise<void> {
    const index = this.users.findIndex(u => u.id === id);

    if (index === -1) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }

    this.users.splice(index, 1);
  }

  // Utility method for other services
  async findByEmail(email: string): Promise<User | undefined> {
    return this.users.find(u => u.email === email);
  }
}

Bereit für deine Node.js / NestJS-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Der Service nutzt NotFoundException aus @nestjs/common, um standardkonforme 404-Fehler auszuloesen. NestJS wandelt diese Exceptions automatisch in passende HTTP-Antworten um.

Datenvalidierung mit class-validator

DTOs (Data Transfer Objects) definieren die Struktur eingehender Daten und nutzen Decorators von class-validator fuer die Validierung. Das CreateUserDto legt fest, welche Felder beim Erstellen eines Benutzers erforderlich sind.

src/users/dto/create-user.dto.tstypescript
import {
  IsEmail,
  IsNotEmpty,
  IsString,
  MinLength,
  MaxLength,
  IsOptional,
  Matches,
} from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty({ message: 'Email is required' })
  @IsEmail({}, { message: 'Invalid email format' })
  email: string;

  @IsNotEmpty({ message: 'Password is required' })
  @MinLength(8, { message: 'Password must be at least 8 characters' })
  @MaxLength(50, { message: 'Password cannot exceed 50 characters' })
  @Matches(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    { message: 'Password must contain at least one uppercase, one lowercase, and one number' }
  )
  password: string;

  @IsNotEmpty({ message: 'First name is required' })
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  firstName: string;

  @IsNotEmpty({ message: 'Last name is required' })
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  lastName: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  phone?: string;
}

Das UpdateUserDto nutzt PartialType und OmitType, um alle Felder optional zu machen und das Passwort von Standard-Updates auszuschliessen.

src/users/dto/update-user.dto.tstypescript
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

// All fields from CreateUserDto become optional
// Password is excluded from standard updates
export class UpdateUserDto extends PartialType(
  OmitType(CreateUserDto, ['password'] as const)
) {}

Die User-Entitaet definiert die vollstaendige Datenstruktur einschliesslich automatisch generierter Felder.

src/users/entities/user.entity.tstypescript
export class User {
  id: number;
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  phone?: string;
  createdAt: Date;
  updatedAt: Date;
}

Zentralisierte Fehlerbehandlung

Exception Filter fangen Fehler ab und formatieren die HTTP-Antworten einheitlich. Der HttpExceptionFilter behandelt bekannte HTTP-Fehler, waehrend der AllExceptionsFilter unerwartete Systemfehler abfaengt.

src/common/filters/http-exception.filter.tstypescript
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';

// Catches all HttpExceptions
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    // Retrieve error message (can be string or object)
    const exceptionResponse = exception.getResponse();
    const message = typeof exceptionResponse === 'string'
      ? exceptionResponse
      : (exceptionResponse as any).message;

    // Standardized response format
    response.status(status).json({
      success: false,
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      message: message,
    });
  }
}
src/common/filters/all-exceptions.filter.tstypescript
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';

// Catches ALL exceptions (including system errors)
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    // Determine HTTP code and message
    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;

    const message = exception instanceof HttpException
      ? exception.message
      : 'Internal server error';

    // Log error for debugging
    console.error('Exception caught:', exception);

    response.status(status).json({
      success: false,
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message: message,
    });
  }
}

Beide Filter werden in der main.ts global registriert.

src/main.ts (updated)typescript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Global validation
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }));

  // Global exception filters
  app.useGlobalFilters(
    new AllExceptionsFilter(),
    new HttpExceptionFilter(),
  );

  app.setGlobalPrefix('api');
  await app.listen(3000);
}
bootstrap();
Reihenfolge der Filter

Die Reihenfolge der Filter bei useGlobalFilters() ist entscheidend. Der zuletzt registrierte Filter wird zuerst ausgefuehrt. Der HttpExceptionFilter muss nach dem AllExceptionsFilter stehen, damit HTTP-Exceptions spezifisch behandelt werden, bevor der allgemeine Filter greift.

Integration mit Prisma ORM

Prisma bietet typsicheren Datenbankzugriff mit automatisch generierten Typen. Die Installation und Einrichtung mit PostgreSQL erfolgt in wenigen Schritten.

bash
# terminal
# Install Prisma
npm install prisma @prisma/client

# Initialize Prisma with PostgreSQL
npx prisma init --datasource-provider postgresql

Das Prisma-Schema definiert die Datenbankmodelle und deren Beziehungen.

prisma/schema.prismaprisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  firstName String   @map("first_name")
  lastName  String   @map("last_name")
  phone     String?
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // Relations
  posts     Post[]

  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  authorId  Int      @map("author_id")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // Relation to User
  author    User     @relation(fields: [authorId], references: [id])

  @@map("posts")
}

Der PrismaService kapselt den Prisma-Client und verwaltet die Datenbankverbindung ueber die NestJS-Lifecycle-Hooks.

src/prisma/prisma.service.tstypescript
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  // Automatic connection on module startup
  async onModuleInit() {
    await this.$connect();
  }

  // Clean disconnection on application shutdown
  async onModuleDestroy() {
    await this.$disconnect();
  }
}

Das PrismaModule wird mit @Global() dekoriert, damit der PrismaService in der gesamten Anwendung verfuegbar ist, ohne ihn in jedem Modul einzeln importieren zu muessen.

src/prisma/prisma.module.tstypescript
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

// @Global makes the service available throughout the application
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Der aktualisierte UsersService ersetzt den In-Memory-Speicher durch Prisma-Abfragen und fuegt Passwort-Hashing sowie E-Mail-Eindeutigkeitspruefungen hinzu.

src/users/users.service.ts (Prisma version)typescript
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from '@prisma/client';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  async create(createUserDto: CreateUserDto): Promise<Omit<User, 'password'>> {
    // Check email uniqueness
    const existingUser = await this.prisma.user.findUnique({
      where: { email: createUserDto.email },
    });

    if (existingUser) {
      throw new ConflictException('This email is already in use');
    }

    // Hash the password
    const hashedPassword = await bcrypt.hash(createUserDto.password, 10);

    // Create the user
    const user = await this.prisma.user.create({
      data: {
        ...createUserDto,
        password: hashedPassword,
      },
    });

    // Exclude password from response
    const { password, ...result } = user;
    return result;
  }

  async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
    // Parallel execution of count and paginated query
    const [data, total] = await Promise.all([
      this.prisma.user.findMany({
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: 'desc' },
        select: {
          id: true,
          email: true,
          firstName: true,
          lastName: true,
          phone: true,
          createdAt: true,
          updatedAt: true,
        },
      }),
      this.prisma.user.count(),
    ]);

    return { data: data as User[], total };
  }

  async findOne(id: number): Promise<Omit<User, 'password'>> {
    const user = await this.prisma.user.findUnique({
      where: { id },
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        phone: true,
        createdAt: true,
        updatedAt: true,
      },
    });

    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }

    return user as Omit<User, 'password'>;
  }

  async update(id: number, updateUserDto: UpdateUserDto): Promise<Omit<User, 'password'>> {
    // Check existence
    await this.findOne(id);

    const user = await this.prisma.user.update({
      where: { id },
      data: updateUserDto,
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        phone: true,
        createdAt: true,
        updatedAt: true,
      },
    });

    return user as Omit<User, 'password'>;
  }

  async remove(id: number): Promise<void> {
    await this.findOne(id);
    await this.prisma.user.delete({ where: { id } });
  }
}

Interceptors fuer Response-Transformation

Interceptors verarbeiten Anfragen und Antworten uebergreifend. Der TransformInterceptor kapselt alle Antworten in ein einheitliches Format, waehrend der LoggingInterceptor Ausfuehrungszeiten protokolliert.

src/common/interceptors/transform.interceptor.tstypescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

// Interface for standardized response format
export interface ApiResponse<T> {
  success: boolean;
  data: T;
  timestamp: string;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}
src/common/interceptors/logging.interceptor.tstypescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const now = Date.now();

    return next.handle().pipe(
      tap(() => {
        const response = context.switchToHttp().getResponse();
        const { statusCode } = response;
        const duration = Date.now() - now;

        // Structured log format
        this.logger.log(
          `${method} ${url} ${statusCode} - ${duration}ms`
        );
      }),
    );
  }
}

Der TransformInterceptor stellt sicher, dass alle Endpunkte dasselbe Antwortformat verwenden: { success, data, timestamp }. Der LoggingInterceptor misst die Ausfuehrungsdauer jeder Anfrage und schreibt strukturierte Logs, die fuer Monitoring-Tools verwertbar sind.

Fazit

Diese Anleitung hat alle Kernkonzepte abgedeckt, die fuer den Aufbau einer produktionsreifen REST-API mit NestJS erforderlich sind. Von der modularen Architektur ueber Validierung bis hin zur Datenbankintegration bietet NestJS ein durchdachtes Gesamtpaket.

Checkliste fuer die REST-API

  • NestJS-Projekt mit der CLI erstellen und ValidationPipe global konfigurieren
  • Code in Feature-Module mit klarer Trennung von Controller und Service aufteilen
  • DTOs mit class-validator-Decorators fuer automatische Datenvalidierung definieren
  • Zentralisierte Fehlerbehandlung mit Exception Filters einrichten
  • Prisma fuer typsicheren Datenbankzugriff integrieren
  • Interceptors fuer einheitliche Antwortformate und Logging implementieren

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Mit dieser Grundlage laesst sich die API durch Guards fuer Authentifizierung, Swagger fuer API-Dokumentation und Middleware fuer Rate Limiting erweitern. Die modulare Architektur von NestJS sorgt dafuer, dass die Codebasis auch bei wachsender Komplexitaet uebersichtlich und wartbar bleibt.

Tags

#nestjs
#nodejs
#typescript
#rest api
#backend

Teilen

Verwandte Artikel