Prisma Patterns
Modèles de production et pièges non évidents pour Prisma ORM dans les backends TypeScript.
Vérifiez votre version avant d'appliquer les modèles. La surface d'API de Prisma a évolué à travers les versions majeures :
npx prisma --versionDifférences notables d'API entre les versions :
relationJoinspeut charger les relations via JOIN plutôt que des requêtes séparées, mais peut provoquer une explosion de lignes sur des relations 1:N volumineuses ou desincludeprofonds — évaluez les deux approches- Le modificateur de champ
omitet l'API Client Extensions (prisma.$extends) ont été ajoutés- Nouvelles installations : le package peut s'appeler
prismaau lieu de@prisma/client;PrismaClientpeut nécessiter un adaptateur (ex.@prisma/adapter-pg) ;datasource.urlpeut se trouver dansprisma.config.tsplutôt queschema.prisma- Les commandes CLI (
migrate dev,migrate deploy,generate) sont inchangées entre les versions
Quand activer
- Concevoir ou modifier des modèles et relations de schéma Prisma
- Écrire des requêtes, transactions ou logique de pagination
- Utiliser
updateMany,deleteManyou toute opération groupée - Exécuter ou planifier des migrations de base de données
- Déployer dans des environnements serverless (Vercel, Lambda, Cloudflare Workers)
- Implémenter une suppression douce ou un filtrage multi-tenant par ligne
Concepts de base
Stratégie d'ID
| Stratégie | Quand l'utiliser | Quand l'éviter |
|---|---|---|
@default(cuid()) | Choix par défaut — URL-safe, triable, pas de collisions | Identifiants séquentiels nécessaires pour les systèmes externes |
@default(uuid()) | Interopérabilité requise avec des systèmes non-Prisma | Tables à écriture élevée (les UUID aléatoires fragmentent les index B-tree) |
@default(autoincrement()) | Tables de jointure internes, journaux d'audit | Identifiants exposés publiquement (divulgue le nombre d'enregistrements) |
Valeurs par défaut du schéma
model User {
id String @id @default(cuid())
email String @unique // @unique crée déjà un index — pas besoin de @@index
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // composite pour soft delete + tri
}
- Ajoutez
@@indexsur chaque clé étrangère et colonne utilisée dansWHEREouORDER BY. - Déclarez
deletedAt DateTime?dès le départ lorsque la suppression douce est une exigence prévisible — l'ajouter ultérieurement nécessite une migration sur une table en production. updatedAt @updatedAtest défini automatiquement par Prisma uniquement surupdateetupsert(voir Anti-Modèles pour le piège des mises à jour groupées).
include vs select
include | select | |
|---|---|---|
| Retourne | Tous les champs scalaires + les relations spécifiées | Uniquement les champs spécifiés |
| Quand l'utiliser | Vous avez besoin de la plupart des champs plus une relation | Chemins chauds, grandes tables, éviter le sur-chargement |
| Performance | Peut sur-charger sur les tables larges | Charge utile minimale, plus rapide sur les grands ensembles de données |
| Note Prisma 5 | Utilise JOIN par défaut (relationJoins) | Identique |
// include — toutes les colonnes + relation
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — liste d'autorisation explicite
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
Ne renvoyez jamais d'entités Prisma brutes depuis les réponses API — mappez vers des DTOs de réponse pour contrôler les champs exposés :
// MAUVAIS : fuit passwordHash, deletedAt, champs internes
return await prisma.user.findUniqueOrThrow({ where: { id } });
// BON : mappage DTO explicite
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
Sélection de la forme de transaction
| Situation | Utiliser |
|---|---|
| Opérations indépendantes, sans interdépendance | Forme tableau |
| Une étape ultérieure dépend d'un résultat précédent | Forme interactive |
| Appels externes (email, HTTP) impliqués | En dehors de la transaction complètement |
// Forme tableau — regroupées en un seul aller-retour
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// Forme interactive — utilisez uniquement le client tx, jamais le client prisma externe
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Interdit');
return tx.post.create({ data: { title, authorId: user.id } });
});
Singleton PrismaClient
Chaque instance de PrismaClient ouvre son propre pool de connexions. Instanciez une seule fois.
// lib/prisma.ts
// Option A — initialisation par adaptateur (requise par les nouvelles installations de Prisma)
import { PrismaClient } from '@prisma/client'; // ou le chemin du client généré pour votre configuration
import { PrismaPg } from '@prisma/adapter-pg';
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
}
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// Option B — initialisation directe (anciennes installations, pas d'adaptateur nécessaire)
// import { PrismaClient } from '@prisma/client';
// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
Utilisez l'Option A si votre installation Prisma nécessite un argument adapter dans le constructeur PrismaClient.
Utilisez l'Option B si new PrismaClient() fonctionne sans arguments. Laissez le compilateur vous dire laquelle est correcte.
Le motif globalThis empêche les instances en double lors du rechargement à chaud (Next.js, nodemon, ts-node-dev).
Problème N+1
Charger les relations dans une boucle émet une requête par ligne.
// MAUVAIS : N+1 — une requête supplémentaire par utilisateur
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// BON : une seule requête
const users = await prisma.user.findMany({ include: { posts: true } });
Avec relationJoins de Prisma 5+, la forme include utilise un seul JOIN. Sur de grands ensembles 1:N, cela peut augmenter la taille du jeu de résultats — évaluez les deux approches si la relation peut retourner de nombreuses lignes par parent.
Exemples de code
Pagination par curseur (recommandée pour les flux et grands ensembles de données)
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // tri secondaire empêche une pagination instable sur des horodatages en double
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}
Récupérez limit + 1 et pop — méthode canonique pour détecter hasNextPage sans requête de comptage supplémentaire. Incluez toujours un champ unique (ex. id) comme orderBy secondaire pour éviter une pagination instable lorsque plusieurs lignes partagent le même horodatage. Utilisez la pagination par décalage uniquement lorsque les utilisateurs doivent sauter à des pages arbitraires (tableaux d'administration).
Suppression douce (Soft Delete)
// Filtrez toujours explicitement — ne comptez pas sur le middleware (masque le comportement, difficile à déboguer)
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // restauration
Gestion des erreurs
import { Prisma } from '@prisma/client'; // ou le chemin du client généré pour votre configuration
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('L\'email existe déjà');
if (e.code === 'P2025') throw new NotFoundError('Enregistrement non trouvé');
if (e.code === 'P2003') throw new BadRequestError('L\'enregistrement référencé n\'existe pas');
}
throw e;
}
Codes courants : P2002 violation d'unicité · P2025 non trouvé · P2003 violation de clé étrangère.
Capturez à la limite du service et traduisez en erreurs métier. N'exposez jamais les messages bruts de Prisma aux consommateurs de l'API.
Pool de connexions — Serverless
Intégrez les paramètres de connexion directement dans DATABASE_URL — la concaténation de chaînes échoue si l'URL a déjà des paramètres de requête (ex. ?schema=public) :
# .env — recommandé : intégrez les paramètres dans l'URL
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
# Avec un pooler externe (PgBouncer, Supabase pooler)
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
// Vercel, AWS Lambda et environnements serverless similaires :
// limiter le pool à 1 par instance ; connection_limit et pool_timeout contrôlés via DATABASE_URL
// Configuration par adaptateur (si votre installation Prisma nécessite un adaptateur) :
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// Configuration directe (si votre installation Prisma ne nécessite pas d'adaptateur) :
// const prisma = new PrismaClient();
Anti-Modèles
updateMany retourne un compte, pas des enregistrements
// MAUVAIS : résultat est { count: 2 } — users[0] est undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// BON : capturez d'abord les IDs, puis mettez à jour, puis récupérez uniquement les lignes affectées
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });
Idem pour deleteMany — retourne { count: n }, jamais les lignes supprimées.
La forme interactive de $transaction expire après 5 secondes
// MAUVAIS : appel externe dans la transaction dépasse la valeur par défaut de 5s → "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // appel externe
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// BON : appels externes en dehors de la transaction
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// Augmentez le timeout uniquement lorsque le traitement par lots en a réellement besoin
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev peut réinitialiser la base de données
migrate dev détecte la dérive du schéma et peut demander de réinitialiser la BD, supprimant toutes les données.
# JAMAIS sur un environnement partagé, de staging ou de production
npx prisma migrate dev --name add_column
# Sûr partout sauf en développement local solo
npx prisma migrate deploy
# Vérifiez la dérive sans appliquer
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
Modifier manuellement un fichier de migration casse les déploiements futurs
Prisma vérifie la somme de contrôle de chaque fichier de migration. Le modifier après application provoque une P3006 checksum mismatch sur chaque environnement où l'original a déjà été exécuté. Créez une nouvelle migration à la place.
Les changements de schéma cassants nécessitent une migration en plusieurs étapes
Ajouter NOT NULL à une colonne existante ou renommer une colonne en une seule migration verrouillera la table ou supprimera des données. Utilisez la méthode expand-and-contract :
# Étape 1 : créer la migration localement, puis déployer
npx prisma migrate dev --name add_new_column # local uniquement
npx prisma migrate deploy # staging / production
// Étape 2 : rattraper les données (exécuter dans un script ou un job de migration, pas dans le shell)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# Étape 3 : créer la migration de contrainte NOT NULL localement, puis déployer
npx prisma migrate dev --name make_new_column_required # local uniquement
npx prisma migrate deploy # staging / production
@updatedAt ne se déclenche pas sur updateMany
@updatedAt est défini automatiquement uniquement sur update et upsert. Les écritures groupées le laissent obsolète.
// MAUVAIS : updatedAt reste à son ancienne valeur
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// BON
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
Soft delete + findUniqueOrThrow divulgue les enregistrements supprimés
findUniqueOrThrow lève P2025 uniquement lorsque la ligne n'existe pas dans la BD. Les lignes supprimées (soft-deleted) existent toujours et sont retournées sans erreur.
findUniqueOrThrow nécessite un champ de contrainte d'unicité dans where — ajouter deletedAt: null à côté de id casse le type car { id, deletedAt } n'est pas une contrainte d'unicité composée. Utilisez findFirstOrThrow à la place.
// MAUVAIS : retourne un utilisateur soft-deleted
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// MAUVAIS : erreur de type Prisma — { id, deletedAt } n'est pas une contrainte d'unicité
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// BON : findFirstOrThrow supporte des conditions where arbitraires
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
deleteMany sans where supprime toutes les lignes
// MAUVAIS : efface silencieusement la table
await prisma.post.deleteMany();
// BON
await prisma.post.deleteMany({ where: { authorId: userId } });
Bonnes pratiques
| Règle | Raison |
|---|---|
migrate deploy dans CI/CD, migrate dev uniquement en local | migrate dev peut réinitialiser la BD en cas de dérive |
| Mapper les entités vers des DTOs de réponse | Empêche la fuite de champs internes |
Ajouter @@index sur chaque clé étrangère et colonne de filtre/tri | Les index manquants sont la cause la plus fréquente de performances lentes de Prisma en production |
Utiliser findFirstOrThrow au lieu de findUniqueOrThrow lors du filtrage par soft delete | findUniqueOrThrow nécessite une contrainte d'unicité et ne peut pas combiner avec deletedAt: null |
| Capturer les erreurs Prisma et les traduire en erreurs applicatives | Empêche les fuites d'informations internes, permet un traitement cohérent des erreurs |
| Utiliser la pagination par curseur pour les listes paginées | Plus efficace que la pagination par décalage sur les grands ensembles de données, stable même lorsque des lignes sont insérées ou supprimées |
| Limiter le pool de connexions à 1 en serverless | Chaque fonction peut s'exécuter sur une instance séparée, plus de connexions que les limites de la base de données |