Was macht Prisma Patterns?
Produktionsmuster und nicht offensichtliche Fallstricke für Prisma ORM in TypeScript-Backends.
Überprüfen Sie Ihre Version, bevor Sie Muster anwenden. Die Prisma-API-Oberfläche hat sich über die großen Versionen hinweg weiterentwickelt:
npx prisma --versionBemerkenswerte API-Unterschiede zwischen den Versionen:
relationJoinskann Beziehungen über JOIN statt separater Abfragen laden, kann aber bei großen 1:N-Beziehungen oder tiefemincludezu einer Zeilenexplosion führen – benchmarken Sie beide AnsätzeomitFeldmodifikator und dieprisma.$extendsClient Extensions API wurden hinzugefügt- Neuere Installationen: Das Paket kann
prismaanstelle von@prisma/clientheißen;PrismaClienterfordert möglicherweise einen Treiberadapter (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
- Verwendung von
updateMany,deleteManyoder einer anderen Bulk-Operation - Ausführen oder Planen von Datenbankmigrationen
- Bereitstellen in serverlosen Umgebungen (Vercel, Lambda, Cloudflare Workers)
- Implementieren von Soft Delete oder Multi-Tenant-Zeilenfilterung
Kernkonzepte
ID-Strategie
| Strategie | Verwenden, wenn | Vermeiden, wenn |
|---|---|---|
@default(cuid()) | Standardwahl – URL-sicher, sortierbar, keine Kollisionen | Sequenzielle IDs für externe Systeme benötigt |
@default(uuid()) | Interoperabilität mit Nicht-Prisma-Systemen erforderlich | Tabellen mit vielen Schreibvorgängen (zufällige UUIDs fragmentieren B-Tree-Indizes) |
@default(autoincrement()) | Interne Join-Tabellen, Audit-Logs | Öffentlich zugängliche IDs (gibt Datensatzanzahl 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ügen Sie
@@indexfür jeden Fremdschlüssel und jede Spalte hinzu, die inWHEREoderORDER BYverwendet wird. - Deklarieren Sie
deletedAt DateTime?von Anfang an, wenn Soft Delete absehbar ist – das spätere Hinzufügen erfordert eine Migration auf einer Live-Tabelle. updatedAt @updatedAtwird von Prisma automatisch nur beiupdateundupsertgesetzt (siehe Anti-Patterns für die Bulk-Update-Falle).
include vs. select
include | select | |
|---|---|---|
| Rückgabe | Alle Skalar-Felder + angegebene Beziehungen | Nur angegebene Felder |
| Verwenden, wenn | Sie die meisten Felder plus eine Beziehung benötigen | Heiße Pfade, große Tabellen, Überabruf vermeiden |
| 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 Positivliste
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
Geben Sie niemals rohe Prisma-Entitäten aus API-Antworten zurück – mappen Sie auf Antwort-DTOs, um die exponierten 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 };
Auswahl der Transaktionsform
| Situation | Verwenden |
|---|---|
| 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 | Außerhalb der gesamten Transaktion |
// Array-Form – in einem Roundtrip gebündelt
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// Interaktive Form – nur tx-Client verwenden, 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 bei neueren Prisma-Installationen)
import { PrismaClient } from '@prisma/client'; // oder der generierte Client-Pfad für Ihr 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({ ... });
Verwenden Sie Option A, wenn Ihre Prisma-Installation ein adapter-Argument im PrismaClient-Konstruktor erfordert.
Verwenden Sie Option B, wenn new PrismaClient() ohne Argumente funktioniert. Lassen Sie den Compiler entscheiden, was richtig 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 Prisma 5+ relationJoins verwendet die include-Form einen einzelnen JOIN. Bei großen 1:N-Mengen kann dies die Ergebnisgröße erhöhen – benchmarken Sie beide Ansätze, wenn die Beziehung viele Zeilen pro übergeordnetem Element 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 };
}
limit + 1 abrufen und das letzte Element entfernen – kanonischer Weg, um hasNextPage ohne eine zusätzliche Count-Abfrage zu erkennen. Fügen Sie immer ein eindeutiges Feld (z.B. id) als sekundäres orderBy ein, um instabile Paginierung zu verhindern, wenn mehrere Zeilen denselben Zeitstempel haben. Verwenden Sie Offset-Paginierung nur, wenn Benutzer zu beliebigen Seiten springen müssen (Admin-Tabellen).
Soft Delete
// Immer explizit filtern – nicht auf Middleware verlassen (verbirgt 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 der generierte Client-Pfad für Ihr Setup
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('E-Mail existiert bereits');
if (e.code === 'P2025') throw new NotFoundError('Datensatz nicht gefunden');
if (e.code === 'P2003') throw new BadRequestError('Referenzierter Datensatz existiert nicht');
}
throw e;
}
Häufige Codes: P2002 Unique-Verletzung · P2025 nicht gefunden · P2003 Fremdschlüsselverletzung.
Fangen Sie Fehler an der Service-Grenze ab und übersetzen Sie sie in Domain-Fehler. Geben Sie niemals rohe Prisma-Nachrichten an API-Konsumenten weiter.
Verbindungspool – Serverlos
Betten Sie Verbindungsparameter direkt in DATABASE_URL ein – Zeichenkettenverkettung bricht, wenn die URL bereits Abfrageparameter enthält (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 externem 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_timeout werden über DATABASE_URL gesteuert
// Adapterbasiertes Setup (wenn Ihre 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 }),
});
// Direktes Setup (wenn Ihre Prisma-Installation keinen Adapter erfordert):
// const prisma = new PrismaClient();
Anti-Patterns
updateMany gibt eine Anzahl 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 bricht nach 5 Sekunden ab
// SCHLECHT: externer Aufruf innerhalb der Transaktion überschreitet den Standard 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 } });
// Nur Timeout erhöhen, 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 zum Zurücksetzen der DB auffordern, 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
# Abweichung prüfen, ohne anzuwenden
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
Manuelles Bearbeiten einer Migrationsdatei 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. Erstellen Sie stattdessen eine neue Migration.
Breaking Schema-Änderungen erfordern mehrstufige Migration
Das Hinzufügen von NOT NULL zu einer vorhandenen Spalte oder das Umbenennen einer Spalte in einer einzigen Migration sperrt die Tabelle oder löscht Daten. Verwenden Sie 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 füllen (in einem Skript oder Migrationsjob ausführen, nicht in der Shell)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# Schritt 3: Migration mit NOT NULL-Constraint 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 automatisch nur bei update und upsert gesetzt. Bulk-Schreibvorgänge lassen es veraltet.
// SCHLECHT: updatedAt bleibt auf seinem 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 eindeutiges Constraint-Feld in where – das Hinzufügen von deletedAt: null neben id bricht den Typ, da { id, deletedAt } kein zusammengesetztes Unique-Constraint ist. Verwenden Sie stattdessen findFirstOrThrow.
// SCHLECHT: gibt soft-gelöschten Benutzer zurück
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// SCHLECHT: Prisma-Typfehler – { id, deletedAt } ist kein 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 die Tabelle stillschweigend
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 mappen | Verhindert das Preisgeben interner Felder |
take + 1 für Cursor-Paginierung | Erkennt hasNextPage ohne extra Count-Abfrage |
| Soft-Delete-Filter explizit in jeder Abfrage angeben | Middleware verbirgt das Verhalten und erschwert das Debuggen |
| Externe Aufrufe aus interaktiven Transaktionen auslagern | Vermeidet 5-Sekunden-Timeout-Fehler |
createdAt als @default(now()) für Default-Werte setzen | createdAt wird bei update nicht durch @updatedAt verwaltet, es sei denn, createdAt @updatedAt ist gesetzt (was nicht sinnvoll ist); createdAt sollte nur @default(now()) sein |
Unique-Feld als sekundäres orderBy hinzufügen | Verhindert instabile Paginierung bei doppelten Zeitstempeln |
Prisma-Version in package.json dokumentieren | API-Änderungen zwischen Versionen verfolgen |
findFirstOrThrow statt findUniqueOrThrow für Soft Delete | findUniqueOrThrow erfordert einen zusammengesetzten Unique-Key; findFirstOrThrow unterstützt beliebige Where-Bedingungen |
Zusammenfassung der Versionen
| Version | Wichtige Änderungen |
|---|---|
| Prisma 5+ | Standard-JOINs (relationJoins); omit-Modifikator; Client Extensions |
| Prisma 6+ | Adapterbasierte Initialisierung (@prisma/adapter-*); prisma.config.ts; Paket kann prisma statt @prisma/client heißen |
So überprüfen Sie Ihre Prisma-Version:
npx prisma --version
So überprüfen Sie, ob Ihre Installation einen Treiberadapter benötigt:
// Wenn der folgende Code einen Fehler wirft, benötigen Sie einen Adapter:
new PrismaClient();
// Wenn nicht (ältere Installation), ist die direkte Initialisierung in Ordnung.
Befolgen Sie diese Muster, um häufige Fallstricke zu vermeiden und Produktionsbereitschaft für Prisma in TypeScript-Backends zu gewährleisten.