ORM mit PostgreSQL beträufeln: Vollständiger Leitfaden
Drizzle ORM nimmt eine einzigartige Position im TypeScript-Datenbank-Ökosystem ein: Es ist das ORM, das eigentlich nur ein getarnter Abfrage-Builder ist, mit Schema-as-Code, der lesbares SQL generiert, und einem Migrationssystem, das Ihre Datenbank wie die Quelle der Wahrheit behandelt. Nach Jahren mit Prisma und TypeORM fühlt sich Drizzle an, als käme man wieder zu reinem SQL zurück – aber mit vollständiger TypeScript-Inferenz und ohne Laufzeitaufwand.
Dieser Leitfaden behandelt alles von der Ersteinrichtung bis hin zu Produktionsmustern und basiert auf einer Codebasis mit über 65 Drizzle-Schemadateien, mandantenfähigen Abfragen und einem Lazy-Proxy-Verbindungsmuster, das sowohl in Next.js- als auch in NestJS-Kontexten zuverlässig funktioniert.
Wichtige Erkenntnisse
- Verwenden Sie einen Lazy-Proxy für die Datenbankverbindung – erstellen Sie niemals eifrige Verbindungen zur Modulladezeit – Lokales PostgreSQL sollte auf einem nicht standardmäßigen Port (5433) ausgeführt werden, um Konflikte mit Systeminstallationen zu vermeiden – Alle Tabellen benötigen
organizationIdfür Mehrmandantenfähigkeit – auf Schemaebene erzwingen – Drizzle-Enum-Vergleiche erfordern eine explizite TypeScript-Umwandlung zur Typeingrenzung – Verwenden Sie niemalssql.raw()– verwenden Sie immer das Vorlagenliteralsqlmit parametrisierten Werten- Beziehungen sind von Fremdschlüsseln getrennt – definieren Sie beide für vollständige Typsicherheit – Transaktionen umschließen mehrstufige Vorgänge; Übergeben Sie das
tx-Objekt an Dienste – Der Schemaindex (schema/index.ts) muss alle Tabellen exportieren, damit Migrationen funktionieren
Installation und Einrichtung
Drizzle erfordert zwei Pakete: das Kern-ORM und den Datenbanktreiber.
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
Für den gesamten Stack inklusive Migrationen und Studio:
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
Das Paket postgres (anders als pg) ist der empfohlene Treiber für Drizzle in modernen TypeScript-Projekten – es unterstützt Verbindungspooling, vorbereitete Anweisungen und verfügt über bessere TypeScript-Typen.
Das Lazy-Proxy-Muster
Die wichtigste Architekturentscheidung bei Drizzle in einem Next.js- oder NestJS-Monorepo besteht darin, niemals eine eifrige Datenbankverbindung herzustellen. Eifrige Verbindungen zur Modulladezeit verursachen Probleme in:
- Next.js: Module werden während des Build-Prozesses importiert, wenn keine DB verfügbar ist
- NestJS: Paketimporte erfolgen, bevor Umgebungsvariablen geladen werden
- Serverlos: Kaltstarts werden durch Verbindungs-Overhead verzögert
Das Lazy-Proxy-Muster löst all diese Probleme:
// packages/db/src/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let _db: ReturnType<typeof drizzle> | null = null;
function getDb() {
if (!_db) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set');
}
const client = postgres(connectionString, {
max: 10, // connection pool size
idle_timeout: 20,
connect_timeout: 10,
});
_db = drizzle(client, { schema });
}
return _db;
}
// Export a Proxy that initializes the connection on first use
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(_, prop) {
return getDb()[prop as keyof ReturnType<typeof drizzle>];
},
});
export * from './schema';
Dieses Muster bedeutet, dass beim Importieren von @ecosire/db niemals eine Datenbankverbindung geöffnet wird. Die Verbindung wird erst erstellt, wenn die erste Abfrage ausgeführt wird.
Schema-Design
Drizzle-Schemas sind einfache TypeScript-Dateien, die Tabellendefinitionen exportieren. Die Disziplin besteht hier darin, zusammengehörige Tabellen in derselben Datei zu halten und alles über einen zentralen Index zu exportieren.
// packages/db/src/schema/contacts.ts
import {
pgTable,
uuid,
text,
varchar,
timestamp,
pgEnum,
boolean,
index,
} from 'drizzle-orm/pg-core';
export const contactTypeEnum = pgEnum('contact_type', ['individual', 'company', 'partner']);
export const contacts = pgTable(
'contacts',
{
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id').notNull(), // Multi-tenancy
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
type: contactTypeEnum('type').default('individual').notNull(),
isActive: boolean('is_active').default(true).notNull(),
notes: text('notes'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
organizationIdx: index('contacts_organization_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
})
);
export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;
Mit den Typen $inferSelect und $inferInsert erhalten Sie vollständige TypeScript-Inferenz – Drizzle weiß zum Einfügezeitpunkt basierend auf Ihren Schemaeinschränkungen, welche Felder erforderlich und welche optional sind.
// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables
Die Indexdatei muss jede Tabelle exportieren, damit drizzle-kit sie während der Migrationsgenerierung abholen kann.
Beziehungen
Drizzle trennt Fremdschlüsseleinschränkungen (durch PostgreSQL erzwungen) von Beziehungsdefinitionen (die von der relationalen Abfrage-API von Drizzle verwendet werden). Sie benötigen beides:
// packages/db/src/schema/relations.ts
import { relations } from 'drizzle-orm';
import { contacts } from './contacts';
import { orders } from './orders';
import { orderItems } from './order-items';
import { products } from './products';
export const contactsRelations = relations(contacts, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
contact: one(contacts, {
fields: [orders.contactId],
references: [contacts.id],
}),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, {
fields: [orderItems.orderId],
references: [orders.id],
}),
product: one(products, {
fields: [orderItems.productId],
references: [products.id],
}),
}));
Wenn Beziehungen definiert sind, können Sie die relationale Abfrage-API von Drizzle verwenden:
// Fetch order with contact and items in one query
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
contact: true,
items: {
with: {
product: true,
},
},
},
});
Drizzle generiert eine einzelne JOIN-Abfrage – kein N+1-Problem.
Typsichere Abfragen
Die Abfrage-API von Drizzle ist vollständig typsicher. Der Auswahltyp wird basierend darauf eingeschränkt, welche Spalten Sie einschließen:
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and, like, sql, count } from 'drizzle-orm';
// Full select — returns Contact[]
const allContacts = await db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(50);
// Partial select — returns only specified columns
const contactNames = await db
.select({
id: contacts.id,
name: contacts.name,
email: contacts.email,
})
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
eq(contacts.isActive, true)
)
);
// Count query
const [{ total }] = await db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId));
// Search with ILIKE (case-insensitive)
const searchResults = await db
.select()
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
like(contacts.name, `%${searchTerm}%`)
)
)
.limit(20);
Erstellen Sie für dynamische WHERE-Klauseln (Filter aus der Benutzeroberfläche) Bedingungen bedingt:
import { SQL } from 'drizzle-orm';
async function searchContacts(orgId: string, filters: {
search?: string;
type?: 'individual' | 'company';
isActive?: boolean;
}) {
const conditions: SQL[] = [eq(contacts.organizationId, orgId)];
if (filters.search) {
conditions.push(like(contacts.name, `%${filters.search}%`));
}
if (filters.type) {
// Enum comparisons need explicit casting
conditions.push(eq(contacts.type, filters.type as 'individual' | 'company' | 'partner'));
}
if (filters.isActive !== undefined) {
conditions.push(eq(contacts.isActive, filters.isActive));
}
return db
.select()
.from(contacts)
.where(and(...conditions))
.limit(100);
}
Beachten Sie die explizite Umwandlung auf Enumerationswerte: filters.type as 'individual' | 'company' | 'partner'. Der Enum-Typ von Drizzle akzeptiert nicht automatisch engere String-Subtypen ohne die Umwandlung.
Einfügen, Aktualisieren und Löschen
import { db } from '@ecosire/db';
import { contacts, NewContact } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';
// Insert with RETURNING
async function createContact(data: NewContact) {
const [created] = await db
.insert(contacts)
.values(data)
.returning();
return created; // Fully typed as Contact
}
// Upsert (insert or update on conflict)
async function upsertContact(data: NewContact) {
const [result] = await db
.insert(contacts)
.values(data)
.onConflictDoUpdate({
target: [contacts.organizationId, contacts.email],
set: {
name: data.name,
updatedAt: new Date(),
},
})
.returning();
return result;
}
// Update with RETURNING
async function updateContact(
id: string,
orgId: string,
updates: Partial<NewContact>
) {
const [updated] = await db
.update(contacts)
.set({ ...updates, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId) // Always filter by org
)
)
.returning();
return updated;
}
// Soft delete (recommended over hard delete)
async function deleteContact(id: string, orgId: string) {
return db
.update(contacts)
.set({ isActive: false, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId)
)
);
}
Verwenden Sie immer .returning() für INSERT- und UPDATE-Anweisungen – es vermeidet einen zweiten SELECT-Roundtrip, um den erstellten/aktualisierten Datensatz abzurufen.
Transaktionen
Für mehrstufige Vorgänge, die gemeinsam erfolgreich sein oder fehlschlagen müssen, sind Transaktionen erforderlich:
async function createOrderWithItems(
orderData: NewOrder,
items: NewOrderItem[]
) {
return db.transaction(async (tx) => {
// Create the order
const [order] = await tx
.insert(orders)
.values(orderData)
.returning();
// Create order items
const createdItems = await tx
.insert(orderItems)
.values(items.map((item) => ({ ...item, orderId: order.id })))
.returning();
// Update inventory (must succeed or entire transaction rolls back)
for (const item of items) {
await tx
.update(products)
.set({
stock: sql`${products.stock} - ${item.quantity}`,
})
.where(eq(products.id, item.productId));
}
return { order, items: createdItems };
});
}
Bei der Übergabe des Transaktionskontexts an Dienstmethoden:
// Service accepts optional transaction for composability
async function activateLicense(
licenseId: string,
tx?: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
) {
const queryRunner = tx || db;
return queryRunner
.update(licenses)
.set({ status: 'active', activatedAt: new Date() })
.where(eq(licenses.id, licenseId))
.returning();
}
Migrationen mit Drizzle-Kit
Die drizzle-kit-CLI generiert Migrationen aus Schemaänderungen:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema/index.ts',
out: './src/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
Arbeitsablauf:
# Generate migration SQL from schema changes
npx drizzle-kit generate
# Apply migrations to database
npx drizzle-kit migrate
# Push schema directly (dev only — skips migration files)
npx drizzle-kit push
# Open Drizzle Studio (visual database browser)
npx drizzle-kit studio
Verwenden Sie für die Produktion immer migrate (nicht push). Der Befehl push dient der lokalen Entwicklungsiteration – er erstellt keine Migrationsdateien, sodass Änderungen nicht nachverfolgt werden.
Paginierungsmuster
Für eine effiziente Paginierung sind sowohl eine Datenabfrage als auch eine Zählabfrage erforderlich:
interface PaginationParams {
page: number;
pageSize: number;
}
async function getContactsPaginated(orgId: string, { page, pageSize }: PaginationParams) {
const offset = (page - 1) * pageSize;
const [data, [{ total }]] = await Promise.all([
db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(pageSize)
.offset(offset)
.orderBy(contacts.createdAt),
db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId)),
]);
return {
data,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
Das parallele Ausführen beider Abfragen mit Promise.all halbiert die Antwortzeit im Vergleich zur sequenziellen Ausführung.
Häufige Fallstricke und Lösungen
Falle 1: Verwendung von sql.raw() für dynamische Abfragen
// Dangerous — SQL injection vulnerability
const results = await db.execute(
sql.raw(`SELECT * FROM contacts WHERE name = '${userInput}'`)
);
// Safe — parameterized
const results = await db.execute(
sql`SELECT * FROM contacts WHERE name = ${userInput}`
);
Falle 2: Fehlende Indizes für häufig abgefragte Spalten
Fügen Sie den Spalten, nach denen Sie filtern, immer Indizes hinzu. Die häufigsten fehlenden Indizes:
(table) => ({
organizationIdx: index('contacts_org_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
compositeIdx: index('contacts_org_active_idx').on(
table.organizationId,
table.isActive
),
})
Falle 3: N+1 Abfragen durch manuelles Laden von Beziehungen
// N+1 problem — one query per order
const orders = await db.select().from(ordersTable);
for (const order of orders) {
order.contact = await db
.select()
.from(contacts)
.where(eq(contacts.id, order.contactId));
}
// Solution — use Drizzle's relational query API
const orders = await db.query.orders.findMany({
with: { contact: true },
});
Falle 4: Vergessen, neue Tabellen aus schema/index.ts zu exportieren
Wenn Sie eine neue Schemadatei erstellen, aber vergessen, sie erneut aus schema/index.ts zu exportieren, wird drizzle-kit die neue Tabelle nicht sehen und es werden keine Migrationen generiert.
Häufig gestellte Fragen
Wie schneidet Drizzle im Vergleich zu Prisma für TypeScript-Projekte ab?
Drizzle ähnelt mit TypeScript-Typen eher reinem SQL – Sie schreiben Abfragen, die 1:1 SQL zuordnen. Prisma abstrahiert mehr, mit einer benutzerdefinierten Abfragesprache und einer separaten Schemadatei. Drizzle bietet eine bessere TypeScript-Inferenz bei komplexen Abfragen, einen geringeren Laufzeitaufwand und erfordert keinen Codegenerierungsschritt. Prisma verfügt über ein größeres Ökosystem und mehr integrierte Funktionen wie Soft-Deletes und Audit-Logging. Für große Teams, die mit SQL vertraut sind, wird Drizzle im Allgemeinen bevorzugt.
Soll ich Drizzle-Kit Push verwenden oder in CI/CD migrieren?
Verwenden Sie in CI/CD immer drizzle-kit migrate. Der Befehl push dient der schnellen lokalen Entwicklung – er wendet Schemaänderungen direkt an, ohne Migrationsdateien zu erstellen, sodass kein Prüfpfad vorhanden ist und ein Rollback schwierig ist. Führen Sie in Ihrer Bereitstellungspipeline drizzle-kit migrate aus, bevor Sie die Anwendung starten, um sicherzustellen, dass das Schema synchronisiert ist.
Wie gehe ich mit dem Datenbank-Seeding mit Drizzle um?
Erstellen Sie eine separate Seed-Datei und importieren Sie Ihr Schema direkt. Verwenden Sie onConflictDoNothing() für idempotente Seeds, die bei einer erneuten Ausführung nicht fehlschlagen. Speichern Sie Seed-Daten in Konstanten für einfache Aktualisierungen und führen Sie Seeds nach Migrationen in Ihrem Entwicklungs-Setup-Skript aus.
Wie führe ich eine Volltextsuche mit Drizzle und PostgreSQL durch?
Verwenden Sie das Vorlagenliteral sql mit den Funktionen to_tsvector und to_tsquery von PostgreSQL. Drizzle abstrahiert die Volltextsuche nicht, aber Sie können sie direkt schreiben. Fügen Sie aus Leistungsgründen einen GIN-Index zur Spalte tsvector hinzu und verwenden Sie websearch_to_tsquery für vom Benutzer bereitgestellte Suchbegriffe, um Abfragen in natürlicher Sprache sicher zu analysieren.
Wie gehe ich mit Schemaänderungen ohne Ausfallzeiten um?
Verwenden Sie additive Migrationen – fügen Sie zuerst Spalten hinzu, die nullbar sind, füllen Sie Daten auf und fügen Sie dann die NOT NULL-Einschränkung hinzu. Löschen Sie niemals Spalten in derselben Migration, die sie aus dem Code entfernt. Die Reihenfolge ist: 1) Code bereitstellen, der die alte Spalte ignoriert, 2) Migration bereitstellen, um sie zu löschen. Dadurch wird sichergestellt, dass ein Rollback immer ohne Datenverlust möglich ist.
Nächste Schritte
Eine gut gestaltete Datenbankschicht ist die Grundlage, auf der jede Produktionsanwendung aufbaut. Das Engineering-Team von ECOSIRE arbeitet täglich mit Drizzle ORM und PostgreSQL und verwaltet über 65 Schemadateien in einem komplexen mandantenfähigen System.
Ganz gleich, ob Sie Beratung zur Datenbankarchitektur, Odoo ERP-Integration oder ein komplettes Backend-System benötigen, das mit modernen TypeScript-Tools erstellt wurde: [Entdecken Sie unsere Dienste] (/services), um zu erfahren, wie wir Ihnen helfen können.
Geschrieben von
ECOSIRE Research and Development Team
Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.
Verwandte Artikel
Zero-Downtime Database Migrations with Drizzle ORM
Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Natural Language Database Queries with OpenClaw
How OpenClaw enables natural language database queries, translating plain English business questions into accurate SQL without exposing database credentials or query complexity.