Proyek dbt TokoKita dari Chapter dbt baru berisi tiga model, tetapi TokoKita yang sesungguhnya membaca banyak sumber: pesanan dari aplikasi, pelanggan dari CRM, produk dari master katalog, retur dari sistem gudang. Tanpa pengelompokan, folder models/ berubah menjadi tumpukan berkas yang tidak jelas mana yang boleh dibaca siapa. Komunitas dbt menjawabnya dengan konvensi layer: staging, intermediate, dan marts — pembagian yang juga mengingatkan kita pada pemisahan raw dan curated di section Data Modeling dan Data Warehouse. Chapter ini membangun ketiga layer tersebut dengan DuckDB sebagai simulasi warehouse.

Tiga Layer Model #

LayerAwalanIsiPembaca
stagingstg_salinan bersih 1:1 dari satu source: rename kolom, menyeragamkan nilai, cast tipemodel lain
intermediateint_join antar staging plus business logicmodel lain
martsfct_, dim_tabel siap konsumen: star schema, wide table, metrik finaldashboard BI, analis

Pembagian ini menjawab dua pertanyaan sekaligus. Pertanyaan pertama: di mana pembersihan dilakukan? Di staging, tepat setelah sumber — satu tempat untuk seluruh proyek. Pertanyaan kedua: tabel mana yang boleh dibaca pengguna akhir? Hanya marts; dua layer di bawahnya adalah dapur dalam yang boleh berubah tanpa mengganggu laporan. Konfigurasi +materialized pada dbt_project.yml di Chapter dbt mengikuti pembagian yang sama: staging diwujudkan sebagai view yang murah dibangun ulang, marts sebagai table yang cepat dibaca alat BI. Konsep model dan materialisasi selengkapnya terdokumentasi di dokumentasi model dbt .

Alur Antar Layer #

Bentuk akhir yang kita kejar untuk TokoKita tampak seperti ini:

Setiap panah hanya boleh bergerak maju ke layer berikutnya, tidak pernah mundur. Tabel mentah app_pesanan dan crm_pelanggan disentuh tepat satu kali oleh masing-masing model staging; selebihnya model saling memanggil lewat ref(). Dibanding DAG dua model pada Chapter dbt, pekerjaan join pelanggan yang tadinya melekat di fct_pesanan kini dipindahkan ke int_pesanan — mart tinggal membaca hasil akhirnya. Perhatikan juga dim_pelanggan: dimensi yang hanya butuh pembersihan ringan boleh melompat dari staging ke mart tanpa mampir ke intermediate — layer dilewati, bukan dilanggar.

Pada berkas proyek, ketiga layer hidup sebagai subfolder models/staging/, models/intermediate/, dan models/marts/ — persis pola yang dbt_project.yml pada Chapter dbt konfigurasikan lewat staging: +materialized: view dan marts: +materialized: table. Model intermediate yang belum diberi konfigurasi sendiri mengikuti materialisasi bawaan dbt, yaitu view, sehingga dbt tidak pernah menulis tabel fisik tanpa kita minta.

Implementasi: Membersihkan, Merangkai, Menyajikan #

Sekarang kita jalankan ketiga layer dalam satu skrip DuckDB. Sumber aplikasi menyimpan kolom dengan nama asing (ord_id, cust_email), status yang tidak seragam (paid, PAID, Pending), dan timestamp yang tersimpan sebagai string — persis kondisi mentah yang staging harus tangani. Keuntungan strukturnya terasa saat sumber berubah: bila tim aplikasi kelak mengganti ord_id menjadi order_uuid, hanya stg_pesanan yang perlu disunting, sementara int_pesanan, fct_pesanan, dan seluruh laporan di hilirnya tetap membaca kolom id_pesanan yang sudah distandarkan. Blok di bawah memotong bagian awal skrip yang membuat dua tabel mentah tersebut beserta salinan bersih stg_pelanggan:

python
print("=== layer 1: stg_pesanan (1:1 dari sumber, rename + clean + cast) ===")
con.execute("""
CREATE TABLE stg_pesanan AS
SELECT
    ord_id                              AS id_pesanan,
    lower(trim(cust_email))             AS email,
    upper(ord_status)                   AS status,      -- 'paid'/'PAID' -> 'PAID'
    CAST(ord_ts AS TIMESTAMP)           AS dibuat_pada, -- string -> timestamp
    qty,
    unit_price                          AS harga_satuan
FROM raw_app_pesanan
""")
for r in con.execute(
    """SELECT id_pesanan, email, status,
              strftime(dibuat_pada, '%Y-%m-%d %H:%M'), harga_satuan
       FROM stg_pesanan ORDER BY id_pesanan"""
).fetchall():
    print(" ", r)

print()
print("=== layer 2: int_pesanan (join sumber lain + business logic) ===")
con.execute("""
CREATE TABLE int_pesanan AS
SELECT
    s.id_pesanan,
    s.dibuat_pada,
    s.status,
    c.kota,
    c.segmen,
    s.qty * s.harga_satuan AS total,
    s.qty * s.harga_satuan >= 300000 AS is_pesanan_besar
FROM stg_pesanan s
LEFT JOIN stg_pelanggan c ON c.email = s.email
""")
for r in con.execute(
    "SELECT id_pesanan, kota, total, is_pesanan_besar FROM int_pesanan ORDER BY id_pesanan"
).fetchall():
    print(" ", r)

Output yang Dihasilkan:

text
=== layer 1: stg_pesanan (1:1 dari sumber, rename + clean + cast) ===
  ('ORD-1', '[email protected]', 'PAID', '2026-01-10 09:15', 250000)
  ('ORD-2', '[email protected]', 'PAID', '2026-01-10 14:30', 180000)
  ('ORD-3', '[email protected]', 'PENDING', '2026-01-11 08:05', 120000)

=== layer 2: int_pesanan (join sumber lain + business logic) ===
  ('ORD-1', 'Jakarta', 250000, False)
  ('ORD-2', 'Bandung', 360000, True)
  ('ORD-3', 'Depok', 120000, False)

Layer berikutnya menyaring dan menyajikan hasilnya untuk konsumen:

python
print()
print("=== layer 3: fct_pesanan di mart (siap konsumen BI) ===")
con.execute("""
CREATE TABLE fct_pesanan AS
SELECT
    p.id_pesanan, p.dibuat_pada, p.status, p.kota, p.segmen, p.total
FROM int_pesanan p
WHERE p.status = 'PAID'
""")
for r in con.execute(
    "SELECT id_pesanan, kota, segmen, total FROM fct_pesanan ORDER BY total DESC"
).fetchall():
    print(" ", r)

print()
print("=== GMV per kota langsung dari mart ===")
for kota, gmv in con.execute(
    "SELECT kota, SUM(total) AS gmv FROM fct_pesanan GROUP BY kota ORDER BY gmv DESC"
).fetchall():
    print(f"  {kota:8s} Rp{gmv}")

Output yang Dihasilkan:

text
=== layer 3: fct_pesanan di mart (siap konsumen BI) ===
  ('ORD-2', 'Bandung', 'premium', 360000)
  ('ORD-1', 'Jakarta', 'reguler', 250000)

=== GMV per kota langsung dari mart ===
  Bandung  Rp360000
  Jakarta  Rp250000

Mari kita telaah bagian-bagian penting skrip di atas:

  1. Blok CREATE TABLE stg_pesanan: rename ord_id menjadi id_pesanan, membersihkan email dengan lower(trim()), menyeragamkan status lewat upper(), dan meng-cast string ord_ts menjadi TIMESTAMP. Tiga nilai status yang tadinya tidak seragam kini tampil sebagai PAID dan PENDING.
  2. Blok CREATE TABLE int_pesanan: menggabungkan stg_pesanan dengan stg_pelanggan lewat email, lalu menambahkan business logic — kolom total dan penanda is_pesanan_besar untuk ambang Rp300.000.
  3. Blok CREATE TABLE fct_pesanan: menyaring hanya pesanan berstatus PAID, sehingga pesanan ORD-3 yang masih PENDING tidak ikut menghitung GMV. Inilah satu-satunya definisi GMV di seluruh proyek: yang dibaca BI, yang diuji, yang dipercaya laporan.

Aturan Main Tiap Layer #

Konvensi hanya berguna bila ditegakkan. Empat aturan intinya:

  • Staging membaca source, tidak pernah join ke tabel lain: satu model membersihkan satu sumber.
  • Intermediate membaca staging atau intermediate lain, tidak membaca source langsung.
  • Marts membaca intermediate atau staging, dan menjadi satu-satunya pintu bagi alat BI.
  • Penamaan mengikuti layer: stg_, int_, fct_, dim_ — nama berkas langsung menunjukkan posisinya di DAG.

Satu hal penting belum kita bahas: fct_pesanan diwujudkan sebagai table yang ditulis ulang penuh setiap dbt run. Untuk ribuan baris hal tersebut selesai dalam hitungan detik, tetapi untuk jutaan baris riwayat pesanan, menulis ulang semuanya setiap hari adalah pemborosan compute. dbt menyediakan materialization khusus untuk masalah ini, materi Chapter Incremental Models.