NestJS + Prisma : La stack moderne pour le backend Node.js
Guide complet pour créer une API backend moderne avec NestJS et Prisma. Configuration, modèles, services, transactions et bonnes pratiques expliquées.

NestJS et Prisma forment une combinaison puissante pour le développement backend moderne. NestJS apporte une architecture modulaire et l'injection de dépendances, tandis que Prisma offre un ORM type-safe avec une expérience développeur exceptionnelle. Cette stack permet de construire des APIs robustes et maintenables.
Prisma génère automatiquement un client TypeScript typé à partir du schéma de base de données. Combiné avec NestJS et son système de modules, le code devient auto-documenté et les erreurs de type sont détectées à la compilation.
Installation et configuration initiale
La mise en place de Prisma dans un projet NestJS nécessite quelques étapes de configuration. Le processus est standard et bien documenté.
# terminal
# Création d'un nouveau projet NestJS
nest new my-backend-api
cd my-backend-api
# Installation de Prisma comme dépendance de développement
npm install prisma --save-dev
# Installation du client Prisma
npm install @prisma/client
# Initialisation de Prisma avec PostgreSQL
npx prisma init --datasource-provider postgresqlCette commande crée un dossier prisma/ avec le fichier schema.prisma et un fichier .env pour les variables d'environnement.
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
// Logger dédié pour les opérations Prisma
private readonly logger = new Logger(PrismaService.name);
constructor() {
// Configuration du client avec logging en développement
super({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}
// Connexion automatique au démarrage du module
async onModuleInit() {
await this.$connect();
this.logger.log('Connexion Prisma établie');
}
// Déconnexion propre à l'arrêt de l'application
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('Connexion Prisma fermée');
}
}Le service Prisma est maintenant prêt à être injecté dans les autres services de l'application.
Création du module Prisma global
Pour rendre le service Prisma disponible dans toute l'application sans l'importer explicitement dans chaque module, le décorateur @Global() est utilisé.
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
// Le décorateur @Global rend ce module disponible partout
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}L'import dans le module racine rend le service disponible automatiquement.
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, // Une seule déclaration suffit
UsersModule,
PostsModule,
],
})
export class AppModule {}Désormais, n'importe quel service peut injecter PrismaService sans import supplémentaire.
Un module global simplifie l'architecture mais rend les dépendances implicites. Pour les petites applications, c'est acceptable. Pour les projets de grande taille, des imports explicites peuvent améliorer la traçabilité des dépendances.
Définition du schéma Prisma
Le fichier schema.prisma définit les modèles de données, les relations et les options de base de données. Prisma utilise son propre langage de définition de schéma (PSL).
generator client {
provider = "prisma-client-js"
// Active les types pour les requêtes de filtrage avancées
previewFeatures = ["fullTextSearch"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Modèle utilisateur avec toutes les 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")
// Relations one-to-many
posts Post[]
comments Comment[]
profile Profile?
// Index pour les recherches fréquentes
@@index([email])
@@map("users")
}
// Enum pour les rôles utilisateur
enum Role {
USER
ADMIN
MODERATOR
}
// Relation one-to-one avec 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")
}
// Articles avec relation many-to-one vers 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")
}
// Commentaires avec double 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")
}
// Catégories pour les articles
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
posts CategoriesOnPosts[]
@@map("categories")
}
// Table pivot pour la relation many-to-many
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")
}Après modification du schéma, la migration applique les changements à la base de données.
# terminal
# Création d'une migration avec nom descriptif
npx prisma migrate dev --name init_schema
# Génération du client Prisma (automatique après migrate dev)
npx prisma generate
# Visualisation du schéma dans le navigateur
npx prisma studioImplémentation du service Users avec Prisma
Le service Users illustre les opérations CRUD courantes avec Prisma. Le typage automatique garantit la cohérence entre le code et la base de données.
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 pour les résultats sans mot de passe
type SafeUser = Omit<User, 'password'>;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// Sélection par défaut sans le mot de passe
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> {
// Vérification de l'unicité de l'email
const existingUser = await this.prisma.user.findUnique({
where: { email: createUserDto.email },
});
if (existingUser) {
throw new ConflictException('Cet email est déjà utilisé');
}
// Hashage sécurisé du mot de passe
const hashedPassword = await bcrypt.hash(createUserDto.password, 12);
// Création avec profil optionnel
return this.prisma.user.create({
data: {
email: createUserDto.email,
password: hashedPassword,
name: createUserDto.name,
// Création imbriquée du profil si fourni
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;
// Condition de recherche optionnelle
const where: Prisma.UserWhereInput = search
? {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
],
}
: {};
// Exécution parallèle pour les performances
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,
// Inclusion des posts récents
posts: {
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
slug: true,
published: true,
},
},
},
});
if (!user) {
throw new NotFoundException(`Utilisateur avec l'ID ${id} introuvable`);
}
return user;
}
async update(id: string, updateUserDto: UpdateUserDto): Promise<SafeUser> {
// Vérification de l'existence
await this.findOne(id);
// Mise à jour avec gestion du profil imbriqué
return this.prisma.user.update({
where: { id },
data: {
name: updateUserDto.name,
// Mise à jour ou création du profil
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);
// La suppression cascade vers le profil et les posts
await this.prisma.user.delete({ where: { id } });
}
}Le typage Prisma garantit que toutes les propriétés utilisées existent dans le schéma.
Prêt à réussir tes entretiens Node.js / NestJS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Gestion des relations avec Prisma
Prisma simplifie la gestion des relations complexes. Les requêtes imbriquées permettent de charger les données associées en une seule requête.
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) {
// Génération automatique du slug à partir du titre
const slug = this.generateSlug(createPostDto.title);
return this.prisma.post.create({
data: {
title: createPostDto.title,
slug,
content: createPostDto.content,
excerpt: createPostDto.excerpt,
// Connexion à l'auteur existant
author: {
connect: { id: authorId },
},
// Connexion aux catégories existantes
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;
// Filtre conditionnel par catégorie
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(`Article "${slug}" introuvable`);
}
return post;
}
async publish(id: string, authorId: string) {
// Vérification que l'auteur est bien le propriétaire
const post = await this.prisma.post.findUnique({
where: { id },
select: { authorId: true },
});
if (!post) {
throw new NotFoundException(`Article avec l'ID ${id} introuvable`);
}
if (post.authorId !== authorId) {
throw new ForbiddenException('Publication non autorisée');
}
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, '');
}
}Les includes permettent de récupérer les relations en profondeur tout en contrôlant les champs retournés.
Transactions et opérations atomiques
Prisma propose plusieurs méthodes pour garantir l'atomicité des opérations. Les transactions interactives offrent le plus de flexibilité.
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) {
// Transaction interactive pour garantir l'atomicité
return this.prisma.$transaction(async (tx) => {
// 1. Vérification du stock pour chaque article
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(
`Produit ${item.productId} introuvable`
);
}
if (product.stock < item.quantity) {
throw new BadRequestException(
`Stock insuffisant pour ${product.name}`
);
}
return { product, quantity: item.quantity };
})
);
// 2. Calcul du total
const total = items.reduce(
(sum, { product, quantity }) => sum + product.price * quantity,
0
);
// 3. Création de la commande
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. Mise à jour du 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) => {
// Récupération de la commande avec ses articles
const order = await tx.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (!order || order.userId !== userId) {
throw new BadRequestException('Commande introuvable');
}
if (order.status !== 'PENDING') {
throw new BadRequestException(
'Seules les commandes en attente peuvent être annulées'
);
}
// Restauration du stock
await Promise.all(
order.items.map((item) =>
tx.product.update({
where: { id: item.productId },
data: { stock: { increment: item.quantity } },
})
)
);
// Mise à jour du statut
return tx.order.update({
where: { id: orderId },
data: { status: 'CANCELLED' },
});
});
}
}Les transactions garantissent que toutes les opérations réussissent ensemble ou échouent ensemble.
Par défaut, les transactions Prisma ont un timeout de 5 secondes. Pour les opérations longues, ce paramètre peut être ajusté avec $transaction([...], { timeout: 10000 }).
Middleware Prisma pour l'audit
Les middlewares Prisma permettent d'intercepter les requêtes pour ajouter des comportements transversaux comme l'audit ou le 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 pour l'audit des modifications
this.$use(async (params: Prisma.MiddlewareParams, next) => {
const start = Date.now();
// Exécution de la requête
const result = await next(params);
const duration = Date.now() - start;
// Log des requêtes lentes (> 100ms)
if (duration > 100) {
this.logger.warn(
`Requête lente: ${params.model}.${params.action} - ${duration}ms`
);
}
// Audit des opérations d'écriture
if (['create', 'update', 'delete'].includes(params.action)) {
this.logger.log(
`Audit: ${params.action} on ${params.model} - ${duration}ms`
);
}
return result;
});
// Middleware pour le soft delete automatique
this.$use(async (params, next) => {
// Transformation des delete en update pour certains modèles
if (params.model === 'User' && params.action === 'delete') {
params.action = 'update';
params.args['data'] = { deletedAt: new Date() };
}
// Exclusion automatique des enregistrements supprimés
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();
}
}Les middlewares s'exécutent dans l'ordre d'enregistrement et peuvent modifier les paramètres de requête.
Optimisation des performances avec Prisma
Plusieurs techniques permettent d'optimiser les performances des requêtes Prisma dans une application NestJS.
import { Prisma, PrismaClient } from '@prisma/client';
// Extension pour la pagination standardisée
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),
},
};
},
},
},
});
// Utilisation dans le service
// const result = await this.prisma.$extends(paginationExtension)
// .user.paginate({ page: 2, limit: 20, where: { role: 'USER' } });Pour les requêtes fréquentes, la mise en cache améliore significativement les temps de réponse.
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';
// Tentative de récupération depuis le cache
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
return cached;
}
// Requête en base de données
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 } },
},
});
// Mise en cache pour 5 minutes
await this.cacheManager.set(cacheKey, posts, 300000);
return posts;
}
async invalidateCache(postId: string) {
// Invalidation sélective du cache
await this.cacheManager.del('posts:popular');
await this.cacheManager.del(`post:${postId}`);
}
}Tests avec Prisma et NestJS
Les tests nécessitent une stratégie de base de données isolée. L'utilisation d'une base de données de test dédiée garantit la reproductibilité.
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
const prisma = new PrismaClient();
export async function setupTestDatabase() {
// Utilisation d'une base de données de test
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
// Application des migrations
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL },
});
}
export async function cleanupTestDatabase() {
// Suppression de toutes les données dans l'ordre des dépendances
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();
}Les tests d'intégration utilisent ces helpers pour un environnement propre.
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);
});
});
});Conclusion
NestJS et Prisma forment une stack backend moderne et productive. Le typage automatique, les migrations déclaratives et l'intégration native avec NestJS permettent de développer des APIs robustes rapidement.
Checklist pour une intégration NestJS + Prisma réussie
- ✅ Module Prisma global pour une injection simplifiée
- ✅ Schéma Prisma avec relations et index optimisés
- ✅ Services typés avec sélection explicite des champs
- ✅ Transactions pour les opérations atomiques
- ✅ Middlewares pour l'audit et le soft delete
- ✅ Cache pour les requêtes fréquentes
- ✅ Tests d'intégration avec base de données isolée
- ✅ Pagination standardisée via extensions
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Cette combinaison tire parti des forces de chaque outil : l'architecture modulaire de NestJS pour la structure et Prisma pour la couche données type-safe. Le résultat est un code maintenable, testable et performant, adapté aux applications d'entreprise.
Tags
Partager
Articles similaires

NestJS : Créer une API REST complète
Guide complet pour créer une API REST professionnelle avec NestJS. Controllers, Services, Modules, validation avec class-validator et gestion des erreurs expliqués.

Questions d'entretien Node.js backend : Guide complet 2026
Les 25 questions d'entretien Node.js backend les plus fréquentes. Event loop, async/await, streams, clustering et performance expliqués avec des réponses détaillées.

NestJS en entretien : Guards, Interceptors et Architecture modulaire
Les questions d'entretien NestJS sur les Guards, Interceptors et l'architecture modulaire, avec des exemples de code concrets et des explications techniques.