"Stok yang oversell masih bisa direstock besok. Slot waktu yang double-booked tidak bisa — jam 2 siang yang sama tidak bisa digandakan untuk dua orang berbeda."
Tentang Artikel Ini
Studi kasus ini sekilas mirip artikel Membangun POS Backend — sama-sama soal mencegah dua orang mendapat hal yang sama secara bersamaan. Tapi masalah intinya berbeda cukup dalam sampai butuh teknik yang berbeda pula: POS mencegah jumlah yang jadi negatif (stok), sistem booking mencegah rentang waktu yang saling tumpang tindih (jadwal). Rentang waktu tidak bisa dicek dengan quantity >= qty sederhana — kamu perlu tahu apakah dua interval waktu beririsan, dan itu logika yang gampang salah kalau ditulis manual di application code.
Kita akan bangun backend untuk sistem reservasi generik — bisa untuk ruang meeting, kursi dokter praktik, lapangan futsal, apa pun yang intinya "resource dipesan untuk rentang waktu tertentu". Setelah selesai, kamu akan bisa:
- Merancang skema booking yang mencegah double-booking di level database, bukan cuma di application code
- Memakai exclusion constraint PostgreSQL untuk deteksi konflik rentang waktu yang benar-benar reliable di bawah konkurensi
- Mengatur jadwal berulang (jam operasional mingguan) dan pengecualian (libur, cuti)
- Mengirim reminder otomatis lewat delayed job, bukan polling
- Menangani pembatalan dan melepas slot dengan aman
- Membangun waitlist sederhana yang memanfaatkan slot yang baru dibatalkan
Prasyarat: Sudah menyelesaikan artikel REST API dengan Node.js & Express, Desain Database untuk Sistem Bisnis, Autentikasi JWT & OAuth2, dan Message Queue & Background Jobs — reminder di Bab 5 melanjutkan langsung pola BullMQ dari artikel itu, bukan mengajarkan dari nol.
Daftar Isi
- Kenapa Booking Beda dari CRUD Reservasi Biasa
- Desain Skema: Resource, Jadwal, dan Booking
- Deteksi Konflik dengan Exclusion Constraint
- Jadwal Berulang dan Pengecualian
- Reminder Otomatis dengan Delayed Job
- Pembatalan dan Pelepasan Slot
- Waitlist: Memanfaatkan Slot yang Dibatalkan
- Kenapa Constraint Database, Bukan Cuma Validasi di Aplikasi
Bab 1: Kenapa Booking Beda dari CRUD Reservasi Biasa
Pendekatan yang sering saya lihat di percobaan pertama: endpoint POST /bookings yang mengecek "apakah ada booking lain di resource dan jam yang sama" lewat SELECT, lalu kalau kosong, INSERT. Ini gagal karena dua alasan yang saling memperkuat:
Mengecek irisan waktu itu sendiri gampang salah kalau ditulis manual. "Apakah 10:00–11:00 beririsan dengan 10:30–11:30?" kelihatannya sepele, tapi begitu kamu mulai menangani kasus batas (booking yang persis bersambungan, misalnya 10:00–11:00 lalu 11:00–12:00 — apakah itu beririsan atau tidak?), logika manual gampang salah di satu edge case yang baru ketahuan setelah komplain customer masuk.
Celah waktu antara SELECT dan INSERT adalah race condition — sama persis seperti masalah stok di artikel POS, tapi akibatnya lebih parah. Stok yang oversell masih punya jalan keluar (restock, minta maaf, refund sebagian). Slot waktu yang double-booked berarti dua pelanggan datang di jam yang sama untuk resource yang sama, dan tidak ada cara "menambah" jam 2 siang supaya cukup untuk berdua.
Solusinya bukan menulis pengecekan irisan yang "lebih hati-hati" di application code — solusinya memindahkan jaminan itu ke database, lewat fitur yang memang dirancang untuk masalah ini persis: exclusion constraint. Itu topik utama Bab 3.
Bab 2: Desain Skema: Resource, Jadwal, dan Booking
-- migrations/030_create_booking_schema.sql
CREATE TABLE resources (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL,
type VARCHAR(50) -- 'meeting_room' | 'staff' | 'equipment', dll — bebas sesuai domain
);
-- Jam operasional mingguan per resource
CREATE TABLE business_hours (
id SERIAL PRIMARY KEY,
resource_id INT REFERENCES resources(id),
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0 = Minggu
start_time TIME NOT NULL,
end_time TIME NOT NULL
);
-- Pengecualian: libur nasional, cuti, maintenance
CREATE TABLE schedule_exceptions (
id SERIAL PRIMARY KEY,
resource_id INT REFERENCES resources(id),
exception_date DATE NOT NULL,
is_closed BOOLEAN NOT NULL DEFAULT true,
note TEXT
);
CREATE TABLE bookings (
id SERIAL PRIMARY KEY,
resource_id INT REFERENCES resources(id),
customer_id INT REFERENCES users(id),
time_range TSTZRANGE NOT NULL, -- rentang waktu booking, timezone-aware
status VARCHAR(20) NOT NULL DEFAULT 'confirmed', -- 'confirmed' | 'cancelled'
created_at TIMESTAMPTZ DEFAULT NOW()
);TSTZRANGE (timestamp with time zone range) adalah tipe data range bawaan PostgreSQL — satu kolom menyimpan waktu mulai dan selesai sekaligus, dengan operator bawaan untuk mengecek irisan (&&). Ini yang membuat Bab 3 bisa bekerja tanpa logika irisan manual sama sekali.
-- Contoh insert: booking 27 September 2026, 10:00-11:00 WIB
INSERT INTO bookings (resource_id, customer_id, time_range)
VALUES (1, 42, '[2026-09-27 10:00+07, 2026-09-27 11:00+07)');Notasi [...) berarti batas awal inklusif, batas akhir eksklusif — konvensi yang membuat booking 10:00–11:00 dan 11:00–12:00 dianggap tidak beririsan (persis perilaku yang kita mau: jam 11:00 adalah akhir booking pertama sekaligus awal booking kedua, bukan tumpang tindih).
Bab 3: Deteksi Konflik dengan Exclusion Constraint
Satu Baris Constraint, Bukan Logika Aplikasi
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings
ADD CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (
resource_id WITH =,
time_range WITH &&
)
WHERE (status = 'confirmed');Baris ini memberitahu PostgreSQL: tidak boleh ada dua baris dengan resource_id yang sama DAN time_range yang beririsan (&&), selama kedua baris itu status = 'confirmed'. btree_gist dibutuhkan supaya index GiST bisa membandingkan kolom resource_id yang tipenya biasa (integer) berdampingan dengan time_range yang tipe range.
// src/routes/bookings.js
router.post('/bookings', async (req, res, next) => {
const { resourceId, startTime, endTime, customerId } = req.body;
try {
const { rows } = await db.query(
`INSERT INTO bookings (resource_id, customer_id, time_range)
VALUES ($1, $2, tstzrange($3, $4, '[)'))
RETURNING id`,
[resourceId, customerId, startTime, endTime]
);
res.status(201).json({ bookingId: rows[0].id });
} catch (err) {
if (err.code === '23P01') { // exclusion_violation
return res.status(409).json({ error: 'Slot ini sudah dipesan, pilih jam lain.' });
}
next(err);
}
});Tidak ada SELECT untuk cek konflik sebelum INSERT. Percobaan INSERT adalah pengecekannya — kalau beririsan dengan booking confirmed lain di resource yang sama, PostgreSQL menolak dengan error code 23P01 (exclusion_violation), yang kita tangkap dan ubah jadi pesan 409 yang jelas. Ini menghilangkan celah race condition sepenuhnya, karena constraint dicek oleh database di level baris yang sama, atomik, terlepas dari berapa banyak request yang mencoba bersamaan.
Kenapa ini lebih baik daripada
SELECT ... FOR UPDATEseperti di artikel POS? Untuk kasus stok, ada barisoutlet_stockyang eksis lebih dulu untuk dikunci. Untuk booking, tidak ada baris yang "sudah ada" untuk direpresentasikan slot kosong — booking baru justru baris pertama yang muncul untuk waktu itu. Exclusion constraint menyelesaikan masalah yang secara struktural berbeda: mencegah dua baris baru saling bertabrakan, bukan mengunci satu baris yang sudah ada.
Bab 4: Jadwal Berulang dan Pengecualian
Constraint di Bab 3 mencegah dua booking bertabrakan satu sama lain — tapi tidak tahu apakah resource-nya memang buka di jam itu. Itu tanggung jawab lapisan validasi terpisah, sebelum INSERT dicoba sama sekali.
// src/services/availability.js
export async function isResourceOpen(resourceId, startTime, endTime) {
const date = startTime.toISOString().slice(0, 10);
const dayOfWeek = startTime.getDay();
const exception = await db.query(
`SELECT is_closed FROM schedule_exceptions
WHERE resource_id=$1 AND exception_date=$2`,
[resourceId, date]
);
if (exception.rows[0]?.is_closed) return false;
const hours = await db.query(
`SELECT start_time, end_time FROM business_hours
WHERE resource_id=$1 AND day_of_week=$2`,
[resourceId, dayOfWeek]
);
if (!hours.rows[0]) return false; // tidak ada jam operasional terdaftar hari itu
const startTimeOnly = startTime.toTimeString().slice(0, 8);
const endTimeOnly = endTime.toTimeString().slice(0, 8);
return startTimeOnly >= hours.rows[0].start_time && endTimeOnly <= hours.rows[0].end_time;
}// dipanggil sebelum INSERT di Bab 3
if (!(await isResourceOpen(resourceId, new Date(startTime), new Date(endTime)))) {
return res.status(400).json({ error: 'Resource tidak beroperasi di jam yang diminta' });
}schedule_exceptions dicek lebih dulu (lebih spesifik — satu tanggal), baru business_hours (lebih umum — pola mingguan). Urutan ini penting: hari libur nasional yang jatuh di hari Senin tetap harus menutup resource walau business_hours bilang Senin buka jam 09:00–17:00.
Bab 5: Reminder Otomatis dengan Delayed Job
Reminder "H-1 sebelum booking" bukan sesuatu yang dicek dengan polling ("tiap menit, cek semua booking, kirim reminder yang jatuh tempo") — itu boros dan tidak presisi. Pola yang tepat: jadwalkan satu delayed job per booking, persis di waktu reminder harus dikirim, memakai BullMQ (sudah dibahas setup-nya di artikel Message Queue & Background Jobs).
// src/services/reminders.js
import { Queue } from 'bullmq';
const reminderQueue = new Queue('booking-reminders', { connection: redisConnection });
export async function scheduleReminder(bookingId, startTime) {
const reminderTime = new Date(startTime).getTime() - 24 * 60 * 60 * 1000; // H-1
const delay = Math.max(reminderTime - Date.now(), 0);
await reminderQueue.add(
'send-reminder',
{ bookingId },
{ delay, jobId: `reminder-${bookingId}` } // jobId unik — memudahkan pembatalan di Bab 6
);
}// src/workers/reminderWorker.js
import { Worker } from 'bullmq';
new Worker('booking-reminders', async (job) => {
const booking = await getBookingById(job.data.bookingId);
if (!booking || booking.status === 'cancelled') return; // sudah dibatalkan, skip
await sendReminderNotification(booking); // email/WA/push, sesuai channel yang dipakai
}, { connection: redisConnection });Pengecekan booking.status === 'cancelled' di dalam worker penting — antara job dijadwalkan dan job dieksekusi (bisa berjarak berhari-hari), booking-nya mungkin sudah dibatalkan. Jangan asumsikan data saat job dibuat masih berlaku saat job benar-benar berjalan.
Bab 6: Pembatalan dan Pelepasan Slot
router.post('/bookings/:id/cancel', async (req, res, next) => {
const booking = await getBookingById(req.params.id);
if (!booking) return res.status(404).json({ error: 'Booking tidak ditemukan' });
const hoursUntilBooking = (new Date(booking.startTime) - Date.now()) / (1000 * 60 * 60);
const MIN_CANCELLATION_HOURS = 2;
if (hoursUntilBooking < MIN_CANCELLATION_HOURS) {
return res.status(400).json({
error: `Pembatalan minimal ${MIN_CANCELLATION_HOURS} jam sebelum jadwal`,
});
}
await db.query(`UPDATE bookings SET status='cancelled' WHERE id=$1`, [booking.id]);
await reminderQueue.remove(`reminder-${booking.id}`); // batalkan reminder yang sudah dijadwalkan
res.json({ cancelled: true });
});Perhatikan: membatalkan booking cuma mengubah status, bukan menghapus baris (DELETE). Ini penting karena dua alasan — riwayat booking (termasuk yang batal) sering dibutuhkan untuk laporan, dan constraint di Bab 3 secara otomatis "melepas" slot begitu status berubah jadi selain 'confirmed' (ingat klausa WHERE (status = 'confirmed') di exclusion constraint) — booking baru di jam yang sama sekarang bisa masuk tanpa perlu logika pelepasan slot manual apa pun.
reminderQueue.remove(...) memakai jobId yang sama seperti saat dijadwalkan di Bab 5 — ini kenapa memberi jobId eksplisit itu bukan detail kosmetik, tapi yang membuat pembatalan job sespesifik ini mungkin dilakukan.
Bab 7: Waitlist: Memanfaatkan Slot yang Dibatalkan
Slot populer (jam makan siang, akhir pekan) sering penuh — daripada pelanggan yang gagal booking langsung pergi, tawarkan masuk waitlist untuk slot itu.
CREATE TABLE waitlist_entries (
id SERIAL PRIMARY KEY,
resource_id INT REFERENCES resources(id),
customer_id INT REFERENCES users(id),
desired_range TSTZRANGE NOT NULL,
notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);// Dipanggil setelah UPDATE status='cancelled' di Bab 6
async function notifyWaitlistForFreedSlot(resourceId, timeRange) {
const { rows } = await db.query(
`SELECT id, customer_id FROM waitlist_entries
WHERE resource_id=$1 AND desired_range && $2 AND notified_at IS NULL
ORDER BY created_at ASC
LIMIT 1`, // yang paling dulu daftar, ditawarkan duluan
[resourceId, timeRange]
);
if (!rows[0]) return;
await notifyQueue.add('waitlist-slot-available', { waitlistEntryId: rows[0].id });
await db.query(`UPDATE waitlist_entries SET notified_at=NOW() WHERE id=$1`, [rows[0].id]);
}Sengaja tidak langsung membuatkan booking otomatis untuk orang waitlist teratas — cuma mengirim notifikasi "slot ini tersedia, buruan booking". Auto-booking terdengar lebih mulus, tapi berarti mengambil keputusan pembayaran/komitmen atas nama pelanggan tanpa konfirmasi eksplisit mereka saat itu — pelanggan yang sudah daftar waitlist tiga hari lalu belum tentu masih butuh slot itu sekarang. Notifikasi + booking manual dari pelanggan tetap lewat endpoint Bab 3 yang sama, exclusion constraint yang sama pula yang menjamin cuma satu orang dari waitlist yang berhasil kalau beberapa dari mereka mencoba booking bersamaan setelah dapat notifikasi.
Bab 8: Kenapa Constraint Database, Bukan Cuma Validasi di Aplikasi
Pola di seluruh artikel ini punya satu benang merah yang sama dengan artikel POS: jaminan yang benar-benar berlaku di bawah konkurensi diletakkan di database (EXCLUDE, CHECK, UNIQUE), bukan cuma di validasi application code. Ini bukan soal tidak percaya kualitas kode aplikasi — ini soal application code kamu mungkin berjalan di banyak instance sekaligus (PM2 cluster mode, beberapa container), dan tidak ada cara bagi satu instance untuk tahu apa yang sedang dilakukan instance lain tanpa mekanisme koordinasi eksplisit. Database yang diakses bersama oleh semua instance itulah satu-satunya titik yang benar-benar tahu keadaan sebenarnya di setiap saat.
Validasi di application code (Bab 4 — cek jam operasional) tetap punya tempatnya: itu bukan soal mencegah dua hal bertabrakan, tapi soal aturan bisnis yang tidak butuh atomisitas lintas-request. Aturan seperti "boleh dibatalkan minimal 2 jam sebelumnya" juga cukup di application code, karena tidak ada race condition di sana — satu booking cuma dibatalkan oleh satu aksi, tidak diperebutkan banyak pihak sekaligus seperti slot waktu itu sendiri.
Penutup
Sistem booking ini kelihatannya seperti CRUD sederhana dari luar — tabel bookings, endpoint create/cancel — tapi jaminan yang membuatnya bisa dipercaya (tidak pernah double-booking, walau di bawah traffic tinggi dan banyak instance server) semuanya bersandar pada satu baris EXCLUDE USING gist di Bab 3. Kalau kamu cuma ingat satu hal dari artikel ini: begitu masalahmu berbentuk "dua hal tidak boleh saling tumpang tindih" — waktu, kapasitas, sumber daya apa pun — cari dulu apakah database yang kamu pakai punya constraint bawaan untuk itu, sebelum menulis logika pengecekan sendiri yang gampang bocor di bawah konkurensi nyata.
Arsitektur Final
Client ──▶ POST /bookings
│
▼
isResourceOpen() ──▶ cek schedule_exceptions, lalu business_hours
│
▼ (kalau buka)
INSERT INTO bookings ... ──▶ EXCLUDE constraint (resource_id =, time_range &&)
│ │
│ └─▶ 23P01 exclusion_violation → 409 "slot sudah dipesan"
▼ (kalau lolos)
scheduleReminder() ──▶ BullMQ delayed job (jobId: reminder-{id})
Cancel ──▶ UPDATE status='cancelled' ──▶ constraint otomatis lepas slot
├─▶ reminderQueue.remove(jobId)
└─▶ notifyWaitlistForFreedSlot()
Production Checklist
Langkah selanjutnya: artikel Integrasi Payment Gateway melengkapi sistem ini begitu booking perlu terima deposit/pembayaran di muka — webhook handling dan idempotency di artikel itu penting terutama karena kombinasi "pembayaran gagal tapi slot sudah ke-booking" (atau sebaliknya) adalah kelas bug yang sama merepotkannya dengan double-booking yang baru saja kita selesaikan.