NestJS + Prisma: der moderne Backend-Stack für Node.js
Vollständiger Leitfaden zum Aufbau einer modernen Backend-API mit NestJS und Prisma. Setup, Modelle, Services, Transaktionen und Best Practices erklärt.

NestJS und Prisma bilden eine starke Kombination für die moderne Backend-Entwicklung. NestJS liefert eine modulare Architektur und Dependency Injection, während Prisma ein typsicheres ORM mit hervorragender Developer Experience bereitstellt. Dieser Stack ermöglicht den Aufbau robuster und wartbarer APIs.
Prisma generiert automatisch einen typisierten TypeScript-Client aus dem Datenbankschema. In Verbindung mit NestJS und seinem Modulsystem dokumentiert sich der Code selbst und Typfehler werden bereits beim Kompilieren erkannt.
Erste Einrichtung des Projekts
Die Integration von Prisma in ein NestJS-Projekt erfordert einige Konfigurationsschritte. Der Ablauf ist standardisiert und gut dokumentiert.
# terminal
# Create a new NestJS project
nest new my-backend-api
cd my-backend-api
# Install Prisma as a dev dependency
npm install prisma --save-dev
# Install the Prisma client
npm install @prisma/client
# Initialize Prisma with PostgreSQL
npx prisma init --datasource-provider postgresqlDieser Befehl legt einen Ordner prisma/ mit der Datei schema.prisma und eine .env-Datei für die Umgebungsvariablen an.
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
// Dedicated logger for Prisma operations
private readonly logger = new Logger(PrismaService.name);
constructor() {
// Configure client with logging in development
super({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}
// Automatic connection on module startup
async onModuleInit() {
await this.$connect();
this.logger.log('Prisma connection established');
}
// Clean disconnection on application shutdown
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('Prisma connection closed');
}
}Der Prisma-Service kann nun in andere Services der Anwendung injiziert werden.
Das globale Prisma-Modul erstellen
Damit der Prisma-Service in der gesamten Anwendung verfügbar ist, ohne ihn in jedem Modul explizit zu importieren, kommt der Decorator @Global() zum Einsatz.
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
// The @Global decorator makes this module available everywhere
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}Der Import im Root-Modul stellt den Service automatisch bereit.
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
import { PostsModule } from './posts/posts.module';
@Module({
imports: [
PrismaModule, // Single declaration is sufficient
UsersModule,
PostsModule,
],
})
export class AppModule {}Jeder Service kann nun ohne zusätzliche Imports den PrismaService injizieren.
Ein globales Modul vereinfacht die Architektur, macht Abhängigkeiten jedoch implizit. Für kleine Anwendungen ist das akzeptabel. Bei großen Projekten verbessern explizite Imports die Nachvollziehbarkeit der Abhängigkeiten.
Das Prisma-Schema definieren
Die Datei schema.prisma definiert Datenmodelle, Beziehungen und Datenbankoptionen. Prisma nutzt seine eigene Schema-Definitionssprache (PSL).
generator client {
provider = "prisma-client-js"
// Enable types for advanced filtering queries
previewFeatures = ["fullTextSearch"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// User model with all relations
model User {
id String @id @default(cuid())
email String @unique
password String
name String
role Role @default(USER)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// One-to-many relations
posts Post[]
comments Comment[]
profile Profile?
// Index for frequent searches
@@index([email])
@@map("users")
}
// Enum for user roles
enum Role {
USER
ADMIN
MODERATOR
}
// One-to-one relation with User
model Profile {
id String @id @default(cuid())
bio String?
avatar String?
website String?
userId String @unique @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
// Posts with many-to-one relation to User
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
published Boolean @default(false)
publishedAt DateTime? @map("published_at")
authorId String @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
comments Comment[]
categories CategoriesOnPosts[]
@@index([authorId])
@@index([slug])
@@map("posts")
}
// Comments with dual relation
model Comment {
id String @id @default(cuid())
content String
postId String @map("post_id")
authorId String @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@index([postId])
@@map("comments")
}
// Categories for posts
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
posts CategoriesOnPosts[]
@@map("categories")
}
// Pivot table for many-to-many relation
model CategoriesOnPosts {
postId String @map("post_id")
categoryId String @map("category_id")
assignedAt DateTime @default(now()) @map("assigned_at")
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
@@map("categories_on_posts")
}Nach Anpassungen am Schema übernehmen Migrationen die Änderungen in die Datenbank.
# terminal
# Create a migration with descriptive name
npx prisma migrate dev --name init_schema
# Generate Prisma client (automatic after migrate dev)
npx prisma generate
# View schema in browser
npx prisma studioDen Users-Service mit Prisma umsetzen
Der Users-Service zeigt typische CRUD-Operationen mit Prisma. Die automatische Typisierung sorgt für Konsistenz zwischen Code und Datenbank.
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} 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, Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
// Type for results without password
type SafeUser = Omit<User, 'password'>;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// Default selection without password
private readonly safeSelect: Prisma.UserSelect = {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
updatedAt: true,
profile: true,
};
async create(createUserDto: CreateUserDto): Promise<SafeUser> {
// 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');
}
// Secure password hashing
const hashedPassword = await bcrypt.hash(createUserDto.password, 12);
// Creation with optional profile
return this.prisma.user.create({
data: {
email: createUserDto.email,
password: hashedPassword,
name: createUserDto.name,
// Nested profile creation if provided
profile: createUserDto.bio ? {
create: {
bio: createUserDto.bio,
},
} : undefined,
},
select: this.safeSelect,
});
}
async findAll(params: {
page?: number;
limit?: number;
search?: string;
}): Promise<{ data: SafeUser[]; total: number; pages: number }> {
const { page = 1, limit = 10, search } = params;
const skip = (page - 1) * limit;
// Optional search condition
const where: Prisma.UserWhereInput = search
? {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
],
}
: {};
// Parallel execution for performance
const [data, total] = await this.prisma.$transaction([
this.prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: this.safeSelect,
}),
this.prisma.user.count({ where }),
]);
return {
data,
total,
pages: Math.ceil(total / limit),
};
}
async findOne(id: string): Promise<SafeUser> {
const user = await this.prisma.user.findUnique({
where: { id },
select: {
...this.safeSelect,
// Include recent posts
posts: {
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
slug: true,
published: true,
},
},
},
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async update(id: string, updateUserDto: UpdateUserDto): Promise<SafeUser> {
// Check existence
await this.findOne(id);
// Update with nested profile handling
return this.prisma.user.update({
where: { id },
data: {
name: updateUserDto.name,
// Update or create profile
profile: updateUserDto.bio ? {
upsert: {
create: { bio: updateUserDto.bio },
update: { bio: updateUserDto.bio },
},
} : undefined,
},
select: this.safeSelect,
});
}
async remove(id: string): Promise<void> {
await this.findOne(id);
// Deletion cascades to profile and posts
await this.prisma.user.delete({ where: { id } });
}
}Die Prisma-Typisierung stellt sicher, dass alle verwendeten Eigenschaften im Schema existieren.
Bereit für deine Node.js / NestJS-Interviews?
Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.
Beziehungen mit Prisma verwalten
Prisma vereinfacht den Umgang mit komplexen Beziehungen. Verschachtelte Abfragen erlauben es, verwandte Daten in einer einzigen Anfrage zu laden.
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class PostsService {
constructor(private readonly prisma: PrismaService) {}
async create(authorId: string, createPostDto: CreatePostDto) {
// Automatic slug generation from title
const slug = this.generateSlug(createPostDto.title);
return this.prisma.post.create({
data: {
title: createPostDto.title,
slug,
content: createPostDto.content,
excerpt: createPostDto.excerpt,
// Connect to existing author
author: {
connect: { id: authorId },
},
// Connect to existing categories
categories: createPostDto.categoryIds ? {
create: createPostDto.categoryIds.map(categoryId => ({
category: { connect: { id: categoryId } },
})),
} : undefined,
},
include: {
author: {
select: { id: true, name: true },
},
categories: {
include: {
category: true,
},
},
},
});
}
async findAllPublished(params: {
page?: number;
limit?: number;
categorySlug?: string;
}) {
const { page = 1, limit = 10, categorySlug } = params;
const skip = (page - 1) * limit;
// Conditional filter by category
const where: Prisma.PostWhereInput = {
published: true,
...(categorySlug && {
categories: {
some: {
category: { slug: categorySlug },
},
},
}),
};
const [posts, total] = await this.prisma.$transaction([
this.prisma.post.findMany({
where,
skip,
take: limit,
orderBy: { publishedAt: 'desc' },
include: {
author: {
select: { id: true, name: true },
},
categories: {
include: {
category: { select: { name: true, slug: true } },
},
},
_count: {
select: { comments: true },
},
},
}),
this.prisma.post.count({ where }),
]);
return { posts, total, pages: Math.ceil(total / limit) };
}
async findBySlug(slug: string) {
const post = await this.prisma.post.findUnique({
where: { slug },
include: {
author: {
select: { id: true, name: true, profile: true },
},
categories: {
include: {
category: true,
},
},
comments: {
orderBy: { createdAt: 'desc' },
take: 20,
include: {
author: {
select: { id: true, name: true },
},
},
},
},
});
if (!post) {
throw new NotFoundException(`Post "${slug}" not found`);
}
return post;
}
async publish(id: string, authorId: string) {
// Verify author is the owner
const post = await this.prisma.post.findUnique({
where: { id },
select: { authorId: true },
});
if (!post) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
if (post.authorId !== authorId) {
throw new ForbiddenException('Publication not authorized');
}
return this.prisma.post.update({
where: { id },
data: {
published: true,
publishedAt: new Date(),
},
});
}
private generateSlug(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
}Mit include lassen sich tief verschachtelte Beziehungen abrufen, wobei genau festgelegt wird, welche Felder zurückgegeben werden.
Transaktionen und atomare Operationen
Prisma bietet mehrere Methoden, um die Atomarität von Operationen sicherzustellen. Interaktive Transaktionen liefern dabei die größte Flexibilität.
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
async createOrder(userId: string, createOrderDto: CreateOrderDto) {
// Interactive transaction to ensure atomicity
return this.prisma.$transaction(async (tx) => {
// 1. Verify stock for each item
const items = await Promise.all(
createOrderDto.items.map(async (item) => {
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product) {
throw new BadRequestException(
`Product ${item.productId} not found`
);
}
if (product.stock < item.quantity) {
throw new BadRequestException(
`Insufficient stock for ${product.name}`
);
}
return { product, quantity: item.quantity };
})
);
// 2. Calculate total
const total = items.reduce(
(sum, { product, quantity }) => sum + product.price * quantity,
0
);
// 3. Create order
const order = await tx.order.create({
data: {
userId,
total,
status: 'PENDING',
items: {
create: items.map(({ product, quantity }) => ({
productId: product.id,
quantity,
price: product.price,
})),
},
},
include: {
items: {
include: { product: true },
},
},
});
// 4. Update stock
await Promise.all(
items.map(({ product, quantity }) =>
tx.product.update({
where: { id: product.id },
data: { stock: { decrement: quantity } },
})
)
);
return order;
});
}
async cancelOrder(orderId: string, userId: string) {
return this.prisma.$transaction(async (tx) => {
// Get order with items
const order = await tx.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (!order || order.userId !== userId) {
throw new BadRequestException('Order not found');
}
if (order.status !== 'PENDING') {
throw new BadRequestException(
'Only pending orders can be cancelled'
);
}
// Restore stock
await Promise.all(
order.items.map((item) =>
tx.product.update({
where: { id: item.productId },
data: { stock: { increment: item.quantity } },
})
)
);
// Update status
return tx.order.update({
where: { id: orderId },
data: { status: 'CANCELLED' },
});
});
}
}Transaktionen garantieren, dass alle Operationen gemeinsam erfolgreich sind oder gemeinsam fehlschlagen.
Standardmäßig haben Prisma-Transaktionen ein Timeout von 5 Sekunden. Für lang laufende Operationen lässt sich das mit $transaction([...], { timeout: 10000 }) anpassen.
Prisma-Middleware für Auditing
Prisma-Middlewares ermöglichen das Abfangen von Abfragen, um übergreifende Verhaltensweisen wie Auditing oder Soft Delete hinzuzufügen.
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient, Prisma } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
],
});
// Middleware for modification auditing
this.$use(async (params: Prisma.MiddlewareParams, next) => {
const start = Date.now();
// Execute query
const result = await next(params);
const duration = Date.now() - start;
// Log slow queries (> 100ms)
if (duration > 100) {
this.logger.warn(
`Slow query: ${params.model}.${params.action} - ${duration}ms`
);
}
// Audit write operations
if (['create', 'update', 'delete'].includes(params.action)) {
this.logger.log(
`Audit: ${params.action} on ${params.model} - ${duration}ms`
);
}
return result;
});
// Middleware for automatic soft delete
this.$use(async (params, next) => {
// Transform delete to update for certain models
if (params.model === 'User' && params.action === 'delete') {
params.action = 'update';
params.args['data'] = { deletedAt: new Date() };
}
// Automatic exclusion of deleted records
if (params.model === 'User' && params.action === 'findMany') {
if (!params.args) params.args = {};
if (!params.args.where) params.args.where = {};
params.args.where.deletedAt = null;
}
return next(params);
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}Middlewares werden in der Reihenfolge ihrer Registrierung ausgeführt und können die Abfrageparameter verändern.
Performance-Optimierung mit Prisma
Verschiedene Techniken optimieren die Performance von Prisma-Abfragen in einer NestJS-Anwendung.
import { Prisma, PrismaClient } from '@prisma/client';
// Extension for standardized pagination
export const paginationExtension = Prisma.defineExtension({
model: {
$allModels: {
async paginate<T, A>(
this: T,
args: Prisma.Exact<A, Prisma.Args<T, 'findMany'>> & {
page?: number;
limit?: number;
}
): Promise<{
data: Prisma.Result<T, A, 'findMany'>;
meta: { page: number; limit: number; total: number; pages: number };
}> {
const { page = 1, limit = 10, ...rest } = args as any;
const skip = (page - 1) * limit;
const context = Prisma.getExtensionContext(this);
const [data, total] = await Promise.all([
(context as any).findMany({ ...rest, skip, take: limit }),
(context as any).count({ where: (rest as any).where }),
]);
return {
data,
meta: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
};
},
},
},
});
// Usage in service
// const result = await this.prisma.$extends(paginationExtension)
// .user.paginate({ page: 2, limit: 20, where: { role: 'USER' } });Für häufige Abfragen verbessert Caching die Antwortzeiten deutlich.
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PostsService {
constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
async findPopularPosts() {
const cacheKey = 'posts:popular';
// Try to get from cache
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
return cached;
}
// Database query
const posts = await this.prisma.post.findMany({
where: { published: true },
orderBy: { comments: { _count: 'desc' } },
take: 10,
include: {
author: { select: { name: true } },
_count: { select: { comments: true } },
},
});
// Cache for 5 minutes
await this.cacheManager.set(cacheKey, posts, 300000);
return posts;
}
async invalidateCache(postId: string) {
// Selective cache invalidation
await this.cacheManager.del('posts:popular');
await this.cacheManager.del(`post:${postId}`);
}
}Tests mit Prisma und NestJS
Tests erfordern eine Strategie für eine isolierte Datenbank. Eine dedizierte Test-Datenbank sorgt für Reproduzierbarkeit.
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
const prisma = new PrismaClient();
export async function setupTestDatabase() {
// Use a test database
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
// Apply migrations
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL },
});
}
export async function cleanupTestDatabase() {
// Delete all data in dependency order
const tablenames = await prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename FROM pg_tables WHERE schemaname='public'
`;
for (const { tablename } of tablenames) {
if (tablename !== '_prisma_migrations') {
await prisma.$executeRawUnsafe(
`TRUNCATE TABLE "public"."${tablename}" CASCADE;`
);
}
}
}
export async function disconnectTestDatabase() {
await prisma.$disconnect();
}Integrationstests nutzen diese Helfer, um eine saubere Umgebung zu garantieren.
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { cleanupTestDatabase, setupTestDatabase } from './helpers/prisma-test.helper';
describe('UsersController (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
await setupTestDatabase();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
prisma = app.get(PrismaService);
await app.init();
});
beforeEach(async () => {
await cleanupTestDatabase();
});
afterAll(async () => {
await app.close();
});
describe('POST /users', () => {
it('should create a new user', async () => {
const createUserDto = {
email: 'test@example.com',
password: 'Password123!',
name: 'Test User',
};
const response = await request(app.getHttpServer())
.post('/users')
.send(createUserDto)
.expect(201);
expect(response.body).toMatchObject({
email: createUserDto.email,
name: createUserDto.name,
});
expect(response.body.password).toBeUndefined();
});
it('should reject duplicate email', async () => {
const createUserDto = {
email: 'duplicate@example.com',
password: 'Password123!',
name: 'First User',
};
await request(app.getHttpServer())
.post('/users')
.send(createUserDto)
.expect(201);
await request(app.getHttpServer())
.post('/users')
.send({ ...createUserDto, name: 'Second User' })
.expect(409);
});
});
});Fazit
NestJS und Prisma bilden einen modernen und produktiven Backend-Stack. Automatische Typisierung, deklarative Migrationen und die native Integration in NestJS ermöglichen die schnelle Entwicklung robuster APIs.
Checkliste für eine erfolgreiche NestJS + Prisma-Integration
- ✅ Globales Prisma-Modul für vereinfachte Injektion
- ✅ Prisma-Schema mit optimierten Beziehungen und Indizes
- ✅ Typisierte Services mit expliziter Feldauswahl
- ✅ Transaktionen für atomare Operationen
- ✅ Middlewares für Auditing und Soft Delete
- ✅ Cache für häufige Abfragen
- ✅ Integrationstests mit isolierter Datenbank
- ✅ Standardisierte Pagination über Extensions
Fang an zu üben!
Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.
Diese Kombination nutzt die Stärken beider Werkzeuge: die modulare Architektur von NestJS für die Struktur und Prisma für eine typsichere Datenebene. Das Ergebnis ist wartbarer, testbarer und performanter Code, geeignet für Enterprise-Anwendungen.
Tags
Teilen
Verwandte Artikel

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.

Node.js Backend Interview-Fragen: Vollständiger Leitfaden 2026
Die 25 häufigsten Node.js Backend Interview-Fragen. Event Loop, async/await, Streams, Clustering und Performance mit ausführlichen Antworten erklärt.

Node.js Performance: Event Loop, Clustering und Optimierung in 2026
Leistungsoptimierung von Node.js durch Event-Loop-Management, Clustering-Strategien und Worker Threads. Praxisnahe Muster für hochperformante Node.js-Anwendungen in 2026.