Constraint Database: Pertahanan Terakhir yang Sering Diabaikan

Hampir semua developer yang saya temui, termasuk saya sendiri di masa awal karier, punya keyakinan yang sama: kalau validasi di aplikasi sudah rapi, database tinggal jadi tempat penyimpanan saja. Di Laravel misalnya, kita terbiasa mengandalkan $request->validate() atau Form Request, lalu merasa cukup aman. Padahal validasi aplikasi itu sifatnya request-scoped — ia hanya berlaku untuk jalur masuk data yang kita kontrol. Begitu ada jalur lain yang menyentuh data yang sama, semua asumsi itu runtuh.

Kenapa validasi aplikasi saja tidak cukup

Aplikasi modern jarang punya satu pintu masuk data. Ada job antrian, ada command artisan untuk migrasi data lama, ada proses sinkronisasi dari sistem lain, ada developer lain yang iseng menjalankan tinker saat debugging produksi, atau ada service kedua yang mengakses database yang sama. Semua jalur itu, kalau tidak lewat kode validasi yang sama persis, bisa menyelipkan data yang tidak konsisten. Race condition juga masalah klasik: dua request datang hampir bersamaan, keduanya lolos pengecekan exists() sebelum insert, dan hasilnya dua baris yang seharusnya unik malah dobel. Validasi aplikasi berjalan di level proses, sementara constraint database berjalan di level transaksi — dan itu bedanya jauh.

Constraint yang wajib ada di level database

Beberapa constraint yang menurut saya tidak boleh ditawar, terlepas seberapa yakin kita pada validasi aplikasi:

  • NOT NULL — kolom yang secara bisnis wajib ada isinya, jangan biarkan nullable hanya karena migrasi lama malas diubah.
  • UNIQUE — untuk identitas seperti nomor rekam medis, email, atau kode referensi. Ini yang paling sering saya lihat gagal karena hanya mengandalkan pengecekan aplikasi.
  • FOREIGN KEY — memastikan relasi antar tabel tidak pernah menunjuk ke baris yang sudah dihapus, apalagi kalau ada proses hapus data yang lupa mempertimbangkan relasi anak.
  • CHECK constraint — untuk aturan sederhana seperti rentang nilai atau enum, sekarang sudah didukung baik di MySQL maupun PostgreSQL dan sayang kalau tidak dipakai.

Contoh ilustrasi di migrasi Laravel:

Schema::create('pasien', function (Blueprint $table) {\n    $table->id();\n    $table->string('nomor_rm')->unique();\n    $table->foreignId('poli_id')->constrained();\n});

Baris kode ini sederhana, tapi efeknya besar: sekalipun ada bug di validasi aplikasi, atau ada proses lain yang menulis langsung ke tabel, database akan menolak data yang melanggar aturan.

Pola yang saya pakai: dua lapis, bukan pilih salah satu

Saya tidak pernah menganggap ini sebagai "validasi aplikasi vs constraint database". Keduanya punya peran berbeda. Validasi aplikasi memberi pesan error yang ramah untuk pengguna, sebelum request diproses lebih jauh — jadi pengalaman penggunanya baik. Constraint database adalah jaring pengaman terakhir yang memastikan, apa pun yang terjadi di lapisan aplikasi, integritas data tetap terjaga. Kalau constraint di database sampai terpicu di produksi, itu justru sinyal berharga bahwa ada jalur di aplikasi yang bocor dan perlu ditelusuri, bukan sesuatu yang harus dihindari dengan cara melonggarkan constraint.

Satu kebiasaan yang saya sarankan: setiap kali menulis migrasi baru, tanyakan dulu "constraint apa yang menjamin data ini selalu valid, terlepas dari kode yang menulisnya?" — baru setelah itu pikirkan validasi di sisi aplikasi untuk pengalaman pengguna.

Takeaway

Validasi aplikasi menjaga pengalaman pengguna, constraint database menjaga integritas data. Keduanya perlu jalan bersama, dan constraint di database adalah investasi kecil di awal yang mencegah masalah besar yang jauh lebih mahal untuk diperbaiki setelah data sudah telanjur tidak konsisten.