Mengapa Skema Awal Menentukan Segalanya
Ada satu kebenaran pahit dalam pengembangan sistem berbasis data: refactor skema tabel di production itu mahal, lambat, dan berisiko. ALTER TABLE pada tabel dengan 50 juta baris di MySQL bisa memakan waktu berjam-jam dan mengunci tabel. Migrasi data yang salah bisa menyebabkan kehilangan data permanen.
Bukan berarti kita harus merancang skema sempurna sebelum satu baris kode pun ditulis — over-engineering di awal juga berbahaya. Yang diperlukan adalah kebiasaan dasar yang membuat skema mudah tumbuh tanpa perlu dibongkar. Itulah yang akan kita bahas melalui checklist ini.
Penamaan yang Konsisten dan Prediktabel
Penamaan yang konsisten adalah bentuk dokumentasi paling murah. Developer baru harus bisa menebak nama kolom hanya dari konteksnya, tanpa perlu membaca dokumentasi.
Aturan yang disarankan
- Gunakan
snake_caseuntuk semua nama tabel dan kolom — hindaricamelCaseatauPascalCase - Nama tabel: plural noun —
users,orders,order_items - Kolom FK:
{tabel_singular}_id— contoh:user_id,product_id - Kolom boolean: awalan
is_atauhas_—is_active,has_verified_email - Hindari singkatan kecuali sudah konvensi industri (
sku,url,ip) - Hindari reserved words sebagai nama kolom:
order,status,value,name— atau gunakan nama yang lebih spesifik
-- ✗ Hindari: inkonsisten, ambigu, singkatan berlebihan CREATE TABLE tbl_User ( UserID INT, Uname VARCHAR(50), stat TINYINT, Dt_Crt DATETIME ); -- ✓ Lebih baik: konsisten, deskriptif, mudah ditebak CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(80) NOT NULL UNIQUE, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );
Primary Key: Surrogate vs Natural
Natural key adalah kolom dengan makna bisnis yang unik: NIM mahasiswa, NIK penduduk,
kode produk. Surrogate key adalah kolom buatan tanpa makna bisnis, biasanya
AUTO_INCREMENT atau UUID.
Rekomendasi umum: selalu gunakan surrogate key sebagai PK, dan jadikan natural key
sebagai UNIQUE constraint. Natural key sering berubah (NIM bisa di-reformat, kode produk
bisa di-rename), dan perubahan PK yang direferensikan oleh FK bisa menjadi operasi yang sangat mahal.
BIGINT vs INT vs UUID
INT UNSIGNED: maksimal ~4,2 miliar baris — terlalu kecil untuk sistem yang tumbuhBIGINT UNSIGNED: maksimal ~18,4 kuintiliun — aman untuk hampir semua use caseUUID: bagus untuk distributed systems, tapi index B-tree pada UUID acak lebih lambat karena pola insert tidak berurutan. Gunakan UUID v7 (time-ordered) atauULIDjika perlu UUID
CREATE TABLE mahasiswa ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, nim VARCHAR(12) NOT NULL UNIQUE, -- natural key jadi UNIQUE, bukan PK nama VARCHAR(255) NOT NULL, program_studi VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );
Pilih Tipe Data Paling Kecil yang Cukup
Tipe data yang tepat bukan hanya soal validasi — ia langsung mempengaruhi ukuran penyimpanan, kecepatan perbandingan, dan efisiensi index. Beberapa panduan kritis:
- Nilai moneter: Selalu gunakan
DECIMAL(15, 2). FLOAT/DOUBLE menyimpan representasi biner yang bisa menghasilkan error pembulatan (0.1 + 0.2 ≠ 0.3dalam floating point) - Enum: Hindari
ENUMdi MySQL untuk nilai yang mungkin bertambah — ALTER TABLE pada tabel besar sangat lambat. GunakanVARCHAR+ CHECK constraint - TIMESTAMP vs DATETIME:
TIMESTAMPdisimpan dalam UTC dan di-convert otomatis sesuai zona waktu sesi (range: 1970–2038).DATETIMEdisimpan apa adanya (range: 1000–9999). Untukcreated_at/updated_at,TIMESTAMPlebih praktis - Text panjang: VARCHAR untuk teks prediktabel (max ~500 karakter), TEXT untuk konten panjang tidak terbatas
- Boolean:
TINYINT(1)di MySQL,BOOLEANdi PostgreSQL
CREATE TABLE produk ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, nama VARCHAR(255) NOT NULL, sku VARCHAR(50) NOT NULL UNIQUE, harga DECIMAL(15, 2) NOT NULL, -- bukan FLOAT stok INT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'aktif', -- bukan ENUM deskripsi TEXT, -- nullable, panjang tidak terbatas is_featured TINYINT(1) NOT NULL DEFAULT 0 );
IS NULL / IS NOT NULL), dan beberapa agregasi fungsi
mengabaikan NULL secara senyap. Jadikan kolom NOT NULL kecuali NULL memang semantis valid.
Constraint sebagai Dokumentasi Aktif
Constraint adalah aturan bisnis yang di-enforce langsung oleh database engine — bukan hanya di lapisan aplikasi. Ini adalah "dokumentasi yang tidak bisa basi" karena database akan menolak data yang melanggar aturan, tanpa perlu kode validasi tambahan.
CREATE TABLE pesanan ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, total DECIMAL(15, 2) NOT NULL CHECK (total >= 0), -- MySQL 8.0.16+ status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'proses', 'selesai', 'dibatalkan')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_pesanan_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT -- cegah hapus user yang punya pesanan ON UPDATE CASCADE );
CONSTRAINT fk_pesanan_user).
Tanpa nama, MySQL akan membuat nama random yang sulit di-debug saat error muncul di log.
Normalisasi sampai 3NF — tapi Tahu Kapan Berhenti
Normal Form adalah kumpulan aturan untuk menghilangkan redundansi data. Tiga level pertama cukup untuk hampir semua OLTP (Online Transaction Processing):
- 1NF: Setiap sel berisi satu nilai atomik. Tidak ada kolom berulang seperti
tag_1,tag_2,tag_3— buat tabel relasi terpisah - 2NF: Tidak ada partial dependency. Berlaku ketika PK komposit ada — setiap kolom non-key harus bergantung pada seluruh PK, bukan sebagian
- 3NF: Tidak ada transitive dependency. Jika
kotabergantung padakode_pos, dankode_posbergantung pada PK — pisahkan kode_pos ke tabel sendiri
Timestamp Standar di Setiap Tabel
Dua kolom ini wajib ada di setiap tabel produski: created_at dan
updated_at. Biaya storage-nya sangat kecil dibanding manfaat auditability-nya yang
besar — kapan data dibuat, kapan terakhir diubah.
CREATE TABLE contoh_tabel ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- ... kolom lain ... created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- Untuk audit yang lebih lengkap, tambahkan: created_by BIGINT UNSIGNED NULL, -- user_id yang membuat updated_by BIGINT UNSIGNED NULL -- user_id yang terakhir mengubah
Soft Delete dengan deleted_at
Hard delete (DELETE query betulan) membuat data hilang permanen — tidak bisa audit, tidak bisa pulihkan. Soft delete menandai baris sebagai "terhapus" dengan kolom timestamp sehingga data tetap ada di database dan bisa dipulihkan.
-- Tambahkan kolom ini ke tabel yang butuh soft delete deleted_at TIMESTAMP NULL DEFAULT NULL, -- NULL = aktif, timestamp = terhapus -- Query data aktif (paling sering dipakai) SELECT * FROM users WHERE deleted_at IS NULL; -- Soft delete: set timestamp, bukan DELETE UPDATE users SET deleted_at = NOW() WHERE id = 42; -- Restore UPDATE users SET deleted_at = NULL WHERE id = 42; -- Index parsial untuk performa (PostgreSQL) CREATE INDEX idx_users_active ON users (id) WHERE deleted_at IS NULL; -- hanya index baris aktif
SoftDeletes. Cukup tambahkan kolom deleted_at dan trait tersebut,
dan semua query otomatis menambahkan WHERE deleted_at IS NULL.
Strategi Indeks Sejak Awal
Indeks mempercepat baca tapi memperlambat tulis (INSERT/UPDATE/DELETE harus update index). Yang perlu di-index sejak awal:
- Semua kolom FK — MySQL tidak membuat index FK otomatis untuk kolom referensi
- Kolom yang sering di-filter dalam WHERE:
status,created_at - Kolom yang sering di-ORDER BY dan di-GROUP BY
- Kolom pencarian teks: pertimbangkan FULLTEXT index untuk kolom nama/deskripsi
-- Index FK (paling sering terlupakan) CREATE INDEX idx_pesanan_user_id ON pesanan (user_id); CREATE INDEX idx_pesanan_produk_id ON pesanan (produk_id); -- Index untuk filter umum CREATE INDEX idx_pesanan_status ON pesanan (status); CREATE INDEX idx_pesanan_created_at ON pesanan (created_at); -- Composite index untuk query yang sudah diketahui polanya CREATE INDEX idx_pesanan_user_status ON pesanan (user_id, status, created_at);
Checklist Final
Gunakan checklist ini sebelum deploy migrasi baru ke production:
snake_caseuntuk semua nama tabel dan kolom- Nama tabel plural, kolom FK berformat
{singular}_id BIGINT UNSIGNED AUTO_INCREMENTsebagai PK- Natural key dijadikan
UNIQUEconstraint, bukan PK DECIMALuntuk semua nilai moneter — bukan FLOATVARCHAR+ CHECK untuk status/enum yang bisa berkembang- Semua kolom
NOT NULLkecuali NULL memang valid secara semantis - CHECK constraint untuk validasi nilai (
total >= 0, dsb.) - FK dengan nama constraint eksplisit dan ON DELETE/UPDATE yang tepat
created_atdanupdated_atdi setiap tabeldeleted_atuntuk tabel yang memerlukan soft delete- Index semua kolom FK
- Composite index untuk pola query yang sudah diketahui
- Skema sudah di-review minimal satu orang lain sebelum deploy