NestJS + Prisma: nowoczesny stack backendowy dla Node.js
Kompletny przewodnik po budowaniu nowoczesnego API backendowego z NestJS i Prismą. Konfiguracja, modele, serwisy, transakcje i dobre praktyki.

NestJS i Prisma tworzą potężne połączenie do nowoczesnego rozwoju backendu. NestJS dostarcza modułową architekturę i wstrzykiwanie zależności, podczas gdy Prisma oferuje typowany ORM z wyjątkowym doświadczeniem programisty. Ten stack pozwala budować solidne i łatwe w utrzymaniu API.
Prisma automatycznie generuje typowanego klienta TypeScript na podstawie schematu bazy danych. W połączeniu z NestJS i jego systemem modułów kod sam się dokumentuje, a błędy typów są wykrywane już na etapie kompilacji.
Wstępna konfiguracja projektu
Integracja Prismy z projektem NestJS wymaga kilku kroków konfiguracyjnych. Proces jest standardowy i dobrze udokumentowany.
# 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 postgresqlKomenda tworzy folder prisma/ z plikiem schema.prisma oraz plik .env na zmienne środowiskowe.
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');
}
}Serwis Prismy jest gotowy do wstrzyknięcia w pozostałe serwisy aplikacji.
Tworzenie globalnego modułu Prismy
Aby udostępnić serwis Prismy w całej aplikacji bez konieczności jawnego importowania go w każdym module, używa się dekoratora @Global().
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 {}Import w module głównym automatycznie udostępnia serwis.
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 {}Każdy serwis może teraz wstrzyknąć PrismaService bez dodatkowych importów.
Globalny moduł upraszcza architekturę, ale czyni zależności niejawnymi. W małych aplikacjach jest to akceptowalne. W dużych projektach jawne importy poprawiają śledzenie zależności.
Definiowanie schematu Prismy
Plik schema.prisma definiuje modele danych, relacje i opcje bazy danych. Prisma używa własnego języka definicji schematu (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")
}Po zmianie schematu migracje wprowadzają zmiany do bazy danych.
# 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 studioImplementacja serwisu użytkowników z Prismą
Serwis Users pokazuje typowe operacje CRUD z Prismą. Automatyczne typowanie zapewnia spójność między kodem a bazą danych.
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 } });
}
}Typowanie Prismy gwarantuje, że wszystkie używane właściwości istnieją w schemacie.
Gotowy na rozmowy o Node.js / NestJS?
Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.
Zarządzanie relacjami z Prismą
Prisma upraszcza obsługę złożonych relacji. Zagnieżdżone zapytania pozwalają załadować powiązane dane w jednym żądaniu.
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, '');
}
}include pozwala pobrać głębokie relacje, kontrolując które pola zostają zwrócone.
Transakcje i operacje atomowe
Prisma oferuje kilka metod zapewniających atomowość operacji. Transakcje interaktywne dają największą elastyczność.
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' },
});
});
}
}Transakcje gwarantują, że wszystkie operacje powiodą się razem albo razem zakończą się niepowodzeniem.
Domyślnie transakcje Prismy mają limit czasu 5 sekund. Dla długich operacji można go zmienić za pomocą $transaction([...], { timeout: 10000 }).
Middleware Prismy do audytu
Middleware Prismy pozwala przechwytywać zapytania w celu dodania zachowań przekrojowych, takich jak audyt lub 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();
}
}Middleware są wykonywane w kolejności rejestracji i mogą modyfikować parametry zapytania.
Optymalizacja wydajności z Prismą
Kilka technik optymalizuje wydajność zapytań Prismy w aplikacji NestJS.
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' } });Dla często powtarzanych zapytań cache znacząco poprawia czasy odpowiedzi.
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}`);
}
}Testy z Prismą i NestJS
Testy wymagają strategii izolowanej bazy danych. Użycie dedykowanej bazy testowej zapewnia powtarzalność.
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();
}Testy integracyjne korzystają z tych helperów, aby zapewnić czyste środowisko.
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);
});
});
});Podsumowanie
NestJS i Prisma tworzą nowoczesny i produktywny stack backendowy. Automatyczne typowanie, deklaratywne migracje i natywna integracja z NestJS pozwalają szybko tworzyć solidne API.
Lista kontrolna udanej integracji NestJS + Prisma
- ✅ Globalny moduł Prismy dla uproszczonego wstrzykiwania
- ✅ Schemat Prismy ze zoptymalizowanymi relacjami i indeksami
- ✅ Typowane serwisy z jawnym wyborem pól
- ✅ Transakcje dla operacji atomowych
- ✅ Middleware do audytu i soft delete
- ✅ Cache dla często powtarzanych zapytań
- ✅ Testy integracyjne z izolowaną bazą danych
- ✅ Standaryzowana paginacja przez extensions
Zacznij ćwiczyć!
Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.
To połączenie wykorzystuje mocne strony obu narzędzi: modułową architekturę NestJS dla struktury i Prismę dla typowanej warstwy danych. Efektem jest kod łatwy w utrzymaniu, testowalny i wydajny, odpowiedni do aplikacji enterprise.
Tagi
Udostępnij
Powiązane artykuły

NestJS: Budowanie kompletnego REST API
Kompletny przewodnik po budowaniu profesjonalnego REST API z NestJS. Kontrolery, serwisy, moduły, walidacja z class-validator i obsługa błędów z praktycznymi przykładami.

Pytania na rozmowie kwalifikacyjnej z Node.js Backend: Kompletny przewodnik 2026
25 najczęściej zadawanych pytań na rozmowie kwalifikacyjnej z Node.js backend. Event loop, async/await, strumienie, klasteryzacja i wydajność z szczegółowymi odpowiedziami.

Wydajność Node.js: Event Loop, Klasteryzacja i Optymalizacja w 2026
Optymalizacja wydajności Node.js poprzez zarządzanie pętlą zdarzeń, strategie klasteryzacji i wątki robocze. Praktyczne wzorce dla wysoko obciążonych aplikacji Node.js w 2026 roku.