Skip to content

Prisma

TL;DR — Prisma génère un client typé à partir d'un schema.prisma. Dans Nest, le pattern standard est un PrismaService extends PrismaClient provider singleton injecté partout. Atouts : typage end-to-end, migrations claires (prisma migrate dev/deploy), API simple. Pièges senior : $transaction séquentielle vs interactive callback, error handling via PrismaClientKnownRequestError codes (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) => {...}) ← interactive

Analogie — 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

prisma
// 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")
}
ts
// 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(); }
}
ts
// 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 });
  }
}
ts
// 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

  1. PrismaService singleton + @Global() — un seul provider, exporté globalement. Pas besoin d'@InjectRepository partout.
  2. Error codes mappingP2002 (unique violation), P2003 (FK violation), P2025 (not found on update/delete), P2034 (txn conflict). Wrapper avec un PrismaErrorInterceptor ou un Exception Filter dédié.
  3. Pagination cursortake: n + 1 pour détecter hasNext, slice et retourner n. Plus rapide que skip/take pour gros datasets (pas de scan O(skip)).
  4. Extensions (v5+)prisma.$extends({ query: { user: { findMany: ({ args, query }) => { args.where = { ...args.where, tenantId } ; return query(args); } } } }) pour multi-tenant ou soft-delete transparent.
  5. select strictselect: { id, email, name } au lieu de include quand on n'a pas besoin de l'entité complète. Plus rapide, payload plus petit.
  6. createMany / updateMany — batch ops sans relations. Pour bulk avec relations, looper dans $transaction.
  7. Computed fields via extensionsprisma.$extends({ result: { user: { fullName: { compute: (u) => ${u.firstName} ${u.lastName} } } } }). Évite de polluer le service avec du mapping.
  8. Multi-tenant strict — extension qui injecte tenantId dans toutes les queries via query.user.$allOperations. Sécurité par défaut.
  9. Soft delete transparent — extension qui filtre deletedAt: null sur tous les findMany et transforme delete en update({ data: { deletedAt: new Date() } }).
  10. $queryRaw pour 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

VersionNotes
Prisma 3API stable. Pas de findUniqueOrThrow (utiliser rejectOnNotFound deprecated).
Prisma 4findUniqueOrThrow, findFirstOrThrow. rejectOnNotFound deprecated.
Prisma 5Middleware deprecated — utiliser client extensions. $queryRaw typage amélioré. JSON nulls handling stricte.
Prisma 6Improved 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 11Compat 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

  1. $transaction sé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.
  2. tx confondu avec prisma — utiliser this.prisma.user dans le callback interactive ⇒ ne participe pas à la transaction. Toujours tx.user.create(...).
  3. N+1 sur include — Prisma v6 résout via JOIN (relationJoins), mais en v5 c'est un IN (...) séparé. Pour gros graphes : select + raw query ou $queryRaw join manuel.
  4. P2025 masqué — un update sur id inexistant lance P2025 (404 en HTTP). Confusion avec une "update qui n'a rien matché". Map explicitement.
  5. Migrations en dev sans reviewerprisma migrate dev génère des migrations destructives (drop colonne). Toujours --create-only puis review SQL, puis migrate deploy en prod.
  6. 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).
  7. JSON column updatesprisma.user.update({ data: { metadata: { foo: 'bar' } } }) remplace le JSON entier. Pour merge, lire puis écrire, ou utiliser $queryRaw avec jsonb_set.
  8. enableTracing perf hit — l'instrumentation OpenTelemetry de Prisma a un coût non négligeable. Mesurer.
  9. Mass-assignment via data: dto — Prisma accepte tout champ valide du modèle. Un DTO mal validé peut écrire tenantId ou isAdmin. Toujours valider via class-validator + whitelist: true.
  10. $transaction timeout par défaut 5s — pour les ops longues (batch import), augmenter via { timeout: 30_000, maxWait: 5_000 }. Sinon P2028 (transaction timeout).
  11. onDelete: Cascade côté Prisma vs DB — Prisma émet le ON DELETE CASCADE dans la migration mais l'enforcement est côté DB. Vérifier que la migration est bien appliquée en prod.
  12. uuid() natif vs DB@default(uuid()) génère côté Prisma (Node). Pour utiliser gen_random_uuid() Postgres natif, @default(dbgenerated("gen_random_uuid()")).
  13. generate non rejoué après changement schema — un dev qui pull et oublie prisma generate voit des types obsolètes. Mettre dans postinstall du package.json.
  14. JSON typingPrisma.JsonValue est très large. Pour typer fort, utiliser un type augmenté via @Prisma.Json<MyType>() (v5+) ou caster côté service.

🧪 Testing

ts
// 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();
ts
// 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. Commentprisma 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.

ts
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.

ts
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é.

ts
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
// 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 }
ts
// 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;
  }
}
ts
// 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

ts
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

ts
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) findUnique n'accepte que des champs uniques dans where, donc on ne peut pas y injecter deletedAt: null : préférer findFirst. (2) Les contraintes @unique ne tiennent plus compte de la suppression logique — un email "supprimé" bloque toujours une réinsertion. Solution : index partiel SQL WHERE deleted_at IS NULL (via migration manuelle) ou colonne générée. (3) Les FK et onDelete: Cascade ne 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é

ts
// 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

ts
@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 :

bash
DATABASE_URL="postgresql://u:p@host:5432/db?connection_limit=5&pool_timeout=10&connect_timeout=5"
DécisionQuandPourquoi
Baisser connection_limit par podBeaucoup de réplicasréplicas × limit doit rester < max_connections × 0.8
PgBouncer en mode transactionServerless / autoscaling agressifMutualise les connexions ; exige ?pgbouncer=true (désactive les prepared statements, sinon prepared statement "s0" already exists)
Prisma Accelerate / Data ProxyLambda, edge, cold startsPool externe géré, connexions HTTP au lieu de TCP
Pool séparé pour les workers BullMQJobs longsNe 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 prodP2024 = 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 :

  1. Logs query event (déjà câblés dans le PrismaService ci-dessus) — capter les requêtes lentes en dev. Ne pas activer level: query en prod (volume + fuite de PII dans les params).
  2. Métriquesawait prisma.$metrics.json() expose pool (connexions ouvertes/idle/en attente), compteurs de requêtes, histogrammes de durée. À scraper vers Prometheus. La métrique prisma_pool_connections_busy qui plafonne = signal avant le P2024.
  3. Tracing OpenTelemetry@prisma/instrumentation ajoute 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.
ts
// 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 » :

prisma
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]) :

ts
@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 costMicros via { increment } plutôt qu'un read-modify-write (atomique, pas de race entre workers) ;
  • un costMicros qui dépasse un budget tenant → on coupe la boucle tool-use (cost-guard) et on passe ERROR avec error: 'BUDGET_EXCEEDED'.
ts
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 (jamais new 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 : costMicros est un Int (micro-USD), jamais un Float — pas d'erreurs d'arrondi sur la facturation. Pour des montants plus larges, Decimal @db.Decimal (voir l'exemple FinTech).

🏋️ Exercices

  1. Cursor pagination propre (implement)Objectif — exposer GET /users?cursor=&take= qui renvoie { items, nextCursor } sans jamais sur-fetcher. Indicetake: take + 1, si items.length > take alors nextCursor = items[take].id et on slice(0, take) ; sinon nextCursor = null. Vérifier que orderBy est stable (sur une colonne unique, sinon le curseur saute des lignes).

  2. Exception filter Prisma → HTTP exhaustif (production-grade)Objectif — mapper P2002 → 409, P2025 → 404, P2003 → 409, P2034/P2024 → 503 (retryable), le reste → 500 masqué (pas de leak de message SQL au client). Indice@Catch(Prisma.PrismaClientKnownRequestError) ; lire e.meta?.target pour nommer le champ en conflit ; logger l'erreur complète côté serveur, renvoyer un code stable côté client. Tester chaque branche.

  3. Soft-delete + unicité qui tient (production-grade)Objectif — soft-delete sur User tout en autorisant la réinscription d'un email déjà « supprimé ». Indice — extension du fichier + migration SQL manuelle CREATE 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 → doit P2002.

  4. Casser le pool, puis le réparer (break it then fix it)Objectif — provoquer un P2024 puis l'éliminer. Indice — lancer 50 requêtes concurrentes ouvrant chacune une transaction interactive avec un await sleep(2000) dedans, connection_limit=5. Observer P2024. 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.

  5. 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ême inputHash → un seul row, un seul appel Claude. Forcer la race avec Promise.all ; sous Serializable le second doit attraper P2034 et rejouer le run existant, pas en créer un autre.

  6. Anti-N+1 mesuré (optimize)Objectif — prouver chiffres à l'appui que relationJoins (Prisma 6) bat le fetch en deux temps sur un graphe User → Post → Comment. Indice — activer le log query, comparer le nombre de requêtes SQL émises avec/sans relationLoadStrategy: '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

Bibliothèque tech perso — Achref