Membangun CRUD PostgreSQL di Express 5 dengan dua pendekatan: pool pg untuk query langsung, dan Prisma untuk ORM dengan migration, plus perbandingan alur kerja relasional melawan MongoDB.

Setelah di episode 9 kalian menguasai MongoDB, episode 10 melengkapi sisi relasional: PostgreSQL. Banyak sistem produksi membutuhkan relasi, transaksi, dan integritas yang dijaga database — domain di mana MongoDB kurang kuat dan PostgreSQL unggul.
Kita memakai dua pendekatan agar kalian punya gambaran lengkap: pool pg untuk kontrol penuh atas SQL, dan Prisma untuk ORM dengan migration yang ergonomis. Mengapa keduanya? Karena di lapangan, tim memakai keduanya — driver mentah untuk query kompleks, Prisma untuk kecepatan development. Memahami dua jalur ini membuat kalian tidak tergantung pada satu tool.
Pastikan kontainer PostgreSQL lab berjalan (episode 0) dan install kedua package:
npm install pg prisma @prisma/client
npx prisma init --datasource-provider postgresqlprisma init membuat prisma/schema.prisma dan mengisi .env dengan placeholder DATABASE_URL.
Driver pg memakai connection pool — kumpulan koneksi yang dibuka sekali dan dipakai bergantian. Ini wajib untuk server HTTP yang melayani banyak request paralel:
import pg from "pg"
const { Pool } = pg
export const pool = new Pool({
connectionString:
process.env.DATABASE_URL ||
"postgres://dev:dev@localhost:5432/expresslab",
max: 10,
})max: 10 membatasi jumlah koneksi dalam pool. Tanpa pool, setiap request membuka koneksi baru — lambat dan bisa menghabiskan resource database.
Jalankan migrasi pertama lewat SQL langsung:
psql "postgres://dev:dev@localhost:5432/expresslab" \
-c "CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email TEXT NOT NULL UNIQUE,
age INT CHECK (age >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);"SERIAL membuat auto-increment id, CHECK menjaga age valid, dan UNIQUE menjaga email unik — integritas dijaga di level database, bukan hanya aplikasi.
import { pool } from "../models/pgPool.js"
export const createUser = async (req, res) => {
const { name, email, age } = req.body
const { rows } = await pool.query(
`INSERT INTO users (name, email, age)
VALUES ($1, $2, $3)
RETURNING *`,
[name, email, age]
)
res.status(201).json({ data: rows[0] })
}
export const listUsers = async (req, res) => {
const { rows } = await pool.query(
`SELECT id, name, email, age, created_at
FROM users ORDER BY id`
)
res.json({ data: rows })
}
export const getUser = async (req, res) => {
const { rows } = await pool.query(
"SELECT id, name, email, age FROM users WHERE id = $1",
[req.params.id]
)
if (rows.length === 0) {
throw new AppError(404, "USER_NOT_FOUND", "User tidak ditemukan")
}
res.json({ data: rows[0] })
}
export const deleteUser = async (req, res) => {
const { rowCount } = await pool.query(
"DELETE FROM users WHERE id = $1",
[req.params.id]
)
if (rowCount === 0) {
throw new AppError(404, "USER_NOT_FOUND", "User tidak ditemukan")
}
res.status(204).end()
}Perhatikan $1, $2 — ini parameterized query. Nilai dari req.body tidak pernah disisipkan langsung ke string SQL, sehingga SQL injection tidak mungkin. Inilah alasan terpenting memakai pool pg dengan benar.
prisma/schema.prisma mendefinisikan model:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
email String @unique
age Int?
createdAt DateTime @default(now()) @map("created_at")
@@map("users")
}Buat migration dan generate client:
npx prisma migrate dev --name initprisma migrate dev membuat file migration SQL, menjalankannya, dan meng-generate Prisma Client — semua dari satu perintah.
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export const createUser = async (req, res) => {
const user = await prisma.user.create({ data: req.body })
res.status(201).json({ data: user })
}
export const listUsers = async (req, res) => {
const users = await prisma.user.findMany({ orderBy: { id: "asc" } })
res.json({ data: users })
}
export const getUser = async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: Number(req.params.id) },
})
if (!user) throw new AppError(404, "USER_NOT_FOUND", "User tidak ditemukan")
res.json({ data: user })
}
export const updateUser = async (req, res) => {
const user = await prisma.user.update({
where: { id: Number(req.params.id) },
data: req.body,
})
res.json({ data: user })
}Prisma Client adalah type-safe — jika age bertipe Int? (nullable), memasukkan string akan ditolak saat kompilasi bila memakai TypeScript, dan dilempar saat runtime di JavaScript.
Note
Prisma Client sebaiknya di-instantiate sekali dan dibagikan (singleton), bukan dibuat per request — membuat instance baru per request membuang resource. Simpan instance di module dan import di mana saja, seperti contoh di atas.
| Aspek | Pool pg | Prisma |
|---|---|---|
| Kontrol SQL | Penuh | Dibatasi API ORM |
| Keamanan injection | Aman jika parameterized | Aman by design |
| Migration | Manual | Built-in (prisma migrate) |
| Kecepatan development | Lebih lambat (menulis SQL) | Lebih cepat |
| Query kompleks | Bebas | Butuh $queryRaw |
| Type safety | Tidak ada | Ya |
Aturan praktisnya: gunakan Prisma untuk pengembangan cepat dan migrasi yang rapi; turun ke pool pg saat query menjadi terlalu kompleks untuk ORM. Banyak tim produksi memakai keduanya dalam satu codebase.
Prisma membaca DATABASE_URL dari .env. Pastikan isinya benar dan file .env tidak ter-commit (episode 16).
Prisma melempar error P2002 untuk duplikat unique — petakan ke 409 di error handler seperti duplicate key Mongo di episode 9.
Di pg, id bertipe integer. Number(req.params.id) harus dikonversi sebelum dipakai findUnique — jika tidak, Prisma melempar error tipe. Gabungkan dengan validasi (episode 13) untuk 400 yang bersih.
Tip
Uji endpoint dengan curl memakai urutan yang sama seperti episode 9: create → list → get → update → delete. Bandingkan bentuk error 4xx yang kalian dapat — konsistensi respons yang sama di dua database membuktikan arsitektur error episode 7 bekerja.
Episode 10 melengkapi kemampuan database kalian: pool pg untuk SQL parameterized yang aman dari injection, Prisma untuk ORM type-safe dengan migration, dan peta kapan memilih yang mana.
Inti yang harus dibawa pulang:
pg membuka koneksi sekali dan memakainya bergantian — wajib untuk server HTTP.$1) mencegah SQL injection — jangan pernah interpolasi string SQL.Di episode 11 selanjutnya kita akan menyajikan konten: static files & templating — express.static, template engine EJS/Pug, dan render halaman server-side. Sampai jumpa di episode 11!