Prisma
TL;DR — Prisma génère un client typé à partir d'un
schema.prisma. Dans Nest, le pattern standard est unPrismaService extends PrismaClientprovider singleton injecté partout. Atouts : typage end-to-end, migrations claires (prisma migrate dev/deploy), API simple. Pièges senior :$transactionséquentielle vs interactive callback, error handling viaPrismaClientKnownRequestErrorcodes (P2002,P2025, etc.), pagination cursor vs offset, et l'ancien middleware deprecated (v5+) remplacé par les extensions.
🧠 Mental model
schema.prisma ──► prisma generate ──► @prisma/client (typed)
│ │
│ ▼
▼ PrismaService extends PrismaClient
prisma migrate dev/deploy │
│ ├── prisma.user.findUnique({ where: { id } })
▼ ├── prisma.$transaction([op1, op2]) ← séquentielle
DB schema sync └── prisma.$transaction(async (tx) => {...}) ← interactiveAnalogie — Prisma c'est un ORM "généré" : le client est prisma.user.findMany() typé exactement comme ton modèle. Pas de classes d'entités à décorer — tu décris ton schéma dans un DSL et le client en découle. C'est l'inverse de TypeORM où tu décris des classes décorées.
🛠️ Code minimal
// schema.prisma
generator client { provider = "prisma-client-js" }
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
@@index([createdAt])
@@map("users")
}
model Post {
id String @id @default(uuid()) @db.Uuid
title String
body String
authorId String @db.Uuid
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([authorId, createdAt])
@@map("posts")
}// PrismaService — singleton
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient<{ log: ({ emit: 'event'; level: 'query' | 'warn' | 'error' })[] }>
implements OnModuleInit, OnModuleDestroy {
private readonly log = new Logger('Prisma');
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'event', level: 'warn' },
{ emit: 'event', level: 'error' },
],
});
// Typé grâce au generic ci-dessus — plus de `as any`.
this.$on('warn', (e) => this.log.warn(e.message));
this.$on('error', (e) => this.log.error(e.message));
// En dev seulement : log des requêtes lentes (> 50 ms).
if (process.env.NODE_ENV !== 'production') {
this.$on('query', (e) => {
if (e.duration > 50) this.log.warn(`SLOW ${e.duration}ms ${e.query}`);
});
}
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}// Service métier
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// Find avec relations
findOne(id: string) {
return this.prisma.user.findUnique({
where: { id },
include: { posts: { take: 5, orderBy: { createdAt: 'desc' } } },
});
}
// findOrThrow → P2025 si absent
findOrThrow(id: string) {
return this.prisma.user.findUniqueOrThrow({ where: { id } });
}
// Pagination cursor (recommandé)
list(cursor?: string, take = 20) {
return this.prisma.user.findMany({
take: take + 1, // +1 pour détecter hasNext
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { id: 'asc' },
});
}
// Transaction interactive
async transferPosts(fromId: string, toId: string) {
return this.prisma.$transaction(async (tx) => {
const from = await tx.user.findUniqueOrThrow({ where: { id: fromId } });
const to = await tx.user.findUniqueOrThrow({ where: { id: toId } });
await tx.post.updateMany({ where: { authorId: from.id }, data: { authorId: to.id } });
}, { isolationLevel: 'Serializable', timeout: 5000 });
}
}// Error handling
import { Prisma } from '@prisma/client';
try {
await this.prisma.user.create({ data: { email, name } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictException('email_already_used');
if (e.code === 'P2025') throw new NotFoundException('record_not_found');
}
throw e;
}🎯 Patterns courants
PrismaServicesingleton +@Global()— un seul provider, exporté globalement. Pas besoin d'@InjectRepositorypartout.- Error codes mapping —
P2002(unique violation),P2003(FK violation),P2025(not found on update/delete),P2034(txn conflict). Wrapper avec unPrismaErrorInterceptorou un Exception Filter dédié. - Pagination cursor —
take: n + 1pour détecterhasNext, slice et retournern. Plus rapide queskip/takepour gros datasets (pas de scan O(skip)). - Extensions (v5+) —
prisma.$extends({ query: { user: { findMany: ({ args, query }) => { args.where = { ...args.where, tenantId } ; return query(args); } } } })pour multi-tenant ou soft-delete transparent. selectstrict —select: { id, email, name }au lieu deincludequand on n'a pas besoin de l'entité complète. Plus rapide, payload plus petit.createMany/updateMany— batch ops sans relations. Pour bulk avec relations, looper dans$transaction.- Computed fields via extensions —
prisma.$extends({ result: { user: { fullName: { compute: (u) =>${u.firstName} ${u.lastName}} } } }). Évite de polluer le service avec du mapping. - Multi-tenant strict — extension qui injecte
tenantIddans toutes les queries viaquery.user.$allOperations. Sécurité par défaut. - Soft delete transparent — extension qui filtre
deletedAt: nullsur tous lesfindManyet transformedeleteenupdate({ data: { deletedAt: new Date() } }). $queryRawpour SQL natif — type-safe via tagged template literal :prisma.$queryRaw<User[]>\SELECT * FROM users WHERE email = ${email}``. Pour queries SQL avancées que Prisma ne couvre pas.
🔄 Versions — Prisma
| Version | Notes |
|---|---|
| Prisma 3 | API stable. Pas de findUniqueOrThrow (utiliser rejectOnNotFound deprecated). |
| Prisma 4 | findUniqueOrThrow, findFirstOrThrow. rejectOnNotFound deprecated. |
| Prisma 5 | Middleware deprecated — utiliser client extensions. $queryRaw typage amélioré. JSON nulls handling stricte. |
| Prisma 6 | Improved relation joins (joined fetch sous le capot, pas N+1). omit global. Préview features remontées en stable. |
| Nest 9+ | Compat Prisma 4/5/6. Pas de package @nestjs/prisma officiel — pattern manuel. |
| Nest 11 | Compat Prisma 5/6. Node 20+. |
Migration v4 → v5 — supprimer toute utilisation de prisma.$use(middleware), migrer vers prisma.$extends. JSON null strict : Prisma.JsonNull vs Prisma.DbNull.
⚠️ Pitfalls
$transactionséquentielle ≠ interactive —$transaction([op1, op2])exécute les ops en parallèle (sous une seule txn) et ne permet pas de raisonner sur les résultats intermédiaires.$transaction(async (tx) => ...)est interactive et bloque. Choisir le bon.txconfondu avecprisma— utiliserthis.prisma.userdans le callback interactive ⇒ ne participe pas à la transaction. Toujourstx.user.create(...).- N+1 sur
include— Prisma v6 résout via JOIN (relationJoins), mais en v5 c'est unIN (...)séparé. Pour gros graphes :select+ raw query ou$queryRawjoin manuel. P2025masqué — unupdatesur id inexistant lance P2025 (404 en HTTP). Confusion avec une "update qui n'a rien matché". Map explicitement.- Migrations en dev sans reviewer —
prisma migrate devgénère des migrations destructives (drop colonne). Toujours--create-onlypuis review SQL, puismigrate deployen prod. - Connection pool trop petit — par défaut Prisma utilise
num_physical_cpus * 2 + 1. Avec PgBouncer en mode transaction, désactiver le prepared statements (?pgbouncer=true). - JSON column updates —
prisma.user.update({ data: { metadata: { foo: 'bar' } } })remplace le JSON entier. Pour merge, lire puis écrire, ou utiliser$queryRawavecjsonb_set. enableTracingperf hit — l'instrumentation OpenTelemetry de Prisma a un coût non négligeable. Mesurer.- Mass-assignment via
data: dto— Prisma accepte tout champ valide du modèle. Un DTO mal validé peut écriretenantIdouisAdmin. Toujours valider via class-validator +whitelist: true. $transactiontimeout par défaut 5s — pour les ops longues (batch import), augmenter via{ timeout: 30_000, maxWait: 5_000 }. SinonP2028(transaction timeout).onDelete: Cascadecôté Prisma vs DB — Prisma émet leON DELETE CASCADEdans la migration mais l'enforcement est côté DB. Vérifier que la migration est bien appliquée en prod.uuid()natif vs DB —@default(uuid())génère côté Prisma (Node). Pour utilisergen_random_uuid()Postgres natif,@default(dbgenerated("gen_random_uuid()")).generatenon rejoué après changement schema — un dev qui pull et oublieprisma generatevoit des types obsolètes. Mettre danspostinstalldupackage.json.- JSON typing —
Prisma.JsonValueest très large. Pour typer fort, utiliser un type augmenté via@Prisma.Json<MyType>()(v5+) ou caster côté service.
🧪 Testing
// Unitaire — mock PrismaService
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
const prismaMock: DeepMockProxy<PrismaService> = mockDeep<PrismaService>();
const mod = await Test.createTestingModule({
providers: [UsersService, { provide: PrismaService, useValue: prismaMock }],
}).compile();
const svc = mod.get(UsersService);
prismaMock.user.findUnique.mockResolvedValue({ id: '1', email: '[email protected]' } as any);
expect(await svc.findOne('1')).toBeDefined();// Intégration — DB éphémère via testcontainers
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start();
process.env.DATABASE_URL = container.getConnectionUri();
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
});
// Reset entre tests — TRUNCATE plutôt que migrate reset
beforeEach(async () => {
const prisma = new PrismaClient();
await prisma.$executeRaw`TRUNCATE TABLE users, posts RESTART IDENTITY CASCADE`;
});Astuce — pour les tests rapides, jest-mock-extended + mockDeep<PrismaService>() couvre 80%. Pour les requêtes complexes (cursor pagination, relations), un vrai Postgres via testcontainers.
🎬 Cas d'usage concrets
SaaS RH — ATS multi-tenant
Qui — Éditeur français d'ATS (Applicant Tracking System) servant 800 PME, chaque tenant ayant ses propres pipelines de recrutement, étapes et templates d'email. Problème — Le schéma évolue toutes les deux semaines (nouvelles colonnes sur Candidate, nouvelles relations sur Job), et l'équipe veut éviter les SQL DDL à la main pendant les démos clients. Le typage doit suivre instantanément côté front-end via tRPC. Comment — prisma migrate dev régénère le client à chaque pull request, et le tenantId est ajouté via une extension Prisma globale ($extends) pour filtrer chaque requête.
const prisma = new PrismaClient().$extends({
query: {
$allModels: {
async $allOperations({ args, query, operation }) {
const tenantId = ClsService.get('tenantId');
if (!tenantId) throw new Error('Missing tenant');
if (['findMany', 'findFirst', 'count', 'update', 'delete'].includes(operation)) {
args.where = { ...args.where, tenantId };
}
if (operation === 'create') args.data = { ...args.data, tenantId };
return query(args);
},
},
},
});Gains — Plus aucune fuite de tenant en code review (35 incidents historiques), schéma versionné dans Git, regénération du client en < 3 s.
FinTech — Comptabilité de type Pennylane
Qui — Plateforme de comptabilité française pour TPE qui réconcilie automatiquement banque + factures + écritures. Problème — Les écritures comptables doivent être immuables une fois validées. Les relations entre Invoice, JournalEntry et BankTransaction forment un graphe que Prisma typé permet de naviguer sans any. Comment — On fait des include profonds typés et on bloque les updates via extend sur JournalEntry.posted = true.
async exportFEC(period: { from: Date; to: Date }) {
return prisma.journalEntry.findMany({
where: { postedAt: { gte: period.from, lte: period.to }, posted: true },
include: {
lines: { include: { account: true } },
invoice: { select: { number: true, supplierId: true } },
},
orderBy: { postedAt: 'asc' },
});
}Gains — Export FEC conforme à la DGFiP en 4 s pour 50 K écritures, typage strict empêche l'oubli d'une ligne au débit.
Immobilier — CRM agence
Qui — Réseau de 120 agences immobilières partageant un CRM mutualisé. Problème — Les biens (Property) ont des relations many-to-many avec les contacts (Lead), via une table de jointure portant des champs (date de visite, score d'intérêt). Comment — Prisma exprime nativement ces relations explicites via un modèle pivot et include imbriqué.
async getPropertyDashboard(propertyId: string) {
return prisma.property.findUnique({
where: { id: propertyId },
include: {
visits: {
include: { lead: { select: { firstName: true, lastName: true, score: true } } },
orderBy: { scheduledAt: 'desc' },
take: 20,
},
photos: { orderBy: { order: 'asc' } },
agent: true,
},
});
}Gains — Code descriptif (les relations sont du JSON), suppression des QueryBuilders, onboarding des juniors en < 2 jours.
🛠️ Exemple end-to-end
Contexte — Le CRM immobilier veut permettre à un agent de planifier une visite : on vérifie le créneau, on crée le Visit, on bumpe le score du lead, et on envoie un événement de domaine consommé par un worker email. L'opération est atomique et idempotente (clef d'unicité sur propertyId + leadId + scheduledAt).
// prisma/schema.prisma
model Property {
id String @id @default(cuid())
ref String @unique
address String
price Decimal @db.Decimal(12, 2)
agentId String
agent Agent @relation(fields: [agentId], references: [id])
visits Visit[]
}
model Lead {
id String @id @default(cuid())
firstName String
lastName String
email String @unique
score Int @default(0)
visits Visit[]
}
model Agent {
id String @id @default(cuid())
email String @unique
properties Property[]
}
model Visit {
id String @id @default(cuid())
propertyId String
leadId String
scheduledAt DateTime
status VisitStatus @default(SCHEDULED)
notes String?
property Property @relation(fields: [propertyId], references: [id])
lead Lead @relation(fields: [leadId], references: [id])
@@unique([propertyId, leadId, scheduledAt])
@@index([scheduledAt])
}
enum VisitStatus { SCHEDULED CANCELLED DONE NO_SHOW }// src/visit/visit.service.ts
@Injectable()
export class VisitService {
constructor(
private prisma: PrismaService,
private events: EventBus,
) {}
async scheduleVisit(input: ScheduleVisitDto) {
const start = new Date(input.scheduledAt);
const end = new Date(start.getTime() + 60 * 60 * 1000);
const visit = await this.prisma.$transaction(async (tx) => {
// 1. Verify slot is free for this property
const conflict = await tx.visit.findFirst({
where: {
propertyId: input.propertyId,
status: { in: ['SCHEDULED', 'DONE'] },
scheduledAt: { gte: start, lt: end },
},
});
if (conflict) throw new ConflictException('SLOT_TAKEN');
// 2. Verify lead exists and not blacklisted
const lead = await tx.lead.findUniqueOrThrow({
where: { id: input.leadId },
});
if (lead.score < 0) throw new ForbiddenException('LEAD_BLACKLISTED');
// 3. Create visit (idempotent via @@unique)
const created = await tx.visit.upsert({
where: {
propertyId_leadId_scheduledAt: {
propertyId: input.propertyId,
leadId: input.leadId,
scheduledAt: start,
},
},
create: {
propertyId: input.propertyId,
leadId: input.leadId,
scheduledAt: start,
notes: input.notes,
},
update: { notes: input.notes },
include: {
property: { include: { agent: true } },
lead: true,
},
});
// 4. Bump lead score (+10 for booking a visit)
await tx.lead.update({
where: { id: input.leadId },
data: { score: { increment: 10 } },
});
return created;
}, { isolationLevel: 'Serializable', timeout: 5000 });
await this.events.publish(new VisitScheduledEvent({
visitId: visit.id,
leadEmail: visit.lead.email,
agentEmail: visit.property.agent.email,
address: visit.property.address,
scheduledAt: visit.scheduledAt,
}));
return visit;
}
}// src/visit/visit.controller.ts
@Controller('visits')
@UseGuards(AgentGuard)
export class VisitController {
constructor(private svc: VisitService, private prisma: PrismaService) {}
@Post()
schedule(@Body() dto: ScheduleVisitDto) {
return this.svc.scheduleVisit(dto);
}
@Get('agenda/:agentId')
agenda(@Param('agentId') agentId: string, @Query('week') week: string) {
const from = startOfWeek(parseISO(week));
const to = endOfWeek(parseISO(week));
return this.prisma.visit.findMany({
where: { property: { agentId }, scheduledAt: { gte: from, lte: to } },
include: { property: true, lead: true },
orderBy: { scheduledAt: 'asc' },
});
}
}L'@@unique rend l'upsert idempotent (rejouer le webhook front ne crée pas de doublon), l'isolation Serializable interdit deux agents de poser le même créneau, et l'événement n'est publié qu'après commit réussi.
🔁 Quand utiliser / éviter
Utiliser Prisma :
- Nouveau projet, équipe TS — meilleur DX du marché.
- Schéma stable, relations classiques.
- Besoin de migrations versionnées et auditées.
Éviter Prisma :
- Requêtes SQL très avancées (CTE récursives, window complexes) — Prisma rame, préférer Drizzle/Kysely en complément ou
$queryRaw. - Multi-tenant avec isolation forte (Row Level Security) — la friction est grande, considérer Drizzle ou Knex.
- Schémas très dynamiques (no-code, low-code) — le client doit être régénéré et redéployé.
- Limite connexions stricte sans PgBouncer — Prisma a un pool, mais lourd par instance.
🧰 Exemples avancés
Extension multi-tenant globale
const tenantExtension = Prisma.defineExtension({
name: 'tenant-scope',
query: {
$allModels: {
async $allOperations({ args, query, operation, model }) {
const tenantId = txCtx.get()?.tenantId; // depuis ALS
if (!tenantId) return query(args);
// Pour read ops, ajouter where tenantId
if (['findUnique', 'findMany', 'findFirst', 'count'].includes(operation)) {
args.where = { ...args.where, tenantId };
}
// Pour write ops, forcer tenantId
if (['create'].includes(operation)) {
args.data = { ...args.data, tenantId };
}
if (['update', 'delete'].includes(operation)) {
args.where = { ...args.where, tenantId };
}
return query(args);
},
},
},
});
const prisma = new PrismaClient().$extends(tenantExtension);Soft delete transparent
const softDelete = Prisma.defineExtension((client) =>
client.$extends({
name: 'soft-delete',
query: {
$allModels: {
// Filtrer les lignes supprimées sur TOUTES les lectures.
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
async count({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
// Transformer delete → update. On passe par `client` (capturé dans la closure),
// pas par `this` qui n'est PAS le client dans une extension.
async delete({ model, args }) {
return (client as any)[model].update({
where: args.where,
data: { deletedAt: new Date() },
});
},
async deleteMany({ model, args }) {
return (client as any)[model].updateMany({
where: args.where,
data: { deletedAt: new Date() },
});
},
},
},
}),
);Pièges du soft-delete transparent — (1)
findUniquen'accepte que des champs uniques danswhere, donc on ne peut pas y injecterdeletedAt: null: préférerfindFirst. (2) Les contraintes@uniquene tiennent plus compte de la suppression logique — unWHERE deleted_at IS NULL(via migration manuelle) ou colonne générée. (3) Les FK etonDelete: Cascadene se déclenchent pas sur un soft-delete : c'est à vous de propager. Beaucoup d'équipes seniors préfèrent une table d'archive (*_archived) au soft-delete global, justement pour éviter ces invariants brisés.
$queryRaw typé
// SQL natif avec type safety
const users = await this.prisma.$queryRaw<Array<{ id: string; email: string; postCount: bigint }>>`
SELECT u.id, u.email, COUNT(p.id)::int as "postCount"
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE u.tenant_id = ${tenantId}
GROUP BY u.id
ORDER BY "postCount" DESC
LIMIT 10
`;Error mapping interceptor
@Injectable()
export class PrismaErrorInterceptor implements NestInterceptor {
intercept(_ctx: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
catchError((err) => {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') return throwError(() => new ConflictException('duplicate'));
if (err.code === 'P2025') return throwError(() => new NotFoundException('not_found'));
}
return throwError(() => err);
}),
);
}
}🏗️ Connection pool & déploiement — le raisonnement senior
C'est le sujet qui casse Prisma en prod, et il est presque toujours sous-estimé. Le modèle mental : chaque instance Prisma ouvre son propre pool de connexions TCP vers Postgres. Postgres a une limite dure (max_connections, souvent 100). Faites le calcul avant de scaler.
connexions_totales = nb_instances_app × connection_limit_par_instance
(+ pools des migrations, des workers, du monitoring…)Défaut Prisma : connection_limit = num_physical_cpus × 2 + 1. Sur un pod 4 vCPU c'est 9 connexions ; à 20 pods → 180 connexions > 100 → P2024 (timeout d'attente d'une connexion dans le pool) ou Postgres qui refuse (too many clients). On contrôle ça dans l'URL :
DATABASE_URL="postgresql://u:p@host:5432/db?connection_limit=5&pool_timeout=10&connect_timeout=5"| Décision | Quand | Pourquoi |
|---|---|---|
Baisser connection_limit par pod | Beaucoup de réplicas | réplicas × limit doit rester < max_connections × 0.8 |
PgBouncer en mode transaction | Serverless / autoscaling agressif | Mutualise les connexions ; exige ?pgbouncer=true (désactive les prepared statements, sinon prepared statement "s0" already exists) |
| Prisma Accelerate / Data Proxy | Lambda, edge, cold starts | Pool externe géré, connexions HTTP au lieu de TCP |
| Pool séparé pour les workers BullMQ | Jobs longs | Ne pas affamer le pool des requêtes HTTP synchrones avec un batch import |
Serverless (Lambda/Cloud Run scale-to-zero) — le piège classique : chaque invocation froide instancie un PrismaClient → explosion de connexions. Soit connection_limit=1 + PgBouncer, soit Accelerate, soit réutiliser le client entre invocations (globalThis.prisma). Ne jamais new PrismaClient() par requête.
Diagnostic en prod — P2024 = pool saturé (augmenter le pool OU réduire la durée des transactions interactives, qui tiennent une connexion tout le temps qu'elles vivent). Une transaction interactive de 3 s sous charge = une connexion bloquée 3 s. C'est souvent la vraie cause d'un pool « trop petit » : il n'est pas trop petit, vos transactions sont trop longues. Mesurez pg_stat_activity (state = 'idle in transaction').
🔭 Observability
Trois leviers, du moins au plus coûteux :
- Logs
queryevent (déjà câblés dans lePrismaServiceci-dessus) — capter les requêtes lentes en dev. Ne pas activerlevel: queryen prod (volume + fuite de PII dans les params). - Métriques —
await prisma.$metrics.json()expose pool (connexions ouvertes/idle/en attente), compteurs de requêtes, histogrammes de durée. À scraper vers Prometheus. La métriqueprisma_pool_connections_busyqui plafonne = signal avant leP2024. - Tracing OpenTelemetry —
@prisma/instrumentationajoute un span par requête, corrélable au span HTTP NestJS. Coût CPU non négligeable (le pitfall #8) : échantillonner (parentbased_traceidratio, ex. 10 %) plutôt que tracer 100 % en prod.
// main.ts — tracing échantillonné
import { PrismaInstrumentation } from '@prisma/instrumentation';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
registerInstrumentations({ instrumentations: [new PrismaInstrumentation()] });Règle senior : un slow query log + les métriques de pool couvrent 90 % des incidents. Le tracing distribué, on l'allume quand on traque une latence qui traverse plusieurs services.
🤖 Persister des agents IA avec Prisma (intégration stack)
Quand NestJS sert un agent Claude (boucle tool-use, streaming SSE — voir le module agents), Prisma est la couche de durabilité qui rend l'agent rejouable, auditable et facturable. Le schéma encode la machine à états d'un « run » :
model AgentRun {
id String @id @default(cuid())
tenantId String
userId String
model String // "claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"
status RunStatus @default(PENDING)
inputHash String // hash du prompt+params → idempotence
inputTokens Int @default(0)
outputTokens Int @default(0)
costMicros Int @default(0) // coût en micro-USD, jamais de float pour l'argent
error String?
createdAt DateTime @default(now())
finishedAt DateTime?
messages AgentMessage[]
toolCalls ToolCall[]
@@unique([tenantId, inputHash]) // idempotence : même requête → même run
@@index([tenantId, status, createdAt])
}
model AgentMessage {
id String @id @default(cuid())
runId String
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
role String // "user" | "assistant" | "tool_result"
content Json // blocs Anthropic (text, tool_use, tool_result)
seq Int
createdAt DateTime @default(now())
@@index([runId, seq])
}
model ToolCall {
id String @id @default(cuid())
runId String
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
name String
input Json
output Json?
status ToolStatus @default(PENDING) // pending|running|done|error
startedAt DateTime @default(now())
endedAt DateTime?
@@index([runId])
}
enum RunStatus { PENDING STREAMING DONE ERROR CANCELLED }
enum ToolStatus { PENDING RUNNING DONE ERROR }Idempotence keyée sur inputHash — un retry réseau ou un double clic ne doit pas relancer (ni refacturer) une génération. On upsert sur @@unique([tenantId, inputHash]) :
@Injectable()
export class AgentRunRepo {
constructor(private prisma: PrismaService) {}
// Réserve un run ; si déjà existant et terminé, on le rejoue sans rappeler Claude.
async claim(tenantId: string, userId: string, inputHash: string, model: string) {
return this.prisma.agentRun.upsert({
where: { tenantId_inputHash: { tenantId, inputHash } },
create: { tenantId, userId, inputHash, model, status: 'PENDING' },
update: {}, // no-op : on récupère le run existant
});
}
}Persistance pendant le streaming — ne pas écrire un row par token (write amplification). On accumule les deltas en mémoire et on flush à la fin du message_stop, ou par chunks rAF-coalescés côté serveur. Le statut passe PENDING → STREAMING → DONE. Sur déconnexion client (l'AbortController côté front coupe le SSE), le handler NestJS observe l'abort, appelle stream.controller.abort() côté SDK Anthropic, et persiste le partiel avec status: CANCELLED — l'agent reste auditable même interrompu.
Job BullMQ cost-aware — pour un agent long, on le sort de la requête HTTP vers un worker. Le job porte le runId ; à chaque retry BullMQ on relit le AgentRun :
- si
status = DONE→ on no-op (idempotent, pas de double facturation) ; - on accumule
costMicrosvia{ increment }plutôt qu'un read-modify-write (atomique, pas de race entre workers) ; - un
costMicrosqui dépasse un budget tenant → on coupe la boucle tool-use (cost-guard) et on passeERRORavecerror: 'BUDGET_EXCEEDED'.
async finalize(runId: string, usage: { input: number; output: number; costMicros: number }) {
await this.prisma.agentRun.update({
where: { id: runId },
data: {
status: 'DONE',
finishedAt: new Date(),
inputTokens: usage.input,
outputTokens: usage.output,
costMicros: { increment: usage.costMicros }, // atomique
},
});
}Le client Anthropic lui-même reste DI'd via
forRootAsync(jamaisnew Anthropic()dans un field) — voir le module agents. Prisma ne stocke jamais la clé API ; elle vient de la config injectée. La règle d'or argent :costMicrosest unInt(micro-USD), jamais unFloat— pas d'erreurs d'arrondi sur la facturation. Pour des montants plus larges,Decimal @db.Decimal(voir l'exemple FinTech).
🏋️ Exercices
Cursor pagination propre (implement)Objectif — exposer
GET /users?cursor=&take=qui renvoie{ items, nextCursor }sans jamais sur-fetcher. Indice —take: take + 1, siitems.length > takealorsnextCursor = items[take].idet onslice(0, take); sinonnextCursor = null. Vérifier queorderByest stable (sur une colonne unique, sinon le curseur saute des lignes).Exception filter Prisma → HTTP exhaustif (production-grade)Objectif — mapper
P2002 → 409,P2025 → 404,P2003 → 409,P2034/P2024 → 503 (retryable), le reste →500masqué (pas de leak de message SQL au client). Indice —@Catch(Prisma.PrismaClientKnownRequestError); liree.meta?.targetpour nommer le champ en conflit ; logger l'erreur complète côté serveur, renvoyer un code stable côté client. Tester chaque branche.Soft-delete + unicité qui tient (production-grade)Objectif — soft-delete sur
Usertout en autorisant la réinscription d'un email déjà « supprimé ». Indice — extension du fichier + migration SQL manuelleCREATE UNIQUE INDEX users_email_active ON users(email) WHERE deleted_at IS NULL;. Écrire un test : delete, puis recréer le même email → doit passer ; deux actifs → doitP2002.Casser le pool, puis le réparer (break it then fix it)Objectif — provoquer un
P2024puis l'éliminer. Indice — lancer 50 requêtes concurrentes ouvrant chacune une transaction interactive avec unawait sleep(2000)dedans,connection_limit=5. ObserverP2024. Fix : sortir le travail lent hors de la transaction (ne tenir la connexion que pour les écritures), augmenter le pool, ou batcher. Mesurer$metrics.json()avant/après.Idempotence d'agent IA sous retry (break it then fix it)Objectif — garantir qu'un double
POST /agent/run(même prompt) ne facture qu'une génération. Indice —@@unique([tenantId, inputHash])+upsert claim. Test : deux appels concurrents avec le mêmeinputHash→ un seul row, un seul appel Claude. Forcer la race avecPromise.all; sousSerializablele second doit attraperP2034et rejouer le run existant, pas en créer un autre.Anti-N+1 mesuré (optimize)Objectif — prouver chiffres à l'appui que
relationJoins(Prisma 6) bat le fetch en deux temps sur un grapheUser → Post → Comment. Indice — activer le logquery, comparer le nombre de requêtes SQL émises avec/sansrelationLoadStrategy: 'join', sur 1000 users. Attention au coût d'un JOIN qui duplique les colonnes parent (parfois deux requêtes restent plus rapides — d'où le tradeoff, pas de dogme).
🎤 En entretien
Q : $transaction([...]) vs $transaction(async tx => …) — différence et quand utiliser chacune ? La forme tableau (séquentielle) exécute une liste d'opérations dans une seule transaction, sans logique entre elles : rapide, pas de round-trips intermédiaires, mais on ne peut pas brancher sur un résultat. La forme callback (interactive) permet if/await entre les requêtes mais tient une connexion ouverte toute sa durée — risque de saturer le pool. Règle : interactive seulement quand une décision dépend d'une lecture ; sinon batch séquentiel ou, mieux, une seule requête atomique (update … { increment }, upsert).
Q : Pourquoi Prisma sature un pool Postgres en prod alors qu'il « marchait en dev » ? En dev une instance, en prod N réplicas × connection_limit chacun → on dépasse max_connections. Aggravé par les transactions interactives longues (une connexion bloquée par transaction vivante) et le serverless (un client par cold start). Fix : dimensionner connection_limit en fonction des réplicas, PgBouncer en mode transaction (?pgbouncer=true), raccourcir les transactions, et ne jamais instancier PrismaClient par requête.
Q : Middleware $use est deprecated en v5 — qu'est-ce qui le remplace et pourquoi c'est mieux ? Les client extensions ($extends). Avantages : typées (le client étendu propage les types des result/model extensions), composables, et scopables (on peut dériver un client étendu par requête, ex. injecter le tenantId du contexte ALS) plutôt qu'un middleware global mutable. Les middlewares étaient non typés et s'empilaient dans un ordre fragile.
Q : Comment garantis-tu l'idempotence et le contrôle de coût quand NestJS orchestre un agent Claude qui écrit en base ? Une clé d'idempotence (@@unique sur tenantId + hash(input)) avec upsert claim : un retry rejoue le run existant au lieu de rappeler — et refacturer — le modèle. Le coût s'accumule en Int micro-USD via { increment } (atomique, pas de race entre workers), un cost-guard coupe la boucle tool-use au-delà d'un budget tenant, et l'AbortController client → stream.abort() serveur persiste le partiel en CANCELLED. Tout passe par un PrismaService et un client Anthropic DI'd, jamais instanciés à la main.
🔗 Liens
- Prisma docs
- Prisma error reference
- Prisma extensions
- jest-mock-extended
- Prisma + PgBouncer
- Voir
04-transactions.mdpour transaction propagation avec ALS.