Web Development

Prisma ORM Advanced: Middleware, Extensions & Performance

Tutorial advanced Prisma ORM — middleware, extensions, raw queries, N+1 optimization, connection pooling, dan performance tuning untuk production

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:

Prerequisites

Artikel ini mengasumsikan kamu sudah familiar dengan:

💡 Tips

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

TypeScript
// 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
// 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.

⚠️ Peringatan

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

TypeScript
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":

TypeScript
// 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

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
// 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

TypeScript
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`);
});
⚠️ Peringatan

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

TypeScript
// ❌ 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)

TypeScript
// ✅ 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)

TypeScript
// ✅ 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

TypeScript
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

TypeScript
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)

Text
# 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

TypeScript
// 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

TypeScript
// 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)

TypeScript
// 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

TypeScript
// 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

TypeScript
// 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

TypeScript
// 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';
💡 Tips

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:

Pertanyaan 1: Apa itu N+1 problem di context ORM?

a) Ketika database kehabisan koneksi
b) Ketika mengambil N records lalu melakukan N query terpisah untuk relasi
c) Ketika query memakan waktu lebih dari N detik
d) Ketika tabel memiliki lebih dari N indeks

Pertanyaan 2: Apa perbedaan utama antara middleware dan extensions di Prisma?

a) Tidak ada perbedaan
b) Middleware intercept semua query, extensions memberikan kontrol lebih granular dan type-safe
c) Middleware lebih cepat
d) Extensions hanya untuk raw queries

Pertanyaan 3: Mana yang aman dari SQL injection di Prisma?

a) $queryRaw("SELECT * FROM users WHERE id = " + id)
b) $queryRaw`SELECT * FROM users WHERE id = ${id}`
c) Keduanya aman
d) Keduanya tidak aman

Pertanyaan 4: Apa fungsi include di Prisma?

a) Menyertakan kolom tambahan dari tabel yang sama
b) Meload relasi dari tabel lain secara eager
c) Mengimpor schema dari file lain
d) Menggabungkan beberapa database

Pertanyaan 5: Kapan sebaiknya menggunakan interactive transaction vs batch transaction?

a) Selalu gunakan interactive
b) Interactive untuk operasi yang membutuhkan logika antara query, batch untuk operasi independen
c) Batch lebih aman untuk semua kasus
d) Tidak ada perbedaan performa
🔍 Zoom
100%
🎨 Tema