Langkah kedua metode Kimball — deklarasi grain — sering dilewati karena terasa abstrak, padahal grain adalah keputusan paling menentukan dalam seluruh dimensional modeling. Grain menjawab satu pertanyaan: satu baris fact table mewakili apa? Jawaban yang kabur membuat campuran peristiwa berbeda menempati satu tabel, dan agregasi yang tampak benar diam-diam menghitung ganda. Chapter ini mendefinisikan grain, menegaskan posisinya sebelum modeling dimulai, lalu membuktikan lewat uji nyata bagaimana grain yang keliru merusak laporan ongkir — dan bagaimana perbaikannya memulihkan angka yang benar.
Apa Itu Grain #
Grain adalah pernyataan eksplisit tentang satuan satu baris fact table. Contoh deklarasi grain yang baik: “satu baris = satu item produk dalam satu pesanan” atau “satu baris = saldo satu rekening pada akhir hari”. Deklarasi tersebut menentukan tiga hal sekaligus: measure apa yang sah dicatat, dimensi apa yang relevan, dan agregasi apa yang valid. Grain bukan kolom di tabel — grain adalah kesepakatan yang dijaga oleh desain tabel dan dokumentasinya.
Grain yang berbeda tidak boleh berbagi satu fact table. Kuantitas produk (grain item pesanan) dan ongkir (grain pesanan) sama-sama berupa angka, tetapi keduanya menggambarkan satuan kejadian yang berbeda; mencampurkan keduanya dalam satu tabel adalah jebakan yang akan kita buktikan.
Deklarasikan Grain Sebelum Modeling #
Kimball menempatkan deklarasi grain sebagai langkah kedua, tepat setelah memilih proses bisnis dan sebelum memilih dimensi maupun fakta. Alasannya praktis: dimensi hanya bisa dipilih setelah kita tahu satuan barisnya. Kalau grain-nya “satu item dalam satu pesanan”, maka dimensi pelanggan, produk, waktu, dan toko relevan; kalau grain-nya “satu pesanan”, dimensi produk justru tidak berlaku karena satu pesanan berisi banyak produk.
Deklarasi grain juga menyaring kebutuhan analitik. Tim manajemen yang bertanya “berapa rata-rata ongkir per pesanan” butuh grain pesanan; tim merchandiser yang bertanya “berapa pendapatan per kategori produk” butuh grain item.
Contoh Grain yang Keliru #
Tabel berikut memaksakan dua grain sekaligus: baris per item pesanan, tetapi ongkir per pesanan ikut dituliskan pada setiap baris (dijalankan nyata dengan SQLite melalui modul sqlite3 Python):
CREATE TABLE pesanan_campur (
pesanan_id TEXT NOT NULL,
pelanggan TEXT NOT NULL,
produk TEXT NOT NULL,
kategori TEXT NOT NULL,
qty INTEGER NOT NULL,
jumlah INTEGER NOT NULL,
ongkir INTEGER NOT NULL
);
INSERT INTO pesanan_campur VALUES
('ORD-001', 'Budi', 'Mouse Wireless M10', 'Aksesoris', 2, 300000, 20000),
('ORD-001', 'Budi', 'Monitor 24 Inci IPS', 'Monitor', 1, 1200000, 20000),
('ORD-002', 'Siti', 'Keyboard Mekanik K75', 'Aksesoris', 1, 450000, 15000),
('ORD-003', 'Dewi', 'Mouse Wireless M10', 'Aksesoris', 1, 150000, 25000),
('ORD-003', 'Dewi', 'Keyboard Mekanik K75', 'Aksesoris', 1, 450000, 25000);Ongkir ORD-001 (20000) tertulis pada dua baris karena pesanan Budi berisi dua item. Query total ongkir pun berbunyi polos:
SELECT SUM(ongkir) AS total_ongkir
FROM pesanan_campur;Output yang Dihasilkan:
105000Angka tersebut keliru. Ongkir sesungguhnya hanya 20000 + 15000 + 25000 = 60000; selisih 45000 berasal dari ongkir ORD-001 dan ORD-003 yang masing-masing terhitung dua kali karena kedua pesanan berisi dua item. Kerusakan menjalar ke potongan analitik lain: agregasi ongkir per kategori pada tabel tersebut menghasilkan 85000 untuk Aksesoris dan 20000 untuk Monitor — pembagian yang tidak bermakna karena ongkir tidak pernah dibeli per kategori.
Perbaikan: Satu Grain per Fact Table #
Perbaikannya adalah memisahkan tabel sesuai grain masing-masing — grain pesanan untuk ongkir, grain item untuk pendapatan produk:
CREATE TABLE fact_pesanan_header (
pesanan_id TEXT PRIMARY KEY,
pelanggan TEXT NOT NULL,
ongkir INTEGER NOT NULL
);
CREATE TABLE fact_pesanan_detail (
pesanan_id TEXT NOT NULL REFERENCES fact_pesanan_header(pesanan_id),
produk TEXT NOT NULL,
kategori TEXT NOT NULL,
qty INTEGER NOT NULL,
jumlah INTEGER NOT NULL
);
INSERT INTO fact_pesanan_header VALUES
('ORD-001', 'Budi', 20000),
('ORD-002', 'Siti', 15000),
('ORD-003', 'Dewi', 25000);
INSERT INTO fact_pesanan_detail VALUES
('ORD-001', 'Mouse Wireless M10', 'Aksesoris', 2, 300000),
('ORD-001', 'Monitor 24 Inci IPS', 'Monitor', 1, 1200000),
('ORD-002', 'Keyboard Mekanik K75', 'Aksesoris', 1, 450000),
('ORD-003', 'Mouse Wireless M10', 'Aksesoris', 1, 150000),
('ORD-003', 'Keyboard Mekanik K75', 'Aksesoris', 1, 450000);Total ongkir kini dihitung pada tabel yang grain-nya memang satu baris per pesanan:
SELECT SUM(ongkir) AS total_ongkir
FROM fact_pesanan_header;Output yang Dihasilkan:
60000Angka 60000 cocok dengan penjumlahan manual — setiap ongkir terhitung tepat sekali. Sementara itu, pertanyaan pendapatan per kategori dijawab di tabel detail yang grain-nya sesuai:
SELECT kategori, SUM(jumlah) AS pendapatan
FROM fact_pesanan_detail
GROUP BY kategori
ORDER BY kategori;Output yang Dihasilkan:
Aksesoris | 1350000
Monitor | 1200000Kedua output di atas berasal dari data yang sama dengan tabel campuran — hanya pemisahan grain yang berbeda. Bila analisis butuh keduanya sekaligus (misalnya margin setelah ongkir), kedua fact table dijoin lewat pesanan_id.
Grain sebagai Kontrak #
Setelah fact table dipakai dashboard dan laporan, grain berubah menjadi kontrak: konsumen di hilir berasumsi “satu baris = satu satuan grain” ketika menulis agregasi, dan pelanggaran asumsi tersebut merusak semua turunannya tanpa peringatan. Karena itu grain wajib didokumentasikan bersama tabelnya — pada komentar DDL, dokumentasi model (mis. file schema.yml di dbt), atau kamus data tim. Aturan tersebut berlaku pada engine apa pun: dokumentasi PostgreSQL
maupun dokumentasi DuckDB
sama-sama menyerahkan kebenaran agregasi sepenuhnya kepada perancang tabel.
Dengan grain yang terkunci, satu baris fact kini bermakna tegas. Tantangan berikutnya muncul dari kunci penghubungnya sendiri: kunci alami seperti kode produk berubah seiring waktu dan merusak history — masalah yang diselesaikan oleh Surrogate Key, chapter berikutnya.