"Desain database yang buruk tidak bisa diselamatkan oleh kode yang bagus. Tapi desain database yang bagus bisa membuat kode yang biasa-biasa saja tetap berjalan dengan baik."
Tentang E-Book Ini
Database adalah fondasi dari hampir semua sistem bisnis. Bug paling mahal dalam karir seorang developer sering kali berakar dari desain database yang tidak dipikirkan dengan baik sejak awal.
E-book ini tidak hanya mengajarkan teori normalisasi — kita akan langsung mendesain database untuk dua sistem nyata: sistem POS (Point of Sale) untuk toko retail, dan sistem SaaS multi-tenant. Keduanya adalah jenis sistem yang paling sering dibangun oleh developer Indonesia.
Setelah selesai, kamu akan bisa:
- Merancang skema database dari kebutuhan bisnis
- Menerapkan normalisasi yang tepat (tidak over-engineer)
- Memilih tipe data yang benar untuk setiap kolom
- Menulis index yang efisien
- Mendesain untuk multi-tenancy
- Merancang sistem migrations yang aman
Prasyarat: Familiar dengan SQL dasar (SELECT, INSERT, UPDATE, DELETE). Punya PostgreSQL terinstall. Sudah baca e-book #01 (Git).
Daftar Isi
- Fondasi: Model Relasional
- Entity Relationship Diagram (ERD)
- Normalisasi: Dari Benar ke Optimal
- Tipe Data PostgreSQL yang Perlu Kamu Tahu
- Indexing & Query Patterns
- Studi Kasus 1: Database Sistem POS
- Studi Kasus 2: Database SaaS Multi-Tenant
- Migrations: Evolusi Schema yang Aman
Bab 1: Fondasi: Model Relasional
Tabel, Baris, dan Kolom
Database relasional menyimpan data dalam tabel (table). Setiap tabel terdiri dari:
- Kolom (column/field): mendefinisikan tipe data yang disimpan
- Baris (row/record): satu entri data
- Primary Key: identifier unik untuk setiap baris
-- Tabel products
id | name | price | stock
---|---------------|---------|------
1 | Kopi Arabika | 85000 | 100
2 | Teh Hijau | 35000 | 200
3 | Gula Pasir | 15000 | 500Relationships: Menghubungkan Tabel
Ada tiga jenis relasi antar tabel:
One-to-Many (1:N) — paling umum. Satu kategori punya banyak produk:
categories: id | name
products: id | category_id (FK) | name | priceMany-to-Many (M:N) — butuh tabel junction. Satu pesanan bisa punya banyak produk, satu produk bisa ada di banyak pesanan:
orders: id | customer_id | total
products: id | name | price
order_items: order_id (FK) | product_id (FK) | qty | unit_priceOne-to-One (1:1) — jarang dibutuhkan. Satu user punya satu profil detail:
users: id | email | password_hash
user_profiles: user_id (FK UNIQUE) | full_name | phone | addressForeign Key: Menjaga Integritas Data
Foreign Key (FK) memastikan referensi antar tabel selalu valid:
CREATE TABLE products (
id SERIAL PRIMARY KEY,
category_id INTEGER REFERENCES categories(id)
ON DELETE SET NULL -- jika kategori dihapus, category_id jadi NULL
ON UPDATE CASCADE -- jika id kategori berubah, ikut berubah
);Pilihan ON DELETE:
CASCADE— hapus juga semua produk jika kategori dihapusSET NULL— set category_id jadi NULLRESTRICT— larang penghapusan kategori jika masih ada produkNO ACTION— default, sama seperti RESTRICT
Soft Delete: Menghapus Tanpa Benar-Benar Menghapus
Hampir semua sistem bisnis nyata tidak benar-benar menghapus data — mereka menyembunyikannya. Produk yang discontinued masih perlu muncul di laporan penjualan lama. User yang dihapus masih perlu terhubung ke order-order sebelumnya.
Ada tiga pendekatan, masing-masing dengan karakter berbeda:
-- Pendekatan 1: is_active BOOLEAN
-- Sederhana, tapi hanya tahu "aktif atau tidak" — tidak tahu kapan dihapus
is_active BOOLEAN NOT NULL DEFAULT true
-- Pendekatan 2: deleted_at TIMESTAMPTZ (direkomendasikan)
-- Tahu kapan dihapus, bisa undelete, bisa TTL cleanup otomatis
deleted_at TIMESTAMPTZ DEFAULT NULL -- NULL = aktif, ada value = sudah dihapus
-- Pendekatan 3: status ENUM
-- Paling fleksibel jika record punya banyak state
CREATE TYPE product_status AS ENUM ('active', 'inactive', 'discontinued', 'deleted');
status product_status NOT NULL DEFAULT 'active'Kapan pakai apa:
is_active— cukup untuk entitas sederhana dengan dua state (ya/tidak)deleted_at— pilihan terbaik untuk sebagian besar kasus: expressif, bisa audit, bisa restorestatus ENUM— gunakan saat entitas punya lebih dari dua state bermakna
Implikasi ke UNIQUE constraint:
Ini jebakan yang sering tidak disadari. Jika sku punya UNIQUE constraint, produk yang di-soft-delete akan menghalangi pembuatan produk baru dengan SKU yang sama:
-- ✗ Akan conflict jika produk lama dengan SKU yang sama sudah di-soft-delete
CREATE UNIQUE INDEX idx_products_sku ON products(sku);
-- ✓ Partial unique index — hanya enforce uniqueness untuk yang belum dihapus
CREATE UNIQUE INDEX idx_products_sku_active
ON products(sku)
WHERE deleted_at IS NULL;Implikasi ke query — selalu filter:
-- Tanpa soft delete filter → data "hantu" ikut muncul
SELECT * FROM products WHERE category_id = 1;
-- ✓ Dengan filter
SELECT * FROM products WHERE category_id = 1 AND deleted_at IS NULL;
-- Buat index partial untuk performa
CREATE INDEX idx_products_active ON products(category_id)
WHERE deleted_at IS NULL;Bab 2: Entity Relationship Diagram (ERD)
Proses Desain Database
Sebelum nulis satu baris SQL, gambar dulu. Proses yang disarankan:
- Identifikasi entity — benda/hal utama dalam sistem (Produk, Pesanan, Pelanggan)
- Identifikasi atribut — informasi yang perlu disimpan untuk setiap entity
- Identifikasi relasi — bagaimana entity saling berhubungan
- Tentukan kardinalitas — 1:1, 1:N, atau M:N
- Normalisasi — periksa dan perbaiki struktur
Contoh: Identifikasi Entity untuk Toko Online
Step 1: Brainstorm entity
Produk, Kategori, Pelanggan, Pesanan, Item Pesanan,
Pembayaran, Ulasan, Kupon, Pengiriman, Stok
Step 2: Identifikasi atribut
Produk: nama, deskripsi, harga, stok, SKU, gambar
Pesanan: tanggal, status, total, catatan
Pelanggan: nama, email, telepon, alamat
Step 3: Tentukan relasi
Kategori → (1:N) → Produk
Pelanggan → (1:N) → Pesanan
Pesanan → (M:N) → Produk [melalui tabel order_items]
Pesanan → (1:1) → Pembayaran
Produk → (1:N) → Ulasan
Pelanggan → (1:N) → Ulasan
ERD Notation (Crow's Foot)
Simbol:
|| satu (exactly one)
|o nol atau satu
|< satu atau lebih (many)
o< nol atau lebih (many)
Contoh:
categories ||--o< products
"satu kategori punya nol atau lebih produk"
orders ||--|| payments
"satu pesanan punya tepat satu pembayaran"
orders ||--o< order_items --o|| products
"satu pesanan punya banyak item, setiap item merujuk ke satu produk"
Bab 3: Normalisasi: Dari Benar ke Optimal
Mengapa Normalisasi?
Tanpa normalisasi, kamu akan menghadapi:
- Update anomaly: ubah nama kota di satu baris, tapi baris lain masih pakai nama lama
- Insert anomaly: tidak bisa simpan data kota baru tanpa ada pelanggan
- Delete anomaly: hapus pelanggan terakhir dari suatu kota, data kota ikut hilang
1NF: Satu Nilai per Sel
Pelanggaran 1NF:
orders: id | customer_name | products
1 | Budi | "Kopi, Teh, Gula" ← multiple values!
Setelah 1NF:
orders: id | customer_name
order_items: id | order_id | product_name
2NF: Tidak Ada Partial Dependency
2NF berlaku untuk tabel dengan composite primary key. Setiap kolom non-key harus bergantung pada seluruh primary key, bukan sebagian.
Pelanggaran 2NF:
order_items: (order_id, product_id) | qty | unit_price | product_name
↑ bergantung hanya pada product_id!
Setelah 2NF:
order_items: (order_id, product_id) | qty | unit_price
products: id | name | ...
3NF: Tidak Ada Transitive Dependency
Kolom non-key tidak boleh bergantung pada kolom non-key lainnya.
Pelanggaran 3NF:
products: id | name | category_id | category_name
↑ bergantung pada category_id, bukan id!
Setelah 3NF:
products: id | name | category_id
categories: id | name
Kapan Denormalisasi Itu Boleh?
Normalisasi penuh tidak selalu optimal untuk performa. Denormalisasi (menyimpan data redundant secara sengaja) kadang perlu untuk:
- Kolom yang sangat sering di-read bersama: daripada JOIN setiap saat, simpan
category_namedi tabelproductssebagai cache - Aggregate yang mahal: simpan
total_orders_countdi tabelcustomersdaripada COUNT setiap query - Laporan/analytics: tabel khusus reporting yang denormalized untuk query analytics cepat
Aturan: normalisasi dulu, denormalisasi kemudian jika ada bukti performa buruk.
Bab 4: Tipe Data PostgreSQL yang Perlu Kamu Tahu
Integer & Numeric
SMALLINT -- -32,768 to 32,767 (2 bytes)
INTEGER -- -2.1 billion to 2.1 billion (4 bytes)
BIGINT -- sangat besar (8 bytes)
SERIAL -- INTEGER auto-increment (shorthand)
BIGSERIAL -- BIGINT auto-increment
NUMERIC(12, 2) -- exact decimal: 12 digit total, 2 desimal
-- SELALU gunakan ini untuk uang/harga!
FLOAT / REAL -- floating point (tidak akurat untuk uang)Jangan gunakan FLOAT/REAL untuk uang.
0.1 + 0.2dalam floating point =0.30000000000000004. Untuk harga, selalu gunakanNUMERIC.
UUID vs SERIAL: Pilih Primary Key yang Tepat
Ini keputusan yang terlihat sepele tapi implikasinya jauh:
-- SERIAL / BIGSERIAL — auto-increment integer
id SERIAL PRIMARY KEY -- 1, 2, 3, 4, ...
id BIGSERIAL PRIMARY KEY -- gunakan ini jika tabel bisa > 2 miliar baris
-- UUID — identifier acak 128-bit
id UUID PRIMARY KEY DEFAULT gen_random_uuid() -- tersedia di PostgreSQL 13+SERIAL/BIGSERIAL cocok untuk:
- Tabel internal yang ID-nya tidak pernah diekspos ke user
- Tabel dengan volume insert sangat tinggi (index B-tree sequential = lebih efisien)
- Foreign key antar tabel internal
UUID cocok untuk:
- ID yang diekspos ke public API atau URL —
GET /orders/550e8400-...tidak mengungkap ada berapa order di sistem kamu - Data yang di-sync antar service atau antar database — tidak ada risiko collision
- ID yang perlu dibuat di sisi client sebelum disimpan ke database
Trade-off UUID:
UUID v4 adalah random sepenuhnya — bagus untuk keamanan tapi buruk untuk index B-tree karena setiap insert ke posisi acak. Untuk tabel besar dengan banyak insert, ini bisa memperlambat performa.
Solusinya: UUID v7 (tersedia di PostgreSQL 17, atau via extension). UUID v7 adalah timestamp-ordered — aman seperti UUID v4, tapi sequential seperti BIGSERIAL untuk keperluan indexing.
-- Rekomendasi praktis untuk sistem bisnis:
-- Gunakan BIGSERIAL untuk tabel internal (products, categories, users internal)
-- Gunakan UUID untuk ID yang diekspos ke luar (order ID, invoice ID, public token)
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- diekspos ke client
user_id BIGINT NOT NULL REFERENCES users(id), -- internal FK
...
);Text
CHAR(n) -- fixed length, padding dengan spasi
VARCHAR(n) -- variable length dengan batas maksimum
TEXT -- unlimited length
-- Rekomendasi: gunakan TEXT atau VARCHAR(n) jika perlu batasan
-- CHAR(n) jarang dibutuhkan di era modernTanggal & Waktu
DATE -- tanggal saja: 2024-01-15
TIME -- waktu saja: 14:30:00
TIMESTAMP -- tanggal + waktu, tanpa timezone
TIMESTAMPTZ -- tanggal + waktu, dengan timezone (SELALU gunakan ini!)
INTERVAL -- durasi: '2 hours', '3 days'Selalu gunakan
TIMESTAMPTZuntuk kolom timestamp. Ini menyimpan dalam UTC dan mengkonversi otomatis ke timezone lokal.TIMESTAMPtanpa timezone menyebabkan bug yang susah di-debug di aplikasi yang berjalan di timezone berbeda.
Boolean & Lainnya
BOOLEAN -- true / false
UUID -- universally unique identifier
JSONB -- JSON binary (bisa di-index, lebih cepat dari JSON)
ARRAY -- array dari tipe apapun: INTEGER[], TEXT[]
ENUM -- set nilai tetap (hati-hati, susah di-alter)JSONB: Kapan Pakai, Kapan Tidak
JSONB adalah fitur powerful yang sering disalahgunakan. Pakai sebagai pelarian dari desain relasional yang proper → masalah di kemudian hari.
Gunakan JSONB saat:
-- 1. Schema per-baris berbeda (product attributes)
-- Laptop punya ram/cpu/storage, baju punya size/material/color
-- Membuat 20 kolom nullable tidak masuk akal
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
attributes JSONB DEFAULT '{}' -- fleksibel per kategori
);
-- Insert laptop:
-- attributes: {"ram": "16GB", "cpu": "Intel i7", "storage": "512GB SSD"}
-- Insert baju:
-- attributes: {"size": "XL", "material": "Cotton", "color": "Navy"}
-- 2. Data dari third-party API yang strukturnya tidak kamu kontrol
CREATE TABLE webhook_logs (
id BIGSERIAL PRIMARY KEY,
source VARCHAR(100) NOT NULL,
payload JSONB NOT NULL, -- simpan raw payload apa adanya
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 3. Metadata opsional yang jarang di-query
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
preferences JSONB DEFAULT '{}' -- theme, language, notification settings
);Jangan gunakan JSONB saat:
-- ✗ Field yang selalu ada, konsisten, dan sering di-query
-- Ini seharusnya kolom biasa
attributes JSONB -- {"name": "Kopi", "price": 25000, "stock": 100}
-- Kamu akan selalu query: WHERE attributes->>'price' > '20000'
-- Lebih baik: kolom price NUMERIC(12,2), stock INTEGER
-- ✗ Relasi yang seharusnya jadi tabel terpisah
-- Ini membuat integrasi data jadi susah
order_items JSONB -- [{"product_id": 1, "qty": 2}, ...]
-- Tidak bisa FK, tidak bisa query efisien, tidak bisa aggregateQuery JSONB yang efisien:
-- Index untuk field yang sering di-query di dalam JSONB
CREATE INDEX idx_products_category_attrs
ON products USING GIN(attributes);
-- Query
SELECT * FROM products
WHERE attributes @> '{"ram": "16GB"}'; -- GIN index akan dipakaiPilih Tipe Data yang Tepat
-- ✓ BENAR
price NUMERIC(12, 2) -- bukan FLOAT
created_at TIMESTAMPTZ -- bukan TIMESTAMP
is_active BOOLEAN -- bukan INTEGER (0/1)
user_id BIGINT -- bukan INTEGER jika bisa besar
phone VARCHAR(20) -- bukan INTEGER (leading zero!)
-- ✗ SALAH
price FLOAT -- tidak akurat untuk uang
created_at TIMESTAMP -- tidak timezone-aware
is_active SMALLINT -- gunakan BOOLEAN
phone INTEGER -- "0812345678" kehilangan leading zeroBab 5: Indexing: Membuat Query Cepat
Mengapa Index Penting
Tanpa index, PostgreSQL harus membaca semua baris untuk menemukan data yang kamu cari (sequential scan). Dengan index, PostgreSQL bisa langsung lompat ke data yang relevan (index scan).
Bayangkan tabel dengan 1 juta produk. Query WHERE sku = 'ABC-123' tanpa index = baca 1 juta baris. Dengan index = langsung ke satu baris.
Kapan Perlu Membuat Index?
Buat index pada kolom yang:
- Sering digunakan di WHERE clause —
WHERE email = ?,WHERE status = ? - Sering digunakan untuk JOIN — kolom foreign key
- Sering digunakan untuk ORDER BY — terutama kolom timestamp
- Sering digunakan untuk GROUP BY
Tipe Index PostgreSQL
-- B-tree (default) — untuk equality dan range query
CREATE INDEX idx_products_sku ON products(sku);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
-- Partial index — hanya index baris yang memenuhi kondisi
-- Lebih kecil dan lebih cepat daripada full index
CREATE INDEX idx_active_products ON products(id) WHERE is_active = true;
CREATE INDEX idx_pending_orders ON orders(created_at) WHERE status = 'pending';
-- Composite index — index beberapa kolom sekaligus
-- Urutan kolom penting! (a, b) cocok untuk WHERE a dan WHERE a AND b
-- tapi tidak untuk WHERE b saja
CREATE INDEX idx_products_category_active ON products(category_id, is_active);
-- Unique index — sekaligus constraint
CREATE UNIQUE INDEX idx_products_sku_unique ON products(sku) WHERE sku IS NOT NULL;
-- GIN index — untuk full-text search dan JSONB
CREATE INDEX idx_products_search ON products USING GIN(to_tsvector('indonesian', name || ' ' || COALESCE(description, '')));Analisa Query dengan EXPLAIN
-- Lihat query plan (tanpa eksekusi)
EXPLAIN SELECT * FROM products WHERE sku = 'ABC-123';
-- Lihat query plan + waktu eksekusi aktual
EXPLAIN ANALYZE SELECT * FROM products WHERE sku = 'ABC-123';Output yang perlu diperhatikan:
- Seq Scan — sequential scan, tidak pakai index (biasanya lambat untuk tabel besar)
- Index Scan — pakai index (bagus)
- cost=X..Y — estimasi cost (lower is better)
- rows=N — estimasi jumlah baris
N+1 Problem: Masalah Performa yang Lahir dari Query
N+1 adalah salah satu masalah performa paling umum di aplikasi backend — dan akarnya ada di cara query ditulis, bukan di kode aplikasi.
Contoh klasik: Tampilkan 20 order beserta nama customer masing-masing.
// ✗ N+1 — 1 query untuk orders, lalu 1 query PER order untuk customer
const orders = await db.query('SELECT * FROM orders LIMIT 20'); // 1 query
for (const order of orders) {
const customer = await db.query( // 20 query!
'SELECT * FROM customers WHERE id = $1',
[order.customer_id]
);
order.customer = customer.rows[0];
}
// Total: 21 query ke database untuk menampilkan 1 halaman-- ✓ Solusi: satu JOIN — satu query, apapun jumlah barisnya
SELECT
o.id,
o.total_amount,
o.created_at,
c.name AS customer_name,
c.email AS customer_email
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id
ORDER BY o.created_at DESC
LIMIT 20;Deteksi N+1: Aktifkan query logging dan lihat apakah ada pola query identik yang dieksekusi berkali-kali dalam satu request. Di production, ini terlihat dari response time yang naik linear seiring jumlah data.
Implikasi ke desain: Foreign key harus punya index agar JOIN tetap cepat. Ini kenapa CREATE INDEX idx_orders_customer ON orders(customer_id) bukan opsional — tanpa index ini, setiap JOIN ke customers akan menjadi sequential scan.
Pagination: OFFSET vs Cursor-Based
LIMIT + OFFSET adalah cara pagination paling umum diajarkan — tapi punya masalah serius di tabel besar.
-- OFFSET pagination — terlihat sederhana
SELECT * FROM transactions ORDER BY created_at DESC LIMIT 10 OFFSET 990;
-- Yang tidak terlihat: PostgreSQL tetap membaca 1000 baris pertama,
-- lalu membuang 990 di antaranya. Semakin dalam halamannya, semakin lambat.
-- OFFSET 10000 = scan 10.010 baris hanya untuk menampilkan 10.Cursor-based pagination menghindari masalah ini sepenuhnya:
-- Halaman pertama
SELECT * FROM transactions
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- Simpan nilai created_at dan id dari baris terakhir sebagai cursor
-- Halaman berikutnya — tidak ada OFFSET, langsung dari cursor
SELECT * FROM transactions
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- PostgreSQL langsung pakai index, tidak ada baris yang dibuangAturan kolom cursor:
- Harus
NOT NULL— nilai NULL tidak bisa dipakai sebagai anchor - Harus punya index —
created_at+idcomposite index - Harus stabil — jangan pakai
updated_at(berubah saat data di-edit, cursor jadi tidak konsisten)
-- Index yang dibutuhkan untuk cursor pagination
CREATE INDEX idx_transactions_cursor ON transactions(created_at DESC, id DESC);Kapan OFFSET masih OK: Tabel kecil (< 50.000 baris), atau halaman admin internal di mana user tidak akan scroll sampai halaman ke-1000.
Yang Harus Dihindari
-- ✗ Jangan pakai fungsi di kolom index (index tidak akan dipakai)
WHERE LOWER(email) = 'user@email.com'
-- ✓ Gunakan functional index atau simpan dalam lowercase
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- ✗ Jangan buat index di semua kolom (memperlambat write operations)
-- Index memiliki biaya: setiap INSERT/UPDATE/DELETE juga harus update index
-- ✗ Jangan pakai LIKE dengan leading wildcard (tidak pakai index)
WHERE name LIKE '%kopi%'
-- ✓ Gunakan full-text search untuk iniBab 6: Studi Kasus 1: Database Sistem POS
Analisa Kebutuhan POS
Sistem POS untuk toko retail perlu mengelola:
- Produk & Stok: item yang dijual, jumlah stok
- Kasir & Transaksi: proses penjualan
- Pelanggan: data pembeli (opsional)
- Pembayaran: tunai, QRIS, kartu
- Laporan: penjualan harian/bulanan
Schema POS Lengkap
-- ============================================================
-- PRODUK & INVENTORY
-- ============================================================
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
sku VARCHAR(100) UNIQUE,
barcode VARCHAR(100) UNIQUE,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
unit VARCHAR(50) NOT NULL DEFAULT 'pcs', -- pcs, kg, liter, lusin
cost_price NUMERIC(12, 2) NOT NULL DEFAULT 0, -- harga modal
selling_price NUMERIC(12, 2) NOT NULL, -- harga jual
stock INTEGER NOT NULL DEFAULT 0,
min_stock INTEGER NOT NULL DEFAULT 0, -- alert jika di bawah ini
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Riwayat pergerakan stok
CREATE TABLE stock_movements (
id BIGSERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id),
type VARCHAR(50) NOT NULL, -- 'sale', 'purchase', 'adjustment', 'return'
quantity INTEGER NOT NULL, -- positif = masuk, negatif = keluar
quantity_before INTEGER NOT NULL,
quantity_after INTEGER NOT NULL,
reference_id INTEGER, -- ID transaksi/penyesuaian terkait
reference_type VARCHAR(50),
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- PELANGGAN
-- ============================================================
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
phone VARCHAR(20),
email VARCHAR(255),
address TEXT,
total_purchases NUMERIC(15, 2) NOT NULL DEFAULT 0,
total_visits INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- TRANSAKSI PENJUALAN
-- ============================================================
CREATE TYPE payment_method AS ENUM ('cash', 'qris', 'debit_card', 'credit_card', 'transfer');
CREATE TYPE transaction_status AS ENUM ('pending', 'completed', 'cancelled', 'refunded');
CREATE TABLE transactions (
id BIGSERIAL PRIMARY KEY,
invoice_number VARCHAR(50) NOT NULL UNIQUE, -- e.g., INV-20240115-0001
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
cashier_id INTEGER NOT NULL REFERENCES users(id),
status transaction_status NOT NULL DEFAULT 'completed',
-- Totals
subtotal NUMERIC(12, 2) NOT NULL,
discount_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
tax_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
total_amount NUMERIC(12, 2) NOT NULL,
-- Payment
payment_method payment_method NOT NULL,
amount_paid NUMERIC(12, 2) NOT NULL,
change_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE transaction_items (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id),
product_name VARCHAR(255) NOT NULL, -- snapshot nama saat transaksi
product_sku VARCHAR(100), -- snapshot SKU saat transaksi
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(12, 2) NOT NULL, -- harga jual saat transaksi
unit_cost NUMERIC(12, 2) NOT NULL, -- harga modal saat transaksi (untuk profit)
discount_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
subtotal NUMERIC(12, 2) NOT NULL -- (unit_price * quantity) - discount
);
-- ============================================================
-- INDEX
-- ============================================================
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_barcode ON products(barcode);
CREATE INDEX idx_products_active ON products(id) WHERE is_active = true;
CREATE INDEX idx_transactions_date ON transactions(created_at DESC);
CREATE INDEX idx_transactions_cashier ON transactions(cashier_id);
CREATE INDEX idx_transaction_items_product ON transaction_items(product_id);
CREATE INDEX idx_stock_movements_product ON stock_movements(product_id, created_at DESC);Audit Trail: Siapa Mengubah Apa Kapan
Schema POS sudah punya created_by di stock_movements — itu awal yang baik. Tapi sistem bisnis yang serius butuh lebih: riwayat lengkap perubahan untuk semua tabel penting.
Level 1 — Kolom audit sederhana (cukup untuk sebagian besar kasus):
-- Tambahkan di tabel yang perlu dilacak
CREATE TABLE products (
...
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Level 2 — Generic audit log table (untuk perubahan yang harus bisa di-audit):
Harga produk berubah — siapa yang ubah? Stok di-adjust — berapa sebelumnya? Untuk kebutuhan ini, butuh tabel audit yang menyimpan before/after values:
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
record_id BIGINT NOT NULL,
action VARCHAR(10) NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
old_values JSONB, -- NULL untuk INSERT
new_values JSONB, -- NULL untuk DELETE
changed_by INTEGER REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_table_record
ON audit_logs(table_name, record_id, changed_at DESC);PostgreSQL trigger untuk auto-populate:
CREATE OR REPLACE FUNCTION audit_trigger_fn()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_logs(table_name, record_id, action, new_values)
VALUES (TG_TABLE_NAME, NEW.id, 'INSERT', to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_logs(table_name, record_id, action, old_values, new_values)
VALUES (TG_TABLE_NAME, NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_logs(table_name, record_id, action, old_values)
VALUES (TG_TABLE_NAME, OLD.id, 'DELETE', to_jsonb(OLD));
RETURN OLD;
END IF;
END;
$$ LANGUAGE plpgsql;
-- Pasang trigger ke tabel yang perlu di-audit
CREATE TRIGGER products_audit
AFTER INSERT OR UPDATE OR DELETE ON products
FOR EACH ROW EXECUTE FUNCTION audit_trigger_fn();Query riwayat perubahan harga produk:
SELECT
changed_at,
old_values->>'selling_price' AS harga_lama,
new_values->>'selling_price' AS harga_baru,
changed_by
FROM audit_logs
WHERE table_name = 'products'
AND record_id = 42
AND action = 'UPDATE'
AND old_values->>'selling_price' != new_values->>'selling_price'
ORDER BY changed_at DESC;Penting: Snapshot Data di Transaksi
Perhatikan kolom product_name dan unit_price di transaction_items. Ini menyimpan snapshot nilai saat transaksi berlangsung, bukan foreign key ke harga produk saat ini.
Kenapa? Karena harga produk bisa berubah. Laporan penjualan bulan lalu harus menampilkan harga yang berlaku saat itu, bukan harga hari ini.
Query Laporan POS
-- Penjualan hari ini
SELECT
COUNT(*) AS total_transaksi,
SUM(total_amount) AS total_pendapatan,
SUM(total_amount - discount_amount) AS pendapatan_bersih,
AVG(total_amount) AS rata_rata_per_transaksi
FROM transactions
WHERE
status = 'completed'
AND DATE(created_at AT TIME ZONE 'Asia/Jakarta') = CURRENT_DATE;
-- Produk terlaris bulan ini
SELECT
ti.product_name,
SUM(ti.quantity) AS total_terjual,
SUM(ti.subtotal) AS total_pendapatan,
SUM(ti.quantity * (ti.unit_price - ti.unit_cost)) AS total_profit
FROM transaction_items ti
JOIN transactions t ON ti.transaction_id = t.id
WHERE
t.status = 'completed'
AND DATE_TRUNC('month', t.created_at) = DATE_TRUNC('month', NOW())
GROUP BY ti.product_id, ti.product_name
ORDER BY total_terjual DESC
LIMIT 10;
-- Stok hampir habis
SELECT name, sku, stock, min_stock
FROM products
WHERE is_active = true AND stock <= min_stock
ORDER BY (stock::FLOAT / NULLIF(min_stock, 0));Bab 7: Studi Kasus 2: Database SaaS Multi-Tenant
Strategi Multi-Tenancy
Multi-tenancy adalah arsitektur di mana satu instance aplikasi melayani banyak tenant (pelanggan/organisasi). Ada tiga pendekatan:
-
Separate Database: setiap tenant punya database sendiri. Isolasi sempurna, tapi mahal dan susah di-manage.
-
Separate Schema: satu database, setiap tenant punya schema sendiri. Isolasi baik, lebih manageable.
-
Shared Table + tenant_id (paling umum): semua tenant di tabel yang sama, dibedakan dengan kolom
tenant_id. Paling efisien, tapi butuh Row-Level Security (RLS) yang ketat.
Kita akan bahas pendekatan 3 yang paling umum dipakai SaaS.
Schema SaaS dengan tenant_id
-- ============================================================
-- TENANTS (Organisasi/Perusahaan)
-- ============================================================
CREATE TABLE tenants (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE, -- untuk subdomain: slug.app.com
plan VARCHAR(50) NOT NULL DEFAULT 'free', -- free, starter, pro, enterprise
status VARCHAR(50) NOT NULL DEFAULT 'active',
max_users INTEGER NOT NULL DEFAULT 5,
max_products INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- USERS & ROLES
-- ============================================================
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'member', -- owner, admin, member
is_active BOOLEAN NOT NULL DEFAULT true,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Email unik per tenant (bukan global unique)
UNIQUE(tenant_id, email)
);
-- ============================================================
-- CONTOH: FITUR UTAMA (gunakan tenant_id di semua tabel)
-- ============================================================
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'todo',
priority SMALLINT NOT NULL DEFAULT 2 CHECK (priority BETWEEN 1 AND 4),
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
due_date DATE,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- ROW-LEVEL SECURITY (RLS) — kunci keamanan multi-tenancy
-- ============================================================
-- Aktifkan RLS di setiap tabel multi-tenant
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Policy: user hanya bisa akses data tenant mereka sendiri
-- (current_setting diset oleh aplikasi di awal setiap query)
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::INTEGER);
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id = current_setting('app.tenant_id')::INTEGER);Cara Set Tenant Context di Aplikasi
// Setiap request, set tenant context sebelum query lain
async function withTenantContext(tenantId, callback) {
const client = await pool.connect();
try {
await client.query(`SET LOCAL app.tenant_id = $1`, [tenantId]);
return await callback(client);
} finally {
client.release();
}
}
// Penggunaan di controller
export const getProjects = asyncHandler(async (req, res) => {
const { tenantId } = req.user; // dari JWT
const projects = await withTenantContext(tenantId, async (client) => {
const result = await client.query(
'SELECT * FROM projects WHERE status = $1 ORDER BY created_at DESC',
['active']
// RLS secara otomatis menambahkan filter tenant_id!
);
return result.rows;
});
res.json({ success: true, data: projects });
});Index untuk Multi-Tenant
Composite index dengan tenant_id di posisi pertama adalah wajib:
-- Selalu mulai composite index dengan tenant_id
CREATE INDEX idx_projects_tenant ON projects(tenant_id, status);
CREATE INDEX idx_tasks_tenant_project ON tasks(tenant_id, project_id);
CREATE INDEX idx_tasks_tenant_assigned ON tasks(tenant_id, assigned_to) WHERE assigned_to IS NOT NULL;
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);Bab 8: Migrations: Evolusi Schema yang Aman
Mengapa Migration Penting
Migration adalah cara sistematis untuk mengubah schema database seiring berkembangnya aplikasi. Tanpa migration:
- "Di komputerku jalan, di server tidak" — karena schema berbeda
- Susah track apa saja yang sudah berubah di database
- Tidak bisa rollback perubahan yang bermasalah
Prinsip Migration yang Aman (Zero-Downtime)
Untuk aplikasi yang sudah live, migration harus bisa dijalankan tanpa menghentikan aplikasi. Aturan utama:
Safe (bisa langsung):
CREATE TABLEADD COLUMNdengan default atau nullableCREATE INDEX CONCURRENTLYDROP INDEX
Berbahaya (perlu strategi):
DROP COLUMN— data hilang, query lama bisa errorRENAME COLUMN— query lama yang pakai nama lama akan errorALTER COLUMN TYPE— bisa lock tabelADD NOT NULLtanpa default — akan fail jika ada data lama
Strategi Rename Column yang Aman
Jangan langsung rename. Lakukan dalam tiga langkah (tiga deployment):
-- Step 1: Tambahkan kolom baru, isi dari kolom lama
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
UPDATE users SET full_name = name WHERE full_name IS NULL;
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;
-- (Deploy kode baru yang nulis ke keduanya — name DAN full_name)
-- Step 2: Update kode untuk baca dari full_name
-- (Deploy kode yang hanya baca full_name)
-- Step 3: Hapus kolom lama
ALTER TABLE users DROP COLUMN name;Contoh File Migration
-- migrations/003_add_customer_loyalty.sql
-- Pastikan idempotent: bisa dijalankan berulang tanpa error
DO $$
BEGIN
-- Tambah kolom loyalty_points jika belum ada
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'customers' AND column_name = 'loyalty_points'
) THEN
ALTER TABLE customers ADD COLUMN loyalty_points INTEGER NOT NULL DEFAULT 0;
ALTER TABLE customers ADD COLUMN loyalty_tier VARCHAR(50) NOT NULL DEFAULT 'bronze';
END IF;
END $$;
-- Index baru (CONCURRENTLY agar tidak lock tabel)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customers_loyalty
ON customers(loyalty_tier, loyalty_points DESC);
-- Record bahwa migration ini sudah dijalankan
INSERT INTO schema_migrations (version, name, applied_at)
VALUES ('003', 'add_customer_loyalty', NOW())
ON CONFLICT (version) DO NOTHING;Tabel Migration Tracking
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(20) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Penutup
Desain database yang baik adalah investasi jangka panjang. Schema yang dirancang dengan benar dari awal menghemat jam-jam debugging di masa depan — dan schema yang buruk akan menghantui setiap fitur baru yang kamu bangun.
Referensi Keputusan Cepat
Simpan ini sebagai cheat sheet saat merancang schema:
| Situasi | Pilihan |
|---|---|
| Primary key tabel internal | BIGSERIAL |
| ID yang diekspos ke public API / URL | UUID (gen_random_uuid()) |
| Harga, total, nilai uang | NUMERIC(12, 2) — bukan FLOAT |
| Timestamp | TIMESTAMPTZ — bukan TIMESTAMP |
| Soft delete tanpa informasi kapan | is_active BOOLEAN |
| Soft delete dengan audit kapan dihapus | deleted_at TIMESTAMPTZ |
| Field per-baris berbeda (product attributes) | JSONB |
| Field konsisten, sering di-query | Kolom biasa |
| Unique constraint + soft delete | Partial unique index WHERE deleted_at IS NULL |
| Pagination tabel kecil (< 50k baris) | LIMIT + OFFSET |
| Pagination tabel besar | Cursor-based (WHERE (created_at, id) < (...)) |
| Lacak siapa yang ubah data | created_by + updated_by columns |
| Lacak history perubahan nilai | audit_logs table + trigger |
| Banyak INSERT bersamaan ke UUID | Pertimbangkan UUID v7 (timestamp-ordered) |
| Kolom yang sering di-JOIN | Wajib ada index pada FK |
Langkah selanjutnya:
- Terapkan desain ini di project REST API dari e-book #02
- Gunakan
dbdiagram.iountuk visualisasi ERD sebelum menulis SQL - Eksplorasi
EXPLAIN ANALYZEsecara rutin — jangan tunggu ada masalah performa baru investigasi