1. Pengenalan Prisma Advanced
Prisma adalah ORM paling populer untuk ekosistem Node.js dan TypeScript. Jika kamu sudah menguasai dasar-dasar Prisma — schema, migration, dan CRUD operations — artikel ini akan membawamu ke level selanjutnya. Kita akan mempelajari fitur-fitur advanced yang sangat penting untuk aplikasi production.
Pada artikel ini, kita akan fokus pada:
- Middleware — Intercept dan modify setiap query sebelum/sesudah eksekusi
- Extensions — Perluas kemampuan Prisma Client dengan custom methods
- Raw Queries — Menulis SQL langsung untuk query kompleks
- N+1 Optimization — Mengatasi masalah N+1 yang sering bikin lambat
- Connection Pooling — Mengelola koneksi database dengan efisien
- Performance Tuning — Tips dan trik untuk query yang lebih cepat
Prerequisites
Artikel ini mengasumsikan kamu sudah familiar dengan:
- Prisma Schema Language (model, relation, enum)
- Prisma Client dasar (findMany, create, update, delete)
- Prisma Migrate
- TypeScript dasar
Jika kamu belum familiar dengan Prisma dasar, baca dokumentasi resmi prisma.io/docs terlebih dahulu sebelum melanjutkan.
2. Prisma Client Setup
Sebelum masuk ke fitur advanced, mari kita setup Prisma Client dengan konfigurasi yang tepat untuk production. Singleton pattern sangat penting untuk mencegah kehabisan koneksi database di environment seperti Next.js yang hot-reload.
Singleton Pattern
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
// Deklarasi global untuk menyimpan Prisma Client instance
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
// Singleton: gunakan instance yang sudah ada atau buat baru
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
errorFormat: 'minimal',
});
// Simpan ke global agar tidak di-instantiate ulang saat hot-reload
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
Konfigurasi Schema untuk Production
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch", "metrics"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") // Untuk migrasi
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
role Role @default(USER)
posts Post[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Profile {
id Int @id @default(autoincrement())
bio String?
avatar String?
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([createdAt])
@@map("posts")
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
@@map("tags")
}
enum Role {
USER
ADMIN
MODERATOR
}
3. Middleware (Prisma Middlewares)
Middleware di Prisma memungkinkan kamu untuk "menangkap" setiap query sebelum dan sesudah dieksekusi. Ini sangat berguna untuk logging, auditing, soft delete, dan validasi. Middleware bekerja di level yang sangat rendah — sebelum query dikirim ke database.
Middleware adalah fitur legacy di Prisma. Untuk versi terbaru, Prisma merekomendasikan menggunakan Prisma Extensions yang lebih fleksibel. Namun middleware masih berguna untuk kasus tertentu.
Basic Middleware — Logging & Timing
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Middleware untuk logging dan mengukur waktu query
prisma.$use(async (params, next) => {
const before = Date.now();
const result = await next(params);
const after = Date.now();
console.log(
`Query ${params.model}.${params.action} took ${after - before}ms`
);
return result;
});
// Usage — semua query otomatis ter-logging
const users = await prisma.user.findMany();
// Output: Query User.findMany took 45ms
Soft Delete Middleware
Salah satu use case paling populer untuk middleware adalah implementasi soft delete. Alih-alih benar-benar menghapus data dari database, kita hanya menandai data sebagai "deleted":
// Tambahkan field deletedAt di schema
// model User {
// deletedAt DateTime?
// }
prisma.$use(async (params, next) => {
// Intercept operasi delete
if (params.action === 'delete') {
// Ubah delete menjadi update (soft delete)
params.action = 'update';
params.args.data = { deletedAt: new Date() };
}
// Intercept deleteMany
if (params.action === 'deleteMany') {
params.action = 'updateMany';
if (!params.args.data) {
params.args.data = {};
}
params.args.data.deletedAt = new Date();
}
return next(params);
});
// Middleware untuk filter soft-deleted records secara otomatis
prisma.$use(async (params, next) => {
if (params.model === 'User') {
if (params.action === 'findUnique' || params.action === 'findFirst') {
params.action = 'findFirst';
params.args.where = {
...params.args.where,
deletedAt: null,
};
}
if (params.action === 'findMany') {
if (!params.args.where) {
params.args.where = {};
}
params.args.where.deletedAt = null;
}
}
return next(params);
});
// Sekarang findMany otomatis mengecualikan soft-deleted records
const activeUsers = await prisma.user.findMany();
// SELECT * FROM users WHERE deleted_at IS NULL
Audit Trail Middleware
interface AuditLog {
model: string;
action: string;
timestamp: Date;
data?: unknown;
}
const auditLogs: AuditLog[] = [];
prisma.$use(async (params, next) => {
const result = await next(params);
// Log operasi write
if (['create', 'update', 'delete'].includes(params.action)) {
auditLogs.push({
model: params.model!,
action: params.action,
timestamp: new Date(),
data: params.args,
});
}
return result;
});
// Query yang menarik untuk audit
await prisma.user.create({ data: { email: 'test@mail.com', name: 'Test' } });
await prisma.post.update({ where: { id: 1 }, data: { published: true } });
console.log(auditLogs);
// [
// { model: 'User', action: 'create', timestamp: '...', data: {...} },
// { model: 'Post', action: 'update', timestamp: '...', data: {...} },
// ]
4. Prisma Extensions
Prisma Client Extensions adalah cara modern untuk memperluas fungsionalitas Prisma. Berbeda dari middleware yang berjalan di setiap query, extensions memberikan kontrol lebih granular dan type-safe.
Model Extension — Custom Methods
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Extend Prisma Client dengan custom methods
const extendedPrisma = prisma.$extends({
model: {
user: {
// Custom method: find by email
async findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
});
},
// Custom method: find active admins
async findActiveAdmins() {
return prisma.user.findMany({
where: {
role: 'ADMIN',
// Asumsikan ada field isActive
},
});
},
// Custom method: register (create with profile)
async register(email: string, name: string, bio?: string) {
return prisma.user.create({
data: {
email,
name,
profile: {
create: { bio },
},
},
include: { profile: true },
});
},
},
post: {
async publish(id: number) {
return prisma.post.update({
where: { id },
data: { published: true },
});
},
async findByTag(tagName: string) {
return prisma.post.findMany({
where: {
tags: { some: { name: tagName } },
},
include: { tags: true, author: true },
});
},
},
},
});
// Usage
const user = await extendedPrisma.user.findByEmail('budi@mail.com');
const admins = await extendedPrisma.user.findActiveAdmins();
const newUser = await extendedPrisma.user.register('sari@mail.com', 'Sari', 'Web developer');
await extendedPrisma.post.publish(1);
Query Extension — Intercept & Modify Queries
const extendedPrisma = prisma.$extends({
query: {
user: {
// Override findMany: always include profile
findMany({ args, query }) {
args.include = { ...args.include, profile: true };
return query(args);
},
// Override create: validate email format
async create({ args, query }) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.data.email)) {
throw new Error('Format email tidak valid');
}
return query(args);
},
// Override delete: soft delete instead
async delete({ args, query }) {
return prisma.user.update({
where: args.where,
data: { deletedAt: new Date() },
});
},
},
},
});
// Semua findMany otomatis include profile
const users = await extendedPrisma.user.findMany();
// Hasil: [{ id: 1, name: 'Budi', profile: { bio: '...' } }]
Result Extension — Custom Fields & Computed
const extendedPrisma = prisma.$extends({
result: {
user: {
// Computed field: fullName
fullName: {
needs: { name: true },
compute(user) {
return user.name ? user.name.toUpperCase() : 'ANONYMOUS';
},
},
// Computed field: emailDomain
emailDomain: {
needs: { email: true },
compute(user) {
return user.email.split('@')[1];
},
},
},
},
});
// Usage — field fullName dan emailDomain tersedia otomatis
const user = await extendedPrisma.user.findUnique({ where: { id: 1 } });
console.log(user?.fullName); // "BUDI SANTOSO"
console.log(user?.emailDomain); // "gmail.com"
5. Raw Queries
Terkadang query yang kamu butuhkan terlalu kompleks untuk Prisma Query API. Prisma menyediakan metode $queryRaw dan $executeRaw untuk menulis SQL langsung dengan tetap menjaga type safety.
$queryRaw — Membaca Data
import { Prisma } from '@prisma/client';
// Template literal (aman dari SQL injection)
const users = await prisma.$queryRaw`
SELECT id, name, email, created_at
FROM users
WHERE created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC
LIMIT 10
`;
// Dengan parameter (gunakan Prisma.sql untuk keamanan)
const userId = 42;
const user = await prisma.$queryRaw`
SELECT u.*, p.bio
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.id = ${userId}
`;
// Query kompleks: subquery dan CTE
const topAuthors = await prisma.$queryRaw`
WITH author_stats AS (
SELECT
u.id,
u.name,
COUNT(p.id) AS post_count,
SUM(p.view_count) AS total_views
FROM users u
INNER JOIN posts p ON p.author_id = u.id
WHERE p.published = true
GROUP BY u.id, u.name
)
SELECT * FROM author_stats
WHERE post_count > 5
ORDER BY total_views DESC
LIMIT 10
`;
$executeRaw — Mengubah Data
// INSERT raw
const result = await prisma.$executeRaw`
INSERT INTO users (email, name, role)
VALUES (${email}, ${name}, 'USER')
`;
// UPDATE dengan operasi SQL kompleks
await prisma.$executeRaw`
UPDATE posts
SET view_count = view_count + 1
WHERE id = ${postId}
`;
// Bulk update
await prisma.$executeRaw`
UPDATE users
SET role = 'INACTIVE'
WHERE last_login < NOW() - INTERVAL '90 days'
`;
// CALL stored procedure
await prisma.$executeRaw`SELECT refresh_materialized_view('user_stats')`;
Typed Raw Queries
interface UserWithStats {
id: number;
name: string;
post_count: bigint;
total_views: bigint;
}
// Type-safe raw query
const topUsers: UserWithStats[] = await prisma.$queryRaw`
SELECT
u.id,
u.name,
COUNT(p.id) as post_count,
COALESCE(SUM(p.view_count), 0) as total_views
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
GROUP BY u.id, u.name
ORDER BY total_views DESC
LIMIT 10
`;
topUsers.forEach(user => {
console.log(`${user.name}: ${user.post_count} posts`);
});
Selalu gunakan template literal $queryRaw\`...\` untuk parameterized query. Jangan menggunakan $queryRaw(string) karena rentan terhadap SQL injection.
6. N+1 Problem & Optimization
N+1 problem adalah masalah performa yang sangat umum di ORM. Masalah ini terjadi ketika kamu mengambil daftar N record, lalu untuk setiap record kamu melakukan query terpisah untuk mengambil relasinya — total menjadi N+1 query.
Mengidentifikasi N+1 Problem
// ❌ BURUK: N+1 Problem
// 1 query untuk semua posts + N query untuk setiap author
const posts = await prisma.post.findMany();
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId },
});
console.log(`${post.title} by ${author?.name}`);
}
// Total: 1 + N queries. Jika 100 posts = 101 queries!
Solusi 1: Include (Eager Loading)
// ✅ BAIK: Menggunakan include
// Hanya 2 query (posts + authors)
const posts = await prisma.post.findMany({
include: {
author: true,
tags: true,
},
});
for (const post of posts) {
console.log(`${post.title} by ${post.author.name}`);
// post.author sudah tersedia, tidak perlu query lagi
}
// Total: 1-2 queries regardless of jumlah posts
Solusi 2: Select (Partial Loading)
// ✅ LEBIH BAIK: Select hanya kolom yang dibutuhkan
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
createdAt: true,
author: {
select: {
name: true,
email: true,
},
},
},
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 20,
});
// Hasil lebih ringan karena hanya kolom yang diperlukan
Solusi 3: Batch Loading dengan DataLoader
import DataLoader from 'dataloader';
// Batch loader untuk users
const userLoader = new DataLoader(async (userIds: readonly number[]) => {
const users = await prisma.user.findMany({
where: { id: { in: [...userIds] } },
});
// Map results in correct order
return userIds.map(id => users.find(u => u.id === id) ?? null);
});
// Batch loader untuk post counts
const postCountLoader = new DataLoader(async (userIds: readonly number[]) => {
const counts = await prisma.post.groupBy({
by: ['authorId'],
where: { authorId: { in: [...userIds] } },
_count: true,
});
return userIds.map(
id => counts.find(c => c.authorId === id)?._count ?? 0
);
});
// Usage — semua request di-batch otomatis
const posts = await prisma.post.findMany({ take: 20 });
for (const post of posts) {
const author = await userLoader.load(post.authorId);
const postCount = await postCountLoader.load(post.authorId);
console.log(`${post.title} by ${author?.name} (${postCount} posts)`);
}
// DataLoader menggabungkan 20 individual loads menjadi 1 batch query
7. Connection Pooling
Setiap instance Prisma Client membuka connection pool ke database. Untuk aplikasi production dengan traffic tinggi, pengelolaan koneksi sangat penting. Terlalu banyak koneksi bisa menyebabkan database kehabisan resource, terlalu sedikit bisa menyebabkan timeout.
Connection Pool Configuration
const prisma = new PrismaClient({
datasources: {
db: {
url: 'postgresql://user:pass@localhost:5432/mydb?connection_limit=10&pool_timeout=20',
},
},
});
// Atau di DATABASE_URL:
// DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=10"
Connection Pool Parameters
| Parameter | Default | Penjelasan |
|---|---|---|
connection_limit |
num_cpus * 2 + 1 | Jumlah maksimal koneksi dalam pool |
pool_timeout |
10 detik | Waktu tunggu untuk mendapatkan koneksi dari pool |
connect_timeout |
5 detik | Waktu tunggu untuk membuat koneksi baru |
sslmode |
prefer | Mode SSL (prefer, require, verify-full) |
External Connection Pooler (PgBouncer)
# Gunakan directUrl untuk migrasi dan pooled URL untuk queries
# .env
DATABASE_URL="postgresql://user:pass@pgbouncer:6432/mydb?pgbouncer=true"
DIRECT_URL="postgresql://user:pass@localhost:5432/mydb"
# schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") # Pooled connection (PgBouncer)
directUrl = env("DIRECT_URL") # Direct connection (untuk migrasi)
}
Monitoring Koneksi
// Monitoring pool metrics (Prisma 4.16+)
async function checkPoolMetrics() {
const metrics = await prisma.$metrics.histogram('prisma_pool_connections_open');
console.log('Open connections:', metrics);
const activeQueries = await prisma.$metrics.gauge('prisma_pool_queries_active');
console.log('Active queries:', activeQueries);
}
// Graceful shutdown — tutup koneksi dengan bersih
process.on('SIGTERM', async () => {
console.log('SIGTERM received, closing Prisma Client...');
await prisma.$disconnect();
process.exit(0);
});
8. Transactions & Batch
Prisma menyediakan beberapa cara untuk melakukan transactions. Pemilihan metode tergantung pada kompleksitas operasi yang perlu dilakukan secara atomik.
Interactive Transactions
// Interactive transaction: kontrol penuh
const result = await prisma.$transaction(async (tx) => {
// Ambil data user
const user = await tx.user.findUnique({
where: { id: userId },
});
if (!user) throw new Error('User tidak ditemukan');
// Update saldo user
const updatedUser = await tx.user.update({
where: { id: userId },
data: { balance: { decrement: 100 } },
});
// Buat record transaksi
const transaction = await tx.transaction.create({
data: {
userId,
amount: -100,
type: 'PURCHASE',
description: 'Pembelian item',
},
});
return { updatedUser, transaction };
});
Batch Transactions (Non-Interactive)
// Batch transaction: semua operasi dijalankan bersama
const [user, post, tag] = await prisma.$transaction([
prisma.user.create({
data: { email: 'budi@mail.com', name: 'Budi' },
}),
prisma.post.create({
data: { title: 'Hello', content: 'World', authorId: 1 },
}),
prisma.tag.create({
data: { name: 'TypeScript' },
}),
]);
// Lebih cepat dari interactive transaction
// karena semua query di-batch dan di-commit bersama
Transaction Options
// Atur timeout dan isolation level
const result = await prisma.$transaction(
async (tx) => {
// Complex operations...
const order = await tx.order.create({
data: { userId, total: calculatedTotal },
});
for (const item of cartItems) {
await tx.orderItem.create({
data: { orderId: order.id, ...item },
});
// Kurangi stok
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } },
});
}
return order;
},
{
maxWait: 10000, // Max tunggu untuk mendapatkan transaction (10 detik)
timeout: 30000, // Max durasi transaction (30 detik)
isolationLevel: 'Serializable', // Isolation level
}
);
9. Performance Tuning
Tips Performa Prisma
| Teknik | Penjelasan | Dampak |
|---|---|---|
Use select |
Pilih hanya kolom yang dibutuhkan | ⚡ Mengurangi data transfer |
Use include |
Resolve relasi sekaligus | ⚡ Menghilangkan N+1 |
| Pagination | Gunakan take & skip |
⚡ Membatasi data |
| Indexes | Tambah @@index di schema | ⚡ Query 10-100x lebih cepat |
| Batch operations | Gunakan createMany, updateMany |
⚡ Mengurangi round trip |
| Raw queries | SQL langsung untuk query kompleks | ⚡ Optimasi query manual |
| Caching | Cache query results di Redis | ⚡ Mengurangi beban database |
| Connection pooling | Atur pool size yang tepat | ⚡ Mengelola koneksi efisien |
Query Caching dengan Prisma
// Simple in-memory cache const cache = new Map(); function cacheKey(model: string, method: string, args: unknown) { return `${model}:${method}:${JSON.stringify(args)}`; } // Prisma extension with caching const cachedPrisma = prisma.$extends({ query: { user: { async findMany({ args, query }) { const key = cacheKey('User', 'findMany', args); const cached = cache.get(key); if (cached && cached.expiry > Date.now()) { return cached.data; } const result = await query(args); cache.set(key, { data: result, expiry: Date.now() + 60_000, // Cache 1 menit }); return result; }, }, }, }); // Invalidate cache saat data berubah function invalidateCache(model: string) { for (const key of cache.keys()) { if (key.startsWith(model + ':')) { cache.delete(key); } } }
Monitoring Query Performance
// Enable query logging untuk development
const prisma = new PrismaClient({
log: [
{ level: 'query', emit: 'event' },
{ level: 'info', emit: 'event' },
{ level: 'warn', emit: 'event' },
{ level: 'error', emit: 'event' },
],
});
// Log slow queries
prisma.$on('query', (e) => {
if (e.duration > 100) { // Query lebih dari 100ms
console.warn(`SLOW QUERY (${e.duration}ms): ${e.query}`);
console.warn(`Params: ${e.params}`);
}
});
// Metrics endpoint (untuk Prometheus)
import { createPrometheusExporter } from '@prisma/prometheus-metrics';
Gunakan EXPLAIN ANALYZE di PostgreSQL untuk menganalisis query plan dari raw queries Prisma. Ini membantu menemukan bottleneck di level database.
10. Quiz Pemahaman
Uji pemahamanmu tentang Prisma ORM Advanced: