NestJS + Prisma: de moderne backend-stack voor Node.js
Volledige gids voor het bouwen van een moderne backend-API met NestJS en Prisma. Setup, modellen, services, transacties en best practices uitgelegd.

NestJS en Prisma vormen een krachtige combinatie voor moderne backend-ontwikkeling. NestJS biedt een modulaire architectuur en dependency injection, terwijl Prisma een type-safe ORM levert met een uitstekende developer experience. Deze stack maakt het mogelijk robuuste en onderhoudbare API's te bouwen.
Prisma genereert automatisch een getypeerde TypeScript-client op basis van het database-schema. In combinatie met NestJS en zijn modulesysteem documenteert de code zichzelf en worden typefouten al tijdens compile-time opgespoord.
Initiële projectconfiguratie
Prisma integreren in een NestJS-project vraagt om enkele configuratiestappen. Het proces is standaard en goed gedocumenteerd.
# 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 postgresqlDit commando maakt een map prisma/ aan met het bestand schema.prisma en een .env-bestand voor omgevingsvariabelen.
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');
}
}De Prisma-service kan nu worden geïnjecteerd in de overige services van de applicatie.
De globale Prisma-module aanmaken
Om de Prisma-service overal in de applicatie beschikbaar te maken zonder hem in elke module expliciet te importeren, wordt de decorator @Global() gebruikt.
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 {}De import in de root-module zorgt dat de service automatisch beschikbaar is.
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 {}Elke service kan nu zonder extra imports PrismaService injecteren.
Een globale module vereenvoudigt de architectuur, maar maakt afhankelijkheden impliciet. Voor kleine applicaties is dat acceptabel. In grote projecten verbeteren expliciete imports de traceerbaarheid van afhankelijkheden.
Het Prisma-schema definiëren
Het bestand schema.prisma definieert de datamodellen, relaties en database-opties. Prisma gebruikt zijn eigen schema-definitietaal (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")
}Na een schemawijziging passen migraties de aanpassingen toe op de database.
# 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 studioDe users-service implementeren met Prisma
De Users-service illustreert de gangbare CRUD-operaties met Prisma. De automatische typering garandeert consistentie tussen code en database.
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 } });
}
}De Prisma-typering garandeert dat alle gebruikte properties bestaan in het schema.
Klaar om je Node.js / NestJS gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
Relaties beheren met Prisma
Prisma vereenvoudigt het werken met complexe relaties. Geneste queries maken het mogelijk gerelateerde data in één enkele aanroep te 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, '');
}
}Met include worden diepe relaties opgehaald, terwijl exact wordt bepaald welke velden worden teruggegeven.
Transacties en atomische operaties
Prisma biedt verschillende methodes om de atomiciteit van operaties te garanderen. Interactieve transacties bieden de meeste flexibiliteit.
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' },
});
});
}
}Transacties garanderen dat alle operaties samen slagen of samen mislukken.
Standaard hebben Prisma-transacties een timeout van 5 seconden. Voor langlopende operaties kan dit aangepast worden met $transaction([...], { timeout: 10000 }).
Prisma-middleware voor auditing
Prisma-middlewares maken het mogelijk queries te onderscheppen om transversale gedragingen toe te voegen, zoals auditing of soft delete.
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 worden uitgevoerd in volgorde van registratie en kunnen de query-parameters aanpassen.
Performance-optimalisatie met Prisma
Meerdere technieken optimaliseren de prestaties van Prisma-queries in een NestJS-applicatie.
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' } });Voor frequente queries verbetert caching de responstijden aanzienlijk.
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}`);
}
}Testen met Prisma en NestJS
Testen vraagt om een geïsoleerde databasestrategie. Het gebruik van een dedicated testdatabase garandeert reproduceerbaarheid.
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();
}Integratietests gebruiken deze helpers om een schone omgeving te garanderen.
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);
});
});
});Conclusie
NestJS en Prisma vormen een moderne en productieve backend-stack. Automatische typering, declaratieve migraties en de native integratie met NestJS maken het mogelijk snel robuuste API's te ontwikkelen.
Checklist voor een geslaagde NestJS + Prisma-integratie
- ✅ Globale Prisma-module voor vereenvoudigde injection
- ✅ Prisma-schema met geoptimaliseerde relaties en indexen
- ✅ Getypeerde services met expliciete veldselectie
- ✅ Transacties voor atomische operaties
- ✅ Middlewares voor auditing en soft delete
- ✅ Cache voor frequente queries
- ✅ Integratietests met geïsoleerde database
- ✅ Gestandaardiseerde paginering via extensions
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Deze combinatie benut de sterke punten van beide tools: de modulaire architectuur van NestJS voor de structuur en Prisma voor een type-safe datalaag. Het resultaat is onderhoudbare, testbare en performante code, geschikt voor enterprise-applicaties.
Tags
Delen
Gerelateerde artikelen

NestJS: Een complete REST API vanaf nul bouwen
Stap-voor-stap handleiding voor het bouwen van een productieklare REST API met NestJS, TypeScript, Prisma en class-validator. CRUD, validatie, foutafhandeling en interceptors.

Node.js Backend Sollicitatievragen: Volledige Gids 2026
De 25 meest gestelde Node.js backend sollicitatievragen. Event loop, async/await, streams, clustering en performance uitgelegd met gedetailleerde antwoorden.

Node.js Performance: Event Loop, Clustering en Optimalisatie in 2026
Node.js-prestatieoptimalisatie via event loop management, clusteringstrategieën en worker threads. Praktische patronen voor high-throughput Node.js-applicaties in 2026.