Data Integrity #

Database adalah single source of truth. Aplikasi bisa berganti teknologi, arsitektur bisa berevolusi dari monolith ke microservices, tim bisa berganti-ganti anggota — tapi data di database adalah satu-satunya hal yang harus tetap benar di tengah semua perubahan itu. Masalahnya, data yang rusak jarang muncul sebagai error yang jelas. Ia muncul diam-diam: laporan keuangan yang selisih satu persen, order yang tidak bisa dilacak, transaksi yang terhitung dua kali tanpa ada yang menyadari. Pada saat masalah itu ditemukan, data yang rusak sudah menyebar ke mana-mana — dan memperbaikinya butuh waktu, tenaga, dan keberanian yang jauh lebih besar dari mencegahnya sejak awal.

Data integrity adalah sekumpulan mekanisme yang memastikan data tetap akurat, konsisten, dan valid — kapan pun dan dari mana pun data itu diakses atau dimodifikasi. Bukan sekadar konsep teoritis, data integrity adalah fondasi operasional dari sistem yang bisa dipercaya dalam jangka panjang.

Mengapa Data Integrity Sering Diabaikan #

Sebelum membahas solusi, perlu dipahami mengapa banyak tim mengabaikan data integrity sampai semuanya terlambat. Polanya hampir selalu sama.

Di awal project, tim bergerak cepat. Constraint, foreign key, dan validasi di level database dianggap “nanti saja — yang penting fitur jalan dulu”. Validasi dipindahkan sepenuhnya ke application layer karena terasa lebih fleksibel dan lebih cepat untuk diubah. Schema database dibuat seminimal mungkin, kolom dibuat nullable semua “biar tidak ribet”, dan foreign key sengaja tidak dipasang “biar insert-nya cepat”.

Enam bulan kemudian, sistem sudah diakses oleh tiga service berbeda. Masing-masing service punya validasi sendiri — dan masing-masing punya interpretasi sedikit berbeda tentang aturan bisnis yang sama. Data mulai inkonsisten. Ada order tanpa user. Ada transaksi dengan amount negatif. Ada baris dengan status yang secara logika tidak mungkin — tapi tidak ada yang tahu sejak kapan itu terjadi.

Inilah biaya sebenarnya dari mengabaikan data integrity di awal: bukan masalah yang muncul sekarang, tapi masalah yang baru ketahuan berbulan-bulan kemudian, sudah menyebar ke jutaan baris data, dan hampir tidak mungkin di-rollback.


Empat Jenis Data Integrity #

Data integrity bukan satu konsep tunggal. Ada empat jenis yang masing-masing melindungi aspek berbeda dari data kamu.

Entity Integrity #

Entity integrity memastikan bahwa setiap baris di dalam tabel bisa diidentifikasi secara unik dan tidak ambigu. Ini dicapai melalui primary key — setiap tabel wajib punya satu, dan primary key tidak boleh null.

Tanpa entity integrity, kamu tidak bisa menjawab pertanyaan sederhana seperti “berapa jumlah user aktif?” — karena mungkin ada duplikat yang tidak bisa dibedakan. Atau kamu tidak bisa melakukan join yang benar karena tidak ada kolom yang bisa dijadikan rujukan pasti.

-- ANTI-PATTERN: tabel tanpa primary key
CREATE TABLE payments (
    amount       DECIMAL(15,2),
    status       VARCHAR(20),
    created_at   TIMESTAMP
);
-- Tidak ada cara untuk mengidentifikasi satu baris secara unik.
-- Duplikat tidak bisa dideteksi. Join tidak bisa diandalkan.

-- BENAR: selalu definisikan primary key yang eksplisit
CREATE TABLE payments (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    amount       DECIMAL(15,2)   NOT NULL,
    status       VARCHAR(20)     NOT NULL,
    created_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
);

Untuk sistem baru, pertimbangkan menggunakan UUID sebagai primary key jika data akan didistribusikan ke beberapa database atau service. UUID menghilangkan risiko collision ketika data dari beberapa sumber digabungkan.

-- Alternatif dengan UUID untuk sistem terdistribusi
CREATE TABLE payments (
    id           CHAR(36)        NOT NULL,
    amount       DECIMAL(15,2)   NOT NULL,
    status       VARCHAR(20)     NOT NULL,
    created_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
);

Referential Integrity #

Referential integrity memastikan bahwa relasi antar tabel selalu valid. Jika tabel orders punya kolom user_id, maka setiap nilai user_id di tabel orders harus merujuk ke baris yang benar-benar ada di tabel users. Tidak boleh ada order yang menggantung di udara tanpa pemilik yang jelas.

Ini dicapai melalui foreign key constraint. Dan ini adalah salah satu mekanisme yang paling sering sengaja dihilangkan dengan alasan performa — padahal kerusakan yang ditimbulkan jauh lebih mahal dari overhead yang dikurangi.

-- ANTI-PATTERN: relasi antar tabel tanpa foreign key
CREATE TABLE orders (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id    BIGINT UNSIGNED NOT NULL, -- angka biasa, bukan foreign key
    total      DECIMAL(15,2)   NOT NULL,
    PRIMARY KEY (id)
);
-- Tidak ada yang mencegah user_id berisi nilai yang tidak ada di tabel users.
-- Jika user dihapus, semua ordernya menjadi orphan — tidak ada pemilik.

-- BENAR: definisikan foreign key secara eksplisit dengan behavior yang jelas
CREATE TABLE orders (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id    BIGINT UNSIGNED NOT NULL,
    total      DECIMAL(15,2)   NOT NULL,
    created_at TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE RESTRICT   -- tolak penghapusan user yang masih punya order
        ON UPDATE CASCADE    -- update user_id otomatis jika id user berubah
);

Pilihan ON DELETE dan ON UPDATE harus diputuskan berdasarkan aturan bisnis, bukan asal pilih:

ON DELETE RESTRICT  → Tolak penghapusan parent jika masih ada child.
                      Gunakan untuk data core yang tidak boleh hilang begitu saja.
                      Contoh: user yang masih punya order aktif.

ON DELETE CASCADE   → Hapus semua child otomatis ketika parent dihapus.
                      Gunakan hanya jika child tidak punya makna tanpa parent.
                      Contoh: session log yang dihapus bersama usernya.

ON DELETE SET NULL  → Set kolom foreign key ke NULL ketika parent dihapus.
                      Gunakan jika relasi memang opsional secara bisnis.
                      Contoh: artikel yang authornya sudah tidak aktif.
Jangan menggunakan ON DELETE CASCADE untuk data finansial atau transaksional. Jika sebuah user terhapus dan semua payment record-nya ikut terhapus secara otomatis, itu adalah bencana audit yang tidak bisa dipulihkan. Gunakan RESTRICT untuk data yang punya konsekuensi bisnis dan legal.

Domain Integrity #

Domain integrity memastikan bahwa nilai di setiap kolom sesuai dengan domain yang sudah didefinisikan — baik dari sisi tipe data, range nilai, maupun aturan bisnis yang berlaku.

Kolom amount pada tabel payment tidak boleh negatif. Kolom status hanya boleh berisi nilai yang sudah didefinisikan dalam state machine bisnis. Kolom email harus punya format yang valid. Semua ini adalah domain integrity.

-- ANTI-PATTERN: tidak ada validasi domain di level database
CREATE TABLE payments (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    amount     DECIMAL(15,2),    -- nullable, bisa negatif, bisa nol
    status     VARCHAR(50),      -- nullable, bisa berisi apa saja
    PRIMARY KEY (id)
);

-- BENAR: definisikan domain secara eksplisit dengan CHECK constraint
CREATE TABLE payments (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    amount     DECIMAL(15,2)   NOT NULL CHECK (amount > 0),
    status     VARCHAR(20)     NOT NULL CHECK (
                   status IN ('pending', 'processing', 'success', 'failed', 'refunded')
               ),
    created_at TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
);

Untuk kolom enum yang nilainya kemungkinan besar tidak akan berubah, CHECK constraint lebih fleksibel daripada tipe ENUM native MySQL — karena menambah nilai baru di CHECK tidak memerlukan ALTER TABLE yang memblokir tabel di produksi.

Transactional Integrity #

Transactional integrity memastikan bahwa serangkaian operasi database diperlakukan sebagai satu unit yang tidak bisa dipisah — semua berhasil, atau semua dibatalkan. Ini adalah properti atomicity dari ACID.

Tanpa transaction, kamu tidak bisa menjamin konsistensi ketika satu operasi logis melibatkan beberapa tabel sekaligus.

-- ANTI-PATTERN: operasi multi-tabel tanpa transaction
-- Jika INSERT ke order_items berhasil tapi UPDATE ke orders gagal,
-- data sudah dalam state yang inkonsisten dan tidak ada cara untuk rollback.
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (101, 5, 2, 150000);

UPDATE orders
SET total = total + 300000, item_count = item_count + 1
WHERE id = 101;

-- BENAR: bungkus dalam transaction
START TRANSACTION;

INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (101, 5, 2, 150000);

UPDATE orders
SET total = total + 300000, item_count = item_count + 1
WHERE id = 101;

-- Jika ada error di antara keduanya, ROLLBACK membatalkan semua perubahan.
-- Jika semua berhasil, COMMIT membuat perubahan permanen.
COMMIT;

Desain Schema yang Menjaga Data Integrity #

Memahami jenis-jenis integrity adalah satu hal. Menerapkannya dalam desain schema sehari-hari adalah hal yang berbeda. Berikut prinsip-prinsip konkret yang bisa langsung diterapkan.

Kolom Harus NOT NULL Kecuali Ada Alasan Bisnis yang Jelas #

Kolom nullable bukan hanya soal database — ia menciptakan complexity di application layer. Setiap kolom nullable berarti ada kondisi tambahan yang harus ditangani di kode: apakah nilainya null? Apa artinya null dalam konteks ini? Apakah null dan string kosong punya makna berbeda?

-- ANTI-PATTERN: semua kolom nullable karena "lebih fleksibel"
CREATE TABLE users (
    id         BIGINT UNSIGNED,
    name       VARCHAR(100),      -- apakah user tanpa nama valid?
    email      VARCHAR(255),      -- apakah user tanpa email valid?
    created_at TIMESTAMP
);

-- BENAR: NOT NULL adalah default, nullable hanya jika ada alasan bisnis
CREATE TABLE users (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name         VARCHAR(100)    NOT NULL,
    email        VARCHAR(255)    NOT NULL,
    phone        VARCHAR(20)     NULL,     -- opsional secara bisnis
    deleted_at   TIMESTAMP       NULL,     -- null = aktif, ada nilai = sudah dihapus
    created_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY  (id),
    UNIQUE KEY   uk_users_email (email)
);

Aturan praktisnya sederhana: mulai dari NOT NULL untuk semua kolom. Ubah ke nullable hanya jika ada keputusan bisnis eksplisit bahwa kolom itu memang boleh kosong — dan dokumentasikan keputusan itu.

State Machine yang Terdefinisi dengan Jelas #

Kolom status adalah salah satu sumber data corruption yang paling sering diabaikan. Ketika tidak ada aturan yang jelas tentang transisi yang valid, status bisa berubah ke nilai apa pun kapan pun — dan sistem kehilangan kemampuan untuk melacak apakah sebuah state valid secara bisnis.

-- State machine untuk payment yang benar:

  pending
    │
    ├─ (user membayar) ──────────────────→ processing
    │                                          │
    │                                          ├─ (berhasil) ──→ success
    │                                          │
    │                                          └─ (gagal) ─────→ failed
    │
    └─ (expired) ────────────────────────→ expired

  success
    └─ (refund diminta) ─────────────────→ refunded

-- Transisi yang TIDAK BOLEH terjadi:
  ✗ failed → success   (tanpa proses ulang yang jelas)
  ✗ success → pending  (tidak ada alasan bisnis)
  ✗ refunded → failed  (state yang tidak masuk akal)

Validasi transisi ini tidak bisa hanya dilakukan di application layer — karena ada skenario di mana database diakses langsung (migrasi data, admin tool, service lain). Kombinasikan validasi di application dengan CHECK constraint di database untuk perlindungan berlapis.

Jangan Gunakan Satu Kolom untuk Banyak Makna #

Ini adalah anti-pattern yang terlihat “efisien” di awal tapi menjadi mimpi buruk di kemudian hari. Kolom yang maknanya berubah tergantung konteks adalah tanda bahwa schema belum dipikirkan dengan cukup matang.

-- ANTI-PATTERN: kolom "type" yang overloaded
CREATE TABLE transactions (
    id       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    ref_id   BIGINT UNSIGNED NOT NULL,  -- bisa order_id, refund_id, atau topup_id
    type     TINYINT         NOT NULL,  -- 1=payment, 2=refund, 3=topup, 4=???
    amount   DECIMAL(15,2)   NOT NULL,
    PRIMARY KEY (id)
);
-- Untuk query "semua refund bulan ini", kamu harus tahu bahwa type=2 berarti refund.
-- Tidak ada constraint yang mencegah type=9 yang tidak punya definisi.
-- ref_id bisa merujuk ke tabel mana pun tergantung type — tidak bisa dijamin dengan foreign key.

-- BENAR: pisahkan berdasarkan makna bisnis yang sesungguhnya
CREATE TABLE payments (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    order_id   BIGINT UNSIGNED NOT NULL,
    amount     DECIMAL(15,2)   NOT NULL CHECK (amount > 0),
    status     VARCHAR(20)     NOT NULL CHECK (status IN ('pending','success','failed')),
    PRIMARY KEY (id),
    CONSTRAINT fk_payments_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE RESTRICT
);

CREATE TABLE refunds (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    payment_id BIGINT UNSIGNED NOT NULL,
    amount     DECIMAL(15,2)   NOT NULL CHECK (amount > 0),
    reason     TEXT            NOT NULL,
    PRIMARY KEY (id),
    CONSTRAINT fk_refunds_payment FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE RESTRICT
);

Unique Constraint untuk Aturan Bisnis yang Bersifat Unik #

Jika aturan bisnis mengatakan bahwa satu user hanya boleh punya satu akun dengan email yang sama, atau satu produk hanya boleh masuk satu kali ke dalam satu order — itu bukan validasi yang cukup dilakukan di application layer. Itu harus dijaga oleh database.

-- ANTI-PATTERN: aturan uniqueness hanya di application layer
-- Jika ada race condition (dua request masuk bersamaan),
-- application-level check tidak cukup — keduanya bisa lolos sebelum satu pun commit.

-- BENAR: gunakan UNIQUE constraint atau UNIQUE INDEX
-- Unique pada satu kolom:
ALTER TABLE users ADD UNIQUE KEY uk_users_email (email);

-- Unique pada kombinasi kolom (composite unique):
ALTER TABLE order_items
    ADD UNIQUE KEY uk_order_items_order_product (order_id, product_id);
-- Ini memastikan satu produk tidak bisa masuk dua kali ke order yang sama,
-- bahkan jika ada request concurrent yang mencoba melakukan insert bersamaan.

Anti-Pattern yang Harus Dihindari #

Semua anti-pattern di bawah ini punya satu kesamaan: mereka tidak menghasilkan error saat diterapkan, tapi mereka menciptakan utang teknis yang bunganya sangat mahal di masa depan.

-- ✗ Anti-pattern 1: tabel tanpa primary key
-- Tidak ada cara untuk mengidentifikasi baris secara unik.
-- Duplikat tidak bisa dideteksi, join tidak bisa diandalkan.
CREATE TABLE logs (message TEXT, created_at TIMESTAMP);

-- ✓ Solusi: selalu tambahkan primary key
CREATE TABLE logs (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    message    TEXT            NOT NULL,
    created_at TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
);

-- ✗ Anti-pattern 2: foreign key disimulasikan di application layer
-- Service lain yang mengakses database langsung tidak terikat aturan ini.
-- Race condition bisa menghasilkan orphan data.
-- "nanti kita handle di kode" -- kalimat yang melahirkan data yatim.
INSERT INTO orders (user_id, total) VALUES (9999, 150000);
-- user_id 9999 tidak ada di tabel users, tapi database menerimanya.

-- ✓ Solusi: definisikan foreign key di level database
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_user
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;

-- ✗ Anti-pattern 3: validasi hanya di frontend
-- Frontend validation bisa dibypass dengan Postman, curl, atau script.
-- Data yang tidak valid masuk langsung ke database jika tidak ada validasi di server dan database.
-- Ini adalah celah yang paling sering diabaikan.

-- ✓ Solusi: validasi berlapis — frontend + backend + database constraint
-- Frontend: untuk UX yang responsif
-- Backend: untuk business logic dan security
-- Database: sebagai jaring pengaman terakhir

-- ✗ Anti-pattern 4: kolom amount tanpa constraint nilai
CREATE TABLE payments (amount DECIMAL(15,2));
-- amount bisa -500000 (negatif) atau 0. Keduanya tidak valid secara bisnis
-- tapi database menerimanya tanpa protes.

-- ✓ Solusi: tambahkan CHECK constraint
CREATE TABLE payments (
    amount DECIMAL(15,2) NOT NULL CHECK (amount > 0)
);

-- ✗ Anti-pattern 5: soft delete dengan kolom is_deleted = 0/1
-- Query harus selalu filter WHERE is_deleted = 0, mudah lupa, mudah bocor.
-- Tidak ada informasi kapan dihapus dan siapa yang menghapus.
ALTER TABLE users ADD COLUMN is_deleted TINYINT DEFAULT 0;

-- ✓ Solusi: gunakan deleted_at timestamp
-- NULL berarti aktif, ada nilai berarti sudah dihapus.
-- Kapan dihapus tersimpan otomatis, query lebih ekspresif.
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL;
-- Query aktif: WHERE deleted_at IS NULL
-- Query sudah dihapus: WHERE deleted_at IS NOT NULL

Checklist Review Data Integrity #

Gunakan checklist ini saat melakukan schema review, sebelum migration, atau saat onboarding ke database yang sudah ada.

ENTITY INTEGRITY:
  □ Setiap tabel punya PRIMARY KEY yang eksplisit
  □ Primary key bersifat NOT NULL dan UNIQUE
  □ Tidak ada tabel yang dibuat tanpa primary key

REFERENTIAL INTEGRITY:
  □ Setiap kolom yang menyimpan ID dari tabel lain punya FOREIGN KEY constraint
  □ Behavior ON DELETE dan ON UPDATE sudah diputuskan secara sadar, bukan default
  □ ON DELETE CASCADE tidak digunakan untuk data finansial atau transaksional
  □ Tidak ada orphan data — semua foreign key merujuk ke baris yang valid

DOMAIN INTEGRITY:
  □ Semua kolom wajib bersifat NOT NULL
  □ Kolom nullable punya alasan bisnis yang terdokumentasi
  □ Kolom status punya CHECK constraint dengan daftar nilai yang valid
  □ Kolom amount/quantity/price punya CHECK constraint untuk range yang valid
  □ Kolom yang harus unik punya UNIQUE constraint atau UNIQUE index

TRANSACTIONAL INTEGRITY:
  □ Operasi yang melibatkan lebih dari satu tabel dibungkus dalam transaction
  □ Error handling dalam transaction sudah benar (ROLLBACK pada exception)
  □ Isolation level sudah sesuai dengan kebutuhan concurrency

DESAIN UMUM:
  □ Tidak ada kolom yang overloaded (satu kolom untuk banyak makna)
  □ State machine sudah terdefinisi — transisi mana yang valid dan mana yang tidak
  □ Soft delete menggunakan deleted_at timestamp, bukan flag boolean
  □ Setiap keputusan "kenapa tidak pakai constraint" terdokumentasi

Ringkasan #

  • Data yang rusak lebih berbahaya dari error yang jelas — silent data corruption tidak crash, tapi ia menyebar diam-diam dan hampir tidak bisa dipulihkan sepenuhnya.
  • Entity integrity dijaga oleh PRIMARY KEY — setiap tabel wajib punya, tidak boleh nullable.
  • Referential integrity dijaga oleh FOREIGN KEY — jangan tinggalkan relasi antar tabel tanpa penjaga; pilih ON DELETE RESTRICT untuk data yang punya konsekuensi bisnis.
  • Domain integrity dijaga oleh NOT NULL, CHECK constraint, dan UNIQUE — validasi di database adalah jaring pengaman terakhir yang tidak bisa dibypass.
  • Transactional integrity dijaga oleh TRANSACTION — semua operasi multi-tabel harus dibungkus transaction agar tidak ada state yang setengah jadi.
  • NOT NULL adalah default, nullable adalah pengecualian — semakin sedikit null dalam schema, semakin sederhana logic aplikasi dan semakin kecil ruang untuk data yang ambigu.
  • Foreign key bukan musuh performa — overhead yang ditambahkan jauh lebih kecil dari biaya investigasi dan perbaikan orphan data di produksi.
  • Desain state machine secara eksplisit — tentukan transisi mana yang valid, dan validasi transisi itu di application layer maupun database constraint.
  • Satu kolom, satu makna — kolom yang overloaded adalah tanda schema yang belum matang dan sumber bug yang sulit dilacak.
  • Constraint adalah alarm dini — mereka memaksa error muncul di tempat yang benar dan pada waktu yang tepat, bukan berbulan-bulan kemudian dalam bentuk data yang tidak bisa dijelaskan.

← Sebelumnya: SPOF   Berikutnya: Replication →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact