Prisma-Muster
Produktionsmuster und nicht offensichtliche Fallstricke für Prisma ORM in TypeScript-Backends.
Prüfe deine Version, bevor du Muster anwendest. Die Prisma-API-Oberfläche hat sich über mehrere Hauptversionen hinweg weiterentwickelt:
npx prisma --versionBemerkenswerte API-Unterschiede zwischen den Versionen:
relationJoinskann Beziehungen über JOIN anstelle separater Abfragen laden, kann aber bei großen 1:N-Beziehungen oder tiefemincludezu einer Zeilenexplosion führen — bewerte beide Ansätze- Der Feldmodifikator
omitund die Client Extensions APIprisma.$extendswurden hinzugefügt- Neuere Installationen: Das Paket kann
prismaanstelle von@prisma/clientheißen;PrismaClienterfordert möglicherweise einen Treiber-Adapter (z. B.@prisma/adapter-pg);datasource.urlkann sich inprisma.config.tsanstelle vonschema.prismabefinden- CLI-Befehle (
migrate dev,migrate deploy,generate) sind versionsübergreifend unverändert
Wann aktivieren
- Entwerfen oder Ändern von Prisma-Schema-Modellen und -Beziehungen
- Schreiben von Abfragen, Transaktionen oder Paginierungslogik
- Verwenden von
updateMany,deleteManyoder anderen Bulk-Operationen - Ausführen oder Planen von Datenbankmigrationen
- Bereitstellen in serverlosen Umgebungen (Vercel, Lambda, Cloudflare Workers)
- Implementieren von Soft-Deletion oder Multi-Tenant-Zeilenfilterung
Kernkonzepte
ID-Strategie
| Strategie | Verwendung, wenn | Vermeiden, wenn |
|---|---|---|
@default(cuid()) | Standardwahl — URL-sicher, sortierbar, keine Kollisionen | Fortlaufende IDs für externe Systeme benötigt werden |
@default(uuid()) | Interoperabilität mit Nicht-Prisma-Systemen erforderlich | Tabellen mit vielen Schreibvorgängen (zufällige UUIDs fragmentieren B-Tree-Indizes) |
@default(autoincrement()) | Interne Joins-Tabellen, Audit-Logs | Öffentlich sichtbare IDs (gibt die Anzahl der Datensätze preis) |
Schema-Standardwerte
model User {
id String @id @default(cuid())
email String @unique // @unique erstellt bereits einen Index — kein @@index nötig
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // zusammengesetzt für Soft-Delete + Sortierabfragen
}
- Füge
@@indexauf jedem Fremdschlüssel und jeder Spalte hinzu, die inWHEREoderORDER BYverwendet wird. - Deklariere
deletedAt DateTime?frühzeitig, wenn Soft-Deletion absehbar ist — ein späteres Hinzufügen erfordert eine Migration auf einer Live-Tabelle. updatedAt @updatedAtwird von Prisma nur beiupdateundupsertautomatisch gesetzt (siehe Anti-Patterns für die Bulk-Update-Falle).
include vs. select
include | select | |
|---|---|---|
| Rückgabe | Alle skalaren Felder + angegebene Beziehungen | Nur angegebene Felder |
| Verwenden, wenn | Die meisten Felder plus eine Beziehung benötigt werden | Kritische Pfade, große Tabellen, Vermeidung von Überabruf |
| Leistung | Kann bei breiten Tabellen zu Überabruf führen | Minimale Nutzlast, schneller bei großen Datensätzen |
| Prisma 5 Hinweis | Verwendet standardmäßig JOIN (relationJoins) | Gleich |
// include — alle Spalten + Beziehung
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — explizite Erlaubnisliste
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
Gib niemals rohe Prisma-Entitäten aus API-Antworten zurück — bilde sie auf Antwort-DTOs ab, um exponierte Felder zu kontrollieren:
// SCHLECHT: gibt passwordHash, deletedAt, interne Felder preis
return await prisma.user.findUniqueOrThrow({ where: { id } });
// GUT: explizites DTO-Mapping
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
Transaktionsformular-Auswahl
| Situation | Verwende |
|---|---|
| Unabhängige Operationen, keine Abhängigkeiten | Array-Form |
| Späterer Schritt hängt von früherem Ergebnis ab | Interaktive Form |
| Externe Aufrufe (E-Mail, HTTP) beteiligt | Vollständig außerhalb der Transaktion |
// Array-Form — gebündelt in einem Roundtrip
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// Interaktive Form — verwende nur den tx-Client, niemals den äußeren prisma-Client
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Forbidden');
return tx.post.create({ data: { title, authorId: user.id } });
});
PrismaClient-Singleton
Jede PrismaClient-Instanz öffnet ihren eigenen Verbindungspool. Einmal instanziieren.
// lib/prisma.ts
// Option A — adapterbasierte Initialisierung (erforderlich für neuere Prisma-Installationen)
import { PrismaClient } from '@prisma/client'; // oder den generierten Client-Pfad für dein Setup
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 — direkte Initialisierung (ältere Installationen, kein Adapter nötig)
// import { PrismaClient } from '@prisma/client';
// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
Verwende Option A, wenn deine Prisma-Installation ein adapter-Argument im PrismaClient-Konstruktor erfordert.
Verwende Option B, wenn new PrismaClient() ohne Argumente funktioniert. Lass den Compiler entscheiden, was korrekt ist.
Das globalThis-Muster verhindert doppelte Instanzen während des Hot Reloads (Next.js, nodemon, ts-node-dev).
N+1-Problem
Das Laden von Beziehungen innerhalb einer Schleife führt zu einer Abfrage pro Zeile.
// SCHLECHT: N+1 — eine zusätzliche Abfrage pro Benutzer
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// GUT: einzelne Abfrage
const users = await prisma.user.findMany({ include: { posts: true } });
Mit relationJoins in Prisma 5+ verwendet die include-Form einen einzelnen JOIN. Bei großen 1:N-Mengen kann dies die Ergebnismenge vergrößern — bewerte beide Ansätze, wenn die Beziehung viele Zeilen pro Elternteil zurückgeben kann.
Codebeispiele
Cursor-Paginierung (bevorzugt für Feeds und große Datensätze)
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // sekundäre Sortierung verhindert instabile Paginierung bei doppelten Zeitstempeln
],
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 };
}
Hole limit + 1 und entferne das letzte Element — kanonischer Weg, um hasNextPage ohne eine zusätzliche COUNT-Abfrage zu erkennen. Füge immer ein eindeutiges Feld (z. B. id) als sekundäres orderBy hinzu, um instabile Paginierung zu verhindern, wenn mehrere Zeilen denselben Zeitstempel haben. Verwende Offset-Paginierung nur, wenn Benutzer zu beliebigen Seiten springen müssen (Admin-Tabellen).
Soft Delete
// Immer explizit filtern — sich nicht auf Middleware verlassen (verbirgt das Verhalten, schwer zu debuggen)
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 } }); // wiederherstellen
Fehlerbehandlung
import { Prisma } from '@prisma/client'; // oder den generierten Client-Pfad für dein Setup
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('Email already exists');
if (e.code === 'P2025') throw new NotFoundError('Record not found');
if (e.code === 'P2003') throw new BadRequestError('Referenced record does not exist');
}
throw e;
}
Häufige Codes: P2002 Unique-Verletzung · P2025 nicht gefunden · P2003 Fremdschlüsselverletzung.
Fange an der Servicegrenze ab und übersetze in domänenspezifische Fehler. Gib niemals rohe Prisma-Nachrichten an API-Konsumenten weiter.
Verbindungspool — Serverlos
Bette Verbindungsparameter direkt in DATABASE_URL ein — Zeichenkettenverkettung bricht, wenn die URL bereits Abfrageparameter hat (z. B. ?schema=public):
# .env — bevorzugt: Parameter in die URL einbetten
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
# Mit einem externen Pooler (PgBouncer, Supabase-Pooler)
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
// Vercel, AWS Lambda und ähnliche serverlose Laufzeiten:
// Pool auf 1 pro Instanz begrenzen; connection_limit und pool_timeover über DATABASE_URL gesteuert
// Adapterbasierte Einrichtung (falls deine Prisma-Installation einen Adapter erfordert):
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// Direkte Einrichtung (falls deine Prisma-Installation keinen Adapter erfordert):
// const prisma = new PrismaClient();
Anti-Patterns
updateMany gibt einen Zähler zurück, keine Datensätze
// SCHLECHT: Ergebnis ist { count: 2 } — users[0] ist undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// GUT: zuerst IDs erfassen, dann aktualisieren, dann nur die betroffenen Zeilen abrufen
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 } } });
Gleiches gilt für deleteMany — gibt { count: n } zurück, niemals die gelöschten Zeilen.
$transaction interaktive Form läuft nach 5 Sekunden ab
// SCHLECHT: externer Aufruf innerhalb der Transaktion überschreitet das Standard-Timeout von 5s → "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // externer Aufruf
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// GUT: externe Aufrufe außerhalb der Transaktion
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// Erhöhe das Timeout nur, wenn die Bulk-Verarbeitung es wirklich erfordert
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev kann die Datenbank zurücksetzen
migrate dev erkennt Schema-Abweichungen und kann dazu auffordern, die DB zurückzusetzen, wobei alle Daten gelöscht werden.
# NIEMALS auf gemeinsam genutzten Entwicklungs-, Staging- oder Produktionsumgebungen
npx prisma migrate dev --name add_column
# Sicher überall außer bei lokaler Solo-Entwicklung
npx prisma migrate deploy
# Überprüfe Abweichungen ohne Anwendung
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
Manuelles Bearbeiten einer Migrationsdatei bricht zukünftige Bereitstellungen
Prisma prüft die Prüfsumme jeder Migrationsdatei. Das Bearbeiten nach der Anwendung verursacht P3006 checksum mismatch in jeder Umgebung, in der die ursprüngliche bereits ausgeführt wurde. Erstelle stattdessen eine neue Migration.
Schemaänderungen, die bestehende Daten brechen, erfordern mehrstufige Migrationen
Das Hinzufügen von NOT NULL zu einer vorhandenen Spalte oder das Umbenennen einer Spalte in einer Migration sperrt die Tabelle oder löscht Daten. Verwende Expand-and-Contract:
# Schritt 1: Migration lokal erstellen, dann bereitstellen
npx prisma migrate dev --name add_new_column # nur lokal
npx prisma migrate deploy # Staging / Produktion
// Schritt 2: Daten nachträglich befüllen (in einem Skript oder Migrations-Job ausführen, nicht in der Shell)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# Schritt 3: NOT NULL-Constraint-Migration lokal erstellen, dann bereitstellen
npx prisma migrate dev --name make_new_column_required # nur lokal
npx prisma migrate deploy # Staging / Produktion
@updatedAt wird bei updateMany nicht ausgelöst
@updatedAt wird nur bei update und upsert automatisch gesetzt. Bulk-Schreibvorgänge lassen es veraltet.
// SCHLECHT: updatedAt bleibt beim alten Wert
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// GUT
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
Soft Delete + findUniqueOrThrow gibt gelöschte Datensätze preis
findUniqueOrThrow wirft P2025 nur, wenn die Zeile nicht in der DB existiert. Soft-gelöschte Zeilen existieren noch und werden ohne Fehler zurückgegeben.
findUniqueOrThrow erfordert ein Feld mit einer Unique-Constraint in where — das Hinzufügen von deletedAt: null neben id bricht den Typ, da { id, deletedAt } keine zusammengesetzte Unique-Constraint ist. Verwende stattdessen findFirstOrThrow.
// SCHLECHT: gibt einen soft-gelöschten Benutzer zurück
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// SCHLECHT: Prisma-Typfehler — { id, deletedAt } ist keine Unique-Constraint
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// GUT: findFirstOrThrow unterstützt beliebige WHERE-Bedingungen
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
deleteMany ohne where löscht jede Zeile
// SCHLECHT: löscht stillschweigend die gesamte Tabelle
await prisma.post.deleteMany();
// GUT
await prisma.post.deleteMany({ where: { authorId: userId } });
Best Practices
| Regel | Grund |
|---|---|
migrate deploy in CI/CD, migrate dev nur lokal | migrate dev kann die DB bei Abweichungen zurücksetzen |
| Entitäten auf Antwort-DTOs abbilden | Verhindert das Preisgeben interner Felder |
| Cursor-Paginierung für Feeds und große Datensätze | Offset-Paginierung wird bei großen Versätzen langsam (teures OFFSET) |
Immer deletedAt: null in where für Soft-Delete angeben | findUniqueOrThrow filtert nicht automatisch nach deletedAt |
| Verbindungspool auf 1 in serverlosen Umgebungen begrenzen | Verhindert Verbindungserschöpfung bei gleichzeitigen Aufrufen |
Verwende findFirstOrThrow anstelle von findUniqueOrThrow für Soft-Delete | findUniqueOrThrow erlaubt keine zusätzlichen Nicht-Constraint-Felder in where |
updateMany + @updatedAt manuell setzen | @updatedAt wird bei Bulk-Operationen nicht automatisch aktualisiert |
| Transaktionen kurz halten (< 5s) | Das Standard-Timeout der interaktiven Transaktion beträgt 5 Sekunden |
Fremdschlüssel immer indexieren (@@index) | Verhindert sequentielle Scans bei Joins und kaskadierenden Löschvorgängen |