Zum Inhalt springen

9. Integration in Webanwendungen

Zu Zen-Modus wechseln

Der Zugriff auf eine Datenbank über rohes SQL und Low-Level-Treiber ist bereits bekannt. In echten Webanwendungen schreiben Entwicklerinnen aber selten rohes SQL von Hand. Stattdessen nutzen sie Object-Relational Mapper (ORMs), die die Lücke zwischen der relationalen Welt von SQL und der objektorientierten Welt der Programmiersprache überbrücken.

Zwei konkrete ORMs begleiten dieses Kapitel nebeneinander:

  • Prisma — ein modernes ORM für Node.js / TypeScript, die Standardwahl in Next.js-Anwendungen.
  • Eloquent — das ORM des Laravel-PHP-Frameworks; das am weitesten verbreitete PHP-ORM.

Beide lösen dieselben Probleme, treffen aber unterschiedliche Entwurfsentscheidungen, besonders dabei, wie Modelle mit der Datenbank zusammenhängen. Beide im Blick zu behalten macht die zugrunde liegenden Konzepte viel klarer, als eines allein zu betrachten.

Ein ORM (Object-Relational Mapper) ist eine Bibliothek, mit der man über die Objekte und Typen der Programmiersprache mit einer relationalen Datenbank arbeitet, ohne SQL von Hand zu schreiben.

Statt so:

SELECT * FROM users WHERE id = 42;

schreibt man:

const user = await prisma.user.findUnique({ where: { id: 42 } });

Das ORM übersetzt den Code in das passende SQL, schickt es an die Datenbank und bildet die Ergebniszeilen zurück auf typisierte Objekte.

Ohne ORMMit ORM
Abfragespracherohe SQL-Stringssprachnative Methodenaufrufe — lesbar, leicht zu refaktorieren
Ergebniszuordnungmanuell, Spalte für Spalteautomatisch auf typisierte Objekte
Fehler-FeedbackSQL-Fehler erst zur LaufzeitTyp-/Syntaxfehler früher erkannt
Schema-Verfolgungmanuelles ALTER TABLEversionierte Migrationsdateien, in git eingecheckt
Datenbank-PortabilitätDB-spezifische Syntax je HerstellerDB durch eine Konfigurationszeile wechseln
Performance-Kontrollevoll — man schreibt genau, was läuftteilweise — das ORM erzeugt SQL; für langsame Abfragen prüfen
Komplexe Abfragenunkomplizierterfordert manchmal den Rückgriff auf rohes SQL
N+1-Risikogering — im Code explizithöher — in Schleifen leicht versehentlich ausgelöst
Terminal-Fenster
npm install prisma @prisma/client
npx prisma init

Das erzeugt eine Datei prisma/schema.prisma, in der die Datenmodelle in Prismas eigener Schemasprache definiert werden:

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
}

Der @relation-Dekorator sagt Prisma, dass Post.authorId ein Fremdschlüssel auf User.id ist. Prisma nutzt das, um typisierte Abfragemethoden zu erzeugen und die referentielle Integrität zu erzwingen.

ORMs decken die Alltagsfälle gut ab — einfaches CRUD, Filtern, Sortieren, Laden verwandter Daten. Es gibt aber Situationen, in denen das vom ORM erzeugte SQL entweder zu eingeschränkt oder zu langsam ist und man SQL direkt schreiben muss.

Typische Szenarien, in denen rohes SQL die bessere Wahl ist:

  • Fensterfunktionen (RANK(), LAG(), LEAD(), laufende Summen) — die meisten ORMs haben dafür keine API.
  • Rekursive Abfragen (WITH RECURSIVE) für Baumstrukturen wie Kategorien, Organigramme oder Thread-Hierarchien.
  • Datenbankspezifische Funktionen — PostgreSQLs Volltextsuche (tsvector, @@), JSONB-Operatoren (@>, #>>) oder LATERAL-Joins werden von den meisten ORMs nicht bereitgestellt.
  • Stark optimierte Reporting-Abfragen — eine handgeschriebene Abfrage mit sorgfältig gesetzten Indizes, Teilaggregaten und bestimmter Join-Reihenfolge kann deutlich schneller sein als das, was das ORM erzeugt.

Prisma und Eloquent bieten für genau diese Fälle einen Ausweg zu rohem SQL:

$queryRaw liefert typisierte Ergebnisse; $executeRaw ist für Anweisungen ohne Rückgabezeilen (INSERT, UPDATE, DELETE).

import { prisma } from '@/lib/prisma';
// count posts per category with a window function
const result = await prisma.$queryRaw<
{ category: string; postCount: bigint; rank: bigint }[]
>`
SELECT
c.name AS category,
COUNT(p.id) AS "postCount",
RANK() OVER (ORDER BY COUNT(p.id) DESC) AS rank
FROM "Category" c
LEFT JOIN "Post" p ON p."categoryId" = c.id
GROUP BY c.id, c.name
ORDER BY rank;
`;
// Parametrisiert — Prisma.sql verhindert SQL-Injection
import { Prisma } from '@prisma/client';
const minPosts = 2;
const popular = await prisma.$queryRaw<{ category: string }[]>`
SELECT c.name AS category
FROM "Category" c
JOIN "Post" p ON p."categoryId" = c.id
GROUP BY c.id, c.name
HAVING COUNT(p.id) >= ${minPosts}
`;

Eine Migration ist eine versionierte, schrittweise Änderung am Datenbankschema. Immer wenn ein Modell geändert wird (eine Spalte hinzufügen, eine Tabelle umbenennen, einen Index anlegen), entsteht eine Migrationsdatei, die diese Änderung festhält.

Ohne Migrationen:

  • Die lokale Datenbank ist mit den Datenbanken des Teams nicht synchron.
  • Ein Deployment in die Produktion bedeutet, ALTER TABLE-Befehle manuell auszuführen — fehleranfällig und nicht reproduzierbar.
  • Es gibt keine Historie darüber, was sich wann geändert hat.

Mit Migrationen:

  • Jede Schemaänderung ist eine Datei in der Versionsverwaltung.
  • Ein einziger Befehl wendet genau die richtigen Änderungen in der richtigen Reihenfolge auf jeder Umgebung an.
  • Die Historie ist explizit, ein Rollback ist möglich.

Zuerst schema.prisma bearbeiten — etwa ein Feld bio zu User hinzufügen:

model User {
// ...vorhandene Felder...
bio String?
}

Dann die Migration erzeugen und anwenden:

Terminal-Fenster
# development: create a migration file + apply it to the local DB immediately
npx prisma migrate dev --name add_user_bio
# Produktion (CI/CD-Pipeline): ausstehende Migrationen anwenden
npx prisma migrate deploy

Prisma erzeugt prisma/migrations/20240601120000_add_user_bio/migration.sql:

ALTER TABLE "User" ADD COLUMN "bio" TEXT;

Die Migrationsdatei wird in git eingecheckt. Jede Entwicklerin und jede Deployment-Pipeline führt dieselben Migrationen in der richtigen Reihenfolge aus.

1. edit schema.prisma
2. npx prisma migrate dev --name <description>
│ (generates migration SQL + applies it to the local DB)
3. git add prisma/migrations/... && git commit
4. CI/CD runs: npx prisma migrate deploy
│ (applies it to staging / production)
5. Done — all environments in sync

Das N+1-Problem ist die häufigste Performance-Falle beim Einsatz eines ORM. Es entsteht, wenn man eine Liste von Datensätzen lädt und dann für jeden einzeln die verwandten Daten nachlädt — was zu weit mehr Datenbankabfragen führt als nötig.

Angenommen, eine Liste von Blog-Beiträgen soll samt Namen der jeweiligen Autorin angezeigt werden.

Naiver Ansatz — kaputt:

// 1 query: load all posts
const posts = await prisma.post.findMany();
for (const post of posts) {
// N queries: one per post to load the author
const author = await prisma.user.findUnique({ where: { id: post.authorId } });
console.log(`${post.title} by ${author.name}`);
}

Bei 100 Beiträgen führen beide Beispiele 101 Abfragen aus:

SELECT * FROM posts; -- 1 query
SELECT * FROM users WHERE id = 1; -- for post 1
SELECT * FROM users WHERE id = 2; -- for post 2
... -- 98 weitere

Im Produktivmaßstab zerstört das die Performance. Das Eloquent-Beispiel ist besonders gefährlich, weil die zusätzlichen Abfragen unsichtbar sind — sie werden still ausgelöst, sobald auf $post->author zugegriffen wird.

Sag dem ORM, die verwandten Daten zusammen mit den Elterndatensätzen in einem JOIN zu laden:

// 1 query: posts + authors in a single JOIN
const posts = await prisma.post.findMany({
include: {
author: true, // users an posts joinen
},
});
for (const post of posts) {
// author is already loaded — no extra query
console.log(`${post.title} by ${post.author.name}`);
}

Erzeugtes SQL (in beiden Fällen):

SELECT posts.*, users.id, users.name, users.email
FROM posts
LEFT JOIN users ON users.id = posts.user_id;

Eine Abfrage, alle Daten geholt. Das nennt man Eager Loading.

Ladestrategien bestimmen, wann verwandte Daten aus der Datenbank geholt werden.

Lazy Loading heißt, dass verwandte Daten erst geladen werden, wenn tatsächlich darauf zugegriffen wird — nicht schon beim Laden des Elterndatensatzes.

Prisma unterstützt kein automatisches Lazy Loading. Verwandte Daten werden nie geladen, außer sie werden ausdrücklich mit include oder select angefordert.

const post = await prisma.post.findUnique({ where: { id: 1 } });
console.log(post.author); // undefined — not loaded, no automatic query
// a separate query must be issued manually:
const author = await prisma.user.findUnique({ where: { id: post.authorId } });

Das ist eine bewusste Entwurfsentscheidung: Prisma zwingt zur Klarheit und verhindert so versehentliche N+1-Probleme.

Vorteile von Lazy Loading:

  • Lädt nur, worauf tatsächlich zugegriffen wird.
  • Hält die anfängliche Abfrage schnell und einfach.

Nachteile von Lazy Loading:

  • In einer Schleife löst es still N Abfragen aus.
  • Die Abfragen sind beim Lesen des Codes unsichtbar — Performance-Probleme sind im Review schwer zu erkennen.

Eager Loading heißt, dass verwandte Daten zusammen mit dem Elterndatensatz in derselben Abfrage geladen werden — entweder als JOIN oder als zweite gebündelte Abfrage.

const post = await prisma.post.findUnique({
where: { id: 1 },
include: { author: true },
});
console.log(post.author.name); // already loaded, no extra query

Vorteile von Eager Loading:

  • Verhindert N+1-Probleme.
  • Explizit — man sieht genau, was geladen wird.
  • Weniger Roundtrips zur Datenbank.

Nachteile von Eager Loading:

  • Lädt eventuell mehr Daten als nötig.
  • Tief verschachtelte Beziehungen können große JOIN-Abfragen erzeugen.

Statt ein vollständiges verwandtes Objekt zu laden, werden nur die konkreten Felder angefordert, die wirklich gebraucht werden:

const posts = await prisma.post.findMany({
select: {
title: true,
author: {
select: { name: true }, // only the author's name
},
},
});

Erzeugtes SQL (in beiden Fällen):

SELECT posts.title, users.name
FROM posts
LEFT JOIN users ON users.id = posts.user_id;

Das ist der effizienteste Ansatz: genau das laden, was gebraucht wird, nicht mehr.

StrategieWann verwenden
Eager Loadingwenn feststeht, dass die verwandten Daten immer gebraucht werden
Selektives Ladenwenn nur bestimmte Felder aus verwandten Datensätzen gebraucht werden
Separate Abfragewenn die verwandten Daten selten oder optional gebraucht werden

Beim Strukturieren des Datenbankzugriffs in einer Anwendung dominieren zwei Muster: Active Record und Repository. Prisma und Eloquent bauen hier auf unterschiedlichen Philosophien auf — genau deshalb lohnt es sich, beide zusammen zu betrachten.

Im Active-Record-Muster ist die Modellklasse zugleich Datencontainer und verantwortlich für ihre eigene Persistenz. Das Objekt weiß, wie es sich selbst speichert, aktualisiert und löscht.

Eloquent ist eine Lehrbuch-Implementierung von Active Record. Eine Modellklasse erweitert Model und erhält sofort save(), delete(), find() und einen fließenden Query Builder — kein Repository nötig.

// Erstellen
$user = new User();
$user->name = 'Anna';
$user->email = '[email protected]';
$user->save(); // INSERT INTO users ...
// find by primary key
$user = User::find(42);
echo $user->name;
// Aktualisieren
$user->name = 'Anna Miller';
$user->save(); // UPDATE users SET name = ? WHERE id = 42
// delete
$user->delete(); // DELETE FROM users WHERE id = 42
// Query Builder — fluent chaining directly on the model
$activeUsers = User::where('active', true)
->orderBy('name')
->get();

Merkmale:

  • Das Modell „weiß”, wie es sich persistiert.
  • Einfach und intuitiv — Daten und Persistenzlogik liegen zusammen.
  • Das kanonische Beispiel aus der Praxis ist Laravel Eloquent (PHP).

Nachteile:

  • Geschäftslogik (was ein Objekt tut) vermischt sich mit Persistenzlogik (wie es gespeichert wird).
  • Schwerer zu testen — man kann ein Modellobjekt nicht ohne echte Datenbankverbindung nutzen.

Im Repository-Muster wird der Datenbankzugriff in eine eigene Repository-Klasse ausgelagert. Das Modell ist ein einfaches Datenobjekt ohne Datenbankwissen, und das Repository übernimmt alle Abfragen.

Prisma ist ein Data-Mapper-ORM — Modelle sind einfache TypeScript-Objekte ohne Datenbankmethoden. Das führt natürlich zum Repository-Muster.

// simple data object — no database logic
interface User {
id?: number;
name: string;
email: string;
}
// Repository — all database access in one place
class UserRepository {
async findById(id: number): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}
async findAll(): Promise<User[]> {
return prisma.user.findMany();
}
async create(data: { name: string; email: string }): Promise<User> {
return prisma.user.create({ data });
}
async update(id: number, data: Partial<User>): Promise<User> {
return prisma.user.update({ where: { id }, data });
}
async delete(id: number): Promise<void> {
await prisma.user.delete({ where: { id } });
}
}
// Verwendung:
const userRepo = new UserRepository();
const user = await userRepo.create({ name: 'Anna', email: '[email protected]' });

Eine gängige Konvention ist, Repositories in einem Verzeichnis lib/repositories/ abzulegen:

lib/
repositories/
UserRepository.ts
PostRepository.ts
prisma.ts ← gemeinsame Prisma-Client-Instanz

Merkmale:

  • Klare Trennung zwischen Datenform und Datenzugriff.
  • Leicht zu testen: Für Unit-Tests ersetzt man das echte Repository durch ein Fake — keine Datenbank nötig.
  • Ein Wechsel des zugrunde liegenden ORM erfordert nur eine Änderung im Repository, nicht im Rest der Anwendung.
Active RecordRepository
Wo liegt die DB-Logik?in der Modellklassein einer separaten Repository-Klasse
Weiß das Modell von der DB?janein
Testbarkeitschwerer — braucht eine echte DBleicht — Repository kann gemockt werden
Am besten fürkleine Apps, einfaches CRUDgrößere Apps, komplexe Geschäftslogik
HauptbeispielEloquent (Laravel / PHP)Prisma (Next.js / TypeScript)

Jede ORM-Abfrage braucht eine Datenbankverbindung — einen dauerhaften TCP-Kanal zwischen Anwendung und Datenbankserver. Wie diese Verbindungen verwaltet werden, wirkt sich direkt auf die Performance aus, besonders unter Last.

Eine neue Verbindung zu öffnen ist teuer:

  • Der Client führt einen TCP-Handshake mit dem Server durch.
  • PostgreSQL authentifiziert den Benutzer und forkt einen eigenen Backend-Prozess für die Verbindung.
  • Das dauert grob 20–100 ms und belegt serverseitig mehrere Megabyte RAM.

Datenbanken haben außerdem eine harte Grenze für die Zahl gleichzeitiger Verbindungen (in PostgreSQL standardmäßig max_connections = 100). Öffnet jede eingehende Web-Anfrage ihre eigene Verbindung, ist die Grenze schnell erreicht:

100 concurrent users
→ 100 open connections (already at PostgreSQL's default limit)
→ 2–5 MB RAM each (200–500 MB just for connections)
→ 50 ms connection overhead per request

Ein Connection Pool ist ein Zwischenspeicher vorab geöffneter Datenbankverbindungen, die Anfragen ausleihen und zurückgeben:

Without a pool:
request 1 ──► open connection ──► query ──► close (+50 ms overhead)
request 2 ──► open connection ──► query ──► close (+50 ms overhead)
request 3 ──► open connection ──► query ──► close (+50 ms overhead)
With a pool (pool size = 5):
┌─────────────────────────────────────┐
│ Connection Pool │
│ conn-1 ──────────────────────────► PostgreSQL
│ conn-2 ──────────────────────────► PostgreSQL
│ conn-3 ──────────────────────────► PostgreSQL
│ conn-4 (free, ready) │
│ conn-5 (free, ready) │
└─────────────────────────────────────┘
request 1 ──► borrow conn-1 ──► query ──► return conn-1
request 2 ──► borrow conn-2 ──► query ──► return conn-2
request 3 ──► borrow conn-1 ──► query ──► return conn-1 (reused!)

Wichtiges Verhalten:

  • Ist eine freie Verbindung verfügbar, wird sie sofort zugewiesen — kein Neuverbindungs-Overhead.
  • Sind alle Verbindungen belegt, wartet die Anfrage bis zu einem konfigurierbaren Timeout und scheitert dann mit einem Fehler.
  • Der Pool hält Verbindungen am Leben und verwendet sie über viele Anfragen hinweg wieder.

Prisma verwaltet je PrismaClient-Instanz einen eigenen Connection Pool. Poolgröße und Timeout werden in der DATABASE_URL gesetzt:

DATABASE_URL="postgresql://user:pass@host:5432/mydb?connection_limit=10&pool_timeout=10"
ParameterStandardBedeutung
connection_limitnum_cpus * 2 + 1maximale offene Verbindungen im Pool
pool_timeout10 (Sekunden)wie lange auf eine freie Verbindung gewartet wird, bevor ein Fehler ausgelöst wird

Auch deshalb ist der globale Singleton aus Abschnitt 7 wichtig. Ohne ihn wird bei jedem Hot-Reload in der Entwicklung ein neuer PrismaClient (und ein neuer Pool) erzeugt — jeder Pool öffnet eigene Verbindungen, und das Datenbanklimit ist schnell erschöpft:

// lib/prisma.ts — one pool for the entire process lifetime
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

Es hält sich der Irrglaube, ein größerer Pool sei immer besser. In der Praxis bedeutet mehr Verbindungen zu PostgreSQL mehr parallele Backend-Prozesse, die um CPU und RAM des Datenbankservers konkurrieren.

Die Faustregel für die Poolgröße:

optimal pool size ≈ (number of CPU cores on the DB server) × 2

Ein Datenbankserver mit 4 Kernen bewältigt grob 8 parallele Abfragen effizient. Zusätzliche Verbindungen darüber hinaus warten auf CPU-Zeit und verbessern den Durchsatz nicht — sie erhöhen nur den Speicherverbrauch und den Scheduling-Overhead.

Pool zu kleinPool zu groß
Anfragen stauen sich und warten auf eine freie VerbindungCPU und RAM des DB-Servers unter Druck
hohe Latenzspitzen unter Lastmehr Context Switching, langsamere Abfragen
Fehler, wenn pool_timeout überschritten wirdkann PostgreSQLs max_connections-Grenze erreichen

Aktive Verbindungen überwachen (PostgreSQL):

-- current connections per application and state
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE datname = 'your_database'
GROUP BY application_name, state
ORDER BY count DESC;

Ein Warnzeichen sind viele Verbindungen, die in idle in transaction hängen — ein Hinweis darauf, dass Verbindungen nach einer Transaktion nicht zügig an den Pool zurückgegeben werden.

Eine vollständige Datenzugriffsschicht für eine Blog-Anwendung — Migrationen, Schema, Repository, Eager Loading und eine Seite/ein Controller. Alles ohne N+1-Problem.

model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
tags Tag[]
createdAt DateTime @default(now())
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
}
// lib/prisma.ts — shared singleton (prevents connection exhaustion on hot reload)
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
lib/repositories/PostRepository.ts
import { prisma } from '../prisma';
export class PostRepository {
async findAllPublished() {
return prisma.post.findMany({
where: { published: true },
include: {
author: { select: { name: true } }, // eager loading — no N+1
tags: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async findById(id: number) {
return prisma.post.findUnique({
where: { id },
include: { author: true, tags: true },
});
}
async create(data: { title: string; content: string; authorId: number; tagNames: string[] }) {
return prisma.post.create({
data: {
title: data.title,
content: data.content,
author: { connect: { id: data.authorId } },
tags: {
connectOrCreate: data.tagNames.map(name => ({
where: { name },
create: { name },
})),
},
},
include: { author: true, tags: true },
});
}
}
// app/blog/page.tsx (Next.js App Router)
import { PostRepository } from '@/lib/repositories/PostRepository';
export default async function BlogPage() {
const repo = new PostRepository();
const posts = await repo.findAllPublished();
return (
<main>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>by {post.author.name}</p>
<p>{post.tags.map(t => t.name).join(', ')}</p>
</article>
))}
</main>
);
}

Beide Varianten führen genau eine SQL-Abfrage aus (mit JOINs), egal wie viele Beiträge existieren — kein N+1-Problem.

KonzeptKernpunkt
ORMbildet Datenbankzeilen auf Objekte ab; erzeugt SQL aus Code (Prisma, Eloquent)
Migrationversionierte, eingecheckte Schemaänderungen, in Reihenfolge über Umgebungen angewendet
N+1-Problem1 Abfrage für eine Liste + N Abfragen für verwandte Daten; Lösung ist Eager Loading
Lazy Loadingverwandte Daten beim ersten Zugriff geladen — Eloquent kann es, Prisma nicht
Eager Loadingverwandte Daten vorab in einem JOIN geladen; explizit, sicher, empfohlen
Active RecordModell übernimmt seine eigene Persistenz — der Eloquent-Ansatz
Repositoryeigene Klasse für den gesamten DB-Zugriff — der natürliche Prisma-Ansatz

Die Kombination aus Migrationen + ORM + Repository-Muster + Eager Loading ist die Standardarchitektur für produktionsreife Webanwendungen, ob mit Next.js/Prisma oder Laravel/Eloquent gebaut wird.