Prisma 7 a changé la donne pour les projets Node.js qui ont besoin d’un ORM typé et productif. La réécriture du moteur en TypeScript pur, livrée dans la version 7.0 en novembre 2025 et stabilisée par les patches successifs jusqu’à la 7.7.0 en avril 2026, élimine la dépendance binaire Rust qui posait des problèmes sur les hébergeurs minimalistes et qui gonflait le node_modules. Ce tutoriel monte une couche de persistance complète pour une API NestJS 11 : schéma déclaratif, migrations versionnées, service injectable, transactions, soft-delete, et seed reproductible.
📍 Article principal : NestJS 11 pour startup : architecture production 2026. Cette brique persistance est consommée par les modules métier décrits dans le guide de référence.
Prérequis
- Application NestJS 11 fonctionnelle — voir le tutoriel monorepo Nx si le squelette n’existe pas encore
- PostgreSQL 16 ou 17 accessible — local via Docker ou managé chez un fournisseur
- Node.js 22 LTS et pnpm 9
- Connaissance basique du SQL et des migrations
- Temps estimé : 75 minutes
Étape 1 — Lancer PostgreSQL en local avec Docker Compose
Le moyen le plus rapide d’avoir une base PostgreSQL pour développer reste Docker Compose. Un seul fichier décrit le service, ses volumes et son port exposé. Cette base éphémère se reset en une commande, ce qui est précieux quand on itère sur le schéma. Pour la production, le service est remplacé par une instance managée via Coolify ou un fournisseur, mais l’API d’accès reste identique grâce à PostgreSQL.
# docker-compose.dev.yml
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: acme
POSTGRES_PASSWORD: acme_dev
POSTGRES_DB: acme
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
Lancer la base avec docker compose -f docker-compose.dev.yml up -d. Vérifier que le service est sain avec docker compose ps — le statut doit afficher running. Une connexion de test depuis l’hôte avec psql postgresql://acme:acme_dev@localhost:5432/acme -c '\\l' liste les bases et confirme que la connectivité réseau fonctionne. Si le port 5432 est déjà occupé par un PostgreSQL système, mapper le service sur 5433 et adapter l’URL en conséquence.
Étape 2 — Installer Prisma et initialiser le projet
L’installation de Prisma se fait en deux paquets : prisma en dépendance de développement pour le CLI et la génération du client, et @prisma/client en dépendance de production qui contient le runtime. Cette séparation permet à l’image Docker de production de ne pas embarquer le CLI, qui pèse plusieurs dizaines de mégaoctets.
cd apps/api
pnpm add @prisma/client@7.7
pnpm add -D prisma@7.7
npx prisma init --datasource-provider postgresql
La commande prisma init crée prisma/schema.prisma avec un squelette minimal et un fichier .env qui définit DATABASE_URL. Régler immédiatement cette URL sur postgresql://acme:acme_dev@localhost:5432/acme?schema=public et déplacer le fichier .env à la racine du monorepo si on travaille en mode workspace. Le paramètre ?schema=public est optionnel mais explicite : il évite les surprises quand on travaillera plus tard avec plusieurs schémas.
Étape 3 — Définir le schéma déclaratif
Le fichier schema.prisma est la source de vérité du modèle de données. Toute modification déclenche une migration et une régénération du client typé. Le langage est volontairement minimaliste : pas de logique, pas de fonctions, juste une description des tables, des colonnes et des relations. Cette austérité est une force — le diff git d’une évolution de schéma est immédiatement lisible en revue.
generator client { provider = "prisma-client-js" }
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
role Role @default(MEMBER)
orders Order[]
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Decimal @db.Decimal(12, 2)
currency String @db.Char(3)
createdAt DateTime @default(now())
@@index([userId])
}
enum Role { OWNER ADMIN MEMBER }
Trois choix méritent commentaire. cuid() génère des identifiants courts triables par ordre de création, plus pratiques que des UUID v4 pour les index et les URLs ; pour un nouveau projet en 2026 il est recommandé de basculer sur cuid(2) (la première version est marquée dépréciée pour des raisons de sécurité). Le champ deletedAt active le pattern soft-delete qui marque les enregistrements supprimés sans les effacer physiquement, indispensable pour les audits réglementaires. La directive @db.Decimal(12, 2) force PostgreSQL à utiliser le type numeric(12,2) exact pour les montants — jamais float, qui introduirait des erreurs d’arrondi en comptabilité.
Étape 4 — Créer la première migration
Prisma sépare deux flux : migrate dev en développement, qui génère un fichier de migration et applique immédiatement le SQL ; migrate deploy en production, qui ne fait qu’appliquer les migrations versionnées sans en créer. Cette séparation évite que le CI essaie d’inventer une migration à partir d’un état dérivé. Toutes les migrations vivent dans prisma/migrations/, sont commitées dans git, et constituent un historique reproductible.
npx prisma migrate dev --name init
La commande génère un fichier prisma/migrations/<timestamp>_init/migration.sql qui contient le SQL exact appliqué à la base, et régénère node_modules/@prisma/client/ avec les types TypeScript correspondant au schéma. Toute modification ultérieure du schéma se traite par npx prisma migrate dev --name <changement>. En production, le pipeline déploie les migrations avec npx prisma migrate deploy avant de démarrer l’application — ordre critique, car démarrer l’API avant la migration provoque des erreurs column does not exist.
Étape 5 — Exposer un PrismaService injectable
Pour intégrer proprement Prisma dans NestJS, la convention est de créer un service singleton qui étend PrismaClient et qui gère le cycle de vie. La méthode onModuleInit ouvre la connexion au démarrage et la méthode onModuleDestroy la ferme au shutdown. Ce singleton est ensuite injecté dans tous les services métier qui ont besoin d’accéder à la base, ce qui évite les pools orphelins.
// apps/api/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
Le service est ensuite enregistré dans un PrismaModule marqué @Global() pour éviter d’avoir à l’importer dans chaque module métier. Cette pratique se justifie ici parce que la persistance est transverse, mais elle ne devrait pas devenir l’habitude — la majorité des modules doivent rester explicites sur leurs dépendances. Côté UsersService, l’injection se fait par constructeur classique et les méthodes appellent this.prisma.user.findUnique({...}) avec auto-complétion complète sur les champs.
Étape 6 — Implémenter le soft-delete via extension
Le soft-delete consiste à intercepter les requêtes delete et findMany pour traduire la suppression en mise à jour de deletedAt et pour filtrer les enregistrements supprimés des lectures. Prisma 7 expose les extensions pour ce type de logique transverse, qui remplacent les anciens middleware ($use) dépréciés en 4.16, retirés en 6.14, et totalement absents de Prisma 7.
// apps/api/src/prisma/soft-delete.extension.ts
import { Prisma } from '@prisma/client';
export const softDelete = Prisma.defineExtension({
query: {
user: {
async delete({ args, query }) {
return query({ ...args, data: { deletedAt: new Date() } } as any);
},
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
},
},
});
L’extension s’applique au client lors de l’instanciation : new PrismaClient().$extends(softDelete). À partir de ce moment, un userService.delete(id) ne supprime plus la ligne mais positionne deletedAt. Pour récupérer les enregistrements supprimés à des fins d’audit, contourner l’extension avec prisma.$queryRaw ou exposer un endpoint admin qui passe where: { deletedAt: { not: null } } explicitement. Le tutoriel consacré à la cryptographie pratique couvre les exigences de chiffrement au repos qui s’ajoutent dans les contextes réglementés.
Étape 7 — Transactions et savepoints
Une opération métier qui modifie plusieurs tables doit être atomique. Prisma expose deux modes de transaction : la transaction interactive via prisma.$transaction(async tx => ...) qui permet d’exécuter du code arbitraire dans la transaction, et la transaction séquentielle via prisma.$transaction([op1, op2, op3]) qui exécute une liste d’opérations sans logique entre elles. La 7.5 a ajouté le support des savepoints, ce qui permet d’imbriquer des transactions et de les annuler partiellement.
await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: { userId, amount, currency: 'XOF' } });
await tx.user.update({ where: { id: userId }, data: { lastOrderAt: new Date() } });
await this.queue.add('order-confirmation', { orderId: order.id });
});
Cette transaction crée la commande, met à jour l’utilisateur, et enfile un job de confirmation. Si l’enfilage en queue jette une exception, les deux modifications de base sont annulées par le rollback automatique. À noter : l’opération sur la queue n’est pas dans la transaction PostgreSQL — Redis et Postgres sont deux systèmes distincts. Pour une vraie cohérence, le pattern outbox écrit le message dans une table outbox dans la même transaction, et un worker BullMQ relit cette table pour publier les messages. Le tutoriel queues BullMQ détaille ce pattern.
Étape 8 — Seed reproductible et tests d’intégration
Un projet sérieux a besoin d’un seed déterministe pour peupler la base de données de développement et pour les tests d’intégration. Prisma supporte officiellement un fichier prisma/seed.ts exécuté par npx prisma db seed. Le pattern recommandé utilise upsert pour rester idempotent : exécuter le seed deux fois ne crée pas de doublons.
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import argon2 from 'argon2';
const prisma = new PrismaClient();
async function main() {
await prisma.user.upsert({
where: { email: 'owner@acme.test' },
update: {},
create: {
email: 'owner@acme.test',
password: await argon2.hash('TempPassword123!'),
role: 'OWNER',
},
});
}
main().finally(() => prisma.$disconnect());
Le hashing du mot de passe avec argon2 dès le seed évite que des mots de passe en clair traînent en environnement local. Pour les tests d’intégration, la stratégie classique consiste à lancer une base PostgreSQL Docker éphémère par fichier de test, à appliquer les migrations, à exécuter le seed, à lancer le test, et à détruire le conteneur. Les outils testcontainers ou @databases/pg-test automatisent ce workflow.
Étape 9 — Index, EXPLAIN et performances
Une fois le schéma stabilisé, l’étape suivante consiste à vérifier que les requêtes les plus fréquentes utilisent bien des index. La directive @@index([userId]) dans le schéma déclare un index B-tree classique, mais PostgreSQL accepte aussi les index partiels et les index sur expressions, qu’on déclare en SQL pur dans une migration manuelle. La commande EXPLAIN ANALYZE reste l’outil de référence pour mesurer le coût réel d’une requête sous charge proche de la production.
EXPLAIN ANALYZE
SELECT * FROM "Order" WHERE "userId" = 'cl12345' AND "createdAt" > NOW() - INTERVAL '30 days';
La sortie indique si PostgreSQL a utilisé un Index Scan (bon signe) ou un Seq Scan (à corriger sur grosse table). Pour les requêtes qui combinent plusieurs colonnes, un index composite @@index([userId, createdAt]) accélère drastiquement le filtrage par utilisateur sur une période. Sur une table de 10 millions de lignes, le passage du Seq Scan à l’Index Scan transforme une requête de 1,2 seconde en une requête de 8 millisecondes.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
P1001 Can't reach database |
URL incorrecte ou Postgres non démarré | Vérifier docker compose ps et DATABASE_URL |
P3009 migrations failed |
Migration partielle bloquée | prisma migrate resolve --rolled-back |
| Type Prisma non à jour | generate non rejoué après modif schéma |
npx prisma generate |
| Connexions saturées en prod | Pool client par requête | Singleton PrismaService, pool size dans URL |
| Soft-delete contourné | findFirst sans extension |
Étendre tous les modèles, pas juste user |
L’erreur la plus coûteuse en production est la dernière de cette liste : un findFirst qui ne passe pas par l’extension de soft-delete renvoie des enregistrements supprimés, ce qui peut exposer des données qu’on croyait effacées. La parade consiste à étendre le client pour tous les modèles concernés et à interdire par lint l’usage de prisma.$queryRaw en dehors d’une liste blanche de fichiers.
FAQ
Faut-il préférer Drizzle à Prisma ?
Le choix dépend du contrôle voulu sur le SQL généré. Drizzle expose un query builder typé proche du SQL natif, ce qui permet d’écrire des jointures complexes sans surprise. Prisma reste plus productif pour des modèles classiques. Le comparatif détaillé est dans Drizzle vs Prisma 2026.
Comment gérer plusieurs schémas PostgreSQL ?
Le paramètre ?schema=public,billing dans DATABASE_URL active le multi-schémas. Chaque modèle peut alors préciser @@schema("billing"). Utile pour isoler les domaines dans une grande base mutualisée.
Quelle taille de pool choisir ?
La règle empirique est nombre de cores × 2 + 1 pour une charge classique. Sur un VPS 2 vCPU avec une instance NestJS, viser connection_limit=5. Au-delà, on sature le serveur PostgreSQL plutôt qu’on n’accélère.
Que faire des migrations très longues ?
Toute migration qui peut bloquer une table critique plus de 100 ms doit passer en mode expand and contract : ajouter la colonne nullable, déployer le code qui écrit dans les deux colonnes, backfiller en background avec un job BullMQ, déployer le code qui ne lit que la nouvelle, supprimer l’ancienne dans une migration séparée.
Tutoriels associés
- 📍 Article principal : NestJS 11 pour startup
- Initialiser un monorepo Nx 22 avec NestJS 11
- Queues asynchrones avec BullMQ et Redis
- Node.js et bases de données avec Prisma