# 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. - Published: 2026-02-09 - Updated: 2026-03-28 - Author: SharpSkill - Tags: nestjs, prisma, nodejs, typescript, backend - Reading time: 14 min --- 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. > **Pourquoi cette combinaison** > > 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é. ```bash # 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 postgresql ``` Cette commande crée un dossier `prisma/` avec le fichier `schema.prisma` et un fichier `.env` pour les variables d'environnement. ```typescript // src/prisma/prisma.service.ts 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é. ```typescript // src/prisma/prisma.module.ts 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. ```typescript // src/app.module.ts 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. > **Module global vs imports explicites** > > 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). ```prisma // prisma/schema.prisma 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. ```bash # 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 studio ``` ## Implé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. ```typescript // src/users/users.service.ts 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; @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 { // 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 { 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 { // 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 { 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. ## 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. ```typescript // src/posts/posts.service.ts 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é. ```typescript // src/orders/orders.service.ts 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. > **Timeout des transactions** > > 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. ```typescript // src/prisma/prisma.service.ts (version avec middleware) 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. ```typescript // src/common/prisma-extensions.ts import { Prisma, PrismaClient } from '@prisma/client'; // Extension pour la pagination standardisée export const paginationExtension = Prisma.defineExtension({ model: { $allModels: { async paginate( this: T, args: Prisma.Exact> & { page?: number; limit?: number; } ): Promise<{ data: Prisma.Result; 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. ```typescript // src/posts/posts.service.ts (avec cache) 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é. ```typescript // test/helpers/prisma-test.helper.ts 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>` 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. ```typescript // test/users.e2e-spec.ts 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 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. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/node-nestjs/nestjs-prisma-stack-moderne-backend