Web Artisan
Beranda

Progress belajar

Modul 23 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 3 · PostgreSQL dan pgx

Pemodelan Data Online Shop
Skincare

Modul ini adalah sumber kebenaran skema untuk seluruh Roadmap 3. Kita ubah domain skincare shop menjadi blueprint tabel PostgreSQL yang dipakai oleh setiap query, transaksi, dan repository pgx berikutnya.

PostgreSQL: 18Bahasa: Go 1.2615 tabel inti~80 menit baca
01

Kenapa Data Modeling Menentukan API

Schema yang benar membuat handler dan repository jauh lebih sederhana

Di React kamu sering mulai dari shape state, di Laravel kamu sering mulai dari model Eloquent, di backend Go yang memakai pgx kita mulai dari kontrak database yang eksplisit.

Data modeling adalah proses menerjemahkan kebutuhan bisnis menjadi tabel, kolom, relasi, constraint, dan index. Untuk online shop skincare, model data bukan sekadar tempat menyimpan produk. Model ini menentukan apakah checkout konsisten, stok aman dari oversell, invoice tetap benar saat harga produk berubah, dan laporan penjualan bisa dihitung tanpa menebak.

Modul ini berbeda dari modul lain di Roadmap 3. Ia adalah schema authority module: setiap nama tabel, nama kolom, tipe, dan constraint yang kita putuskan di sini akan dipakai apa adanya oleh modul query pgx, modul transaksi checkout, modul indexing, dan modul repository. Maka kita tidak cukup membuat skema yang “jalan”, kita membuat skema yang benar dan stabil untuk dipakai berbulan-bulan ke depan.

🌉Jembatan: dari state frontend ke schema database

Di React, state cart boleh dibentuk ulang kapan saja, karena ia hanya hidup di memori browser. Di database, order yang sudah dibayar adalah jejak bisnis permanen yang dipakai untuk invoice, refund, dan laporan pajak. Karena itu skema order harus menyimpan snapshot harga, nama produk, alamat, dan status yang relevan pada saat checkout, bukan sekadar referensi ke katalog yang masih bisa berubah.

Ada satu mindset shift penting bagi developer yang datang dari Laravel atau dari ORM JavaScript seperti Prisma. Di sana, “model” adalah class di kode aplikasi, dan tabel database sering dianggap detail yang digenerate otomatis. Di jalur Go ini, urutannya dibalik. Skema SQL adalah kontrak utama yang kita tulis dengan sengaja, dan struct Go menyesuaikan diri dengan skema, bukan sebaliknya.

ORM-first (Prisma / Eloquent)
  • Model class atau schema.prisma jadi sumber kebenaran.
  • Tabel dan migrasi digenerate dari model.
  • Relasi dan aturan bisnis menempel ke model.
Schema-first (Go + pgx)
  • DDL SQL adalah sumber kebenaran yang ditulis tangan.
  • Struct Go memetakan kolom hasil query, bukan mendefinisikannya.
  • Constraint hidup di database, aturan alur di service.

Di Roadmap 3 ini kita belum membahas migration runner, transaction helper, atau repository lengkap. Namun skema dari modul ini akan menjadi dasar untuk chapter pgx berikutnya: koneksi pool, query baca, query tulis, transaksi checkout, indexing, dan repository pattern. Setiap baris SQL di sini punya konsekuensi nyata di kode Go nanti.

data modeling

Data modeling adalah desain struktur data yang menjawab tiga hal: data apa yang disimpan, bagaimana data saling berelasi, dan aturan apa yang harus dijaga database lewat constraint, bukan hanya oleh kode aplikasi.

02

Prinsip Desain Skema

Keputusan kecil di DDL bisa menghindari bug mahal saat checkout

Skema yang baik tidak hanya normal, tetapi juga menjaga fakta bisnis yang tidak boleh berubah.

Kita memakai PostgreSQL karena ia kuat untuk transaksi, relasi, constraint, index, JSONB, dan tipe data native yang kaya. Dokumentasi resminya menyediakan rujukan untuk CREATE TABLE, Constraints, dan Data Types. Sepanjang modul ini kita mengacu ke perilaku PostgreSQL 18 yang menjadi versi target proyek.

Sebelum menyentuh tabel pertama, ada empat prinsip yang memandu setiap keputusan. Keempatnya bukan teori akademik, melainkan jawaban atas bug yang benar-benar terjadi di toko online sungguhan.

Normalisasi katalog, snapshot transaksi

Data yang masih hidup (produk, harga, alamat) dinormalisasi agar satu sumber kebenaran. Data yang sudah jadi bukti (order, invoice) menyalin fakta agar tidak ikut berubah.

Constraint dulu, kode belakangan

CHECK, UNIQUE, dan FOREIGN KEY membuat database ikut menjaga aturan. Validasi di service bisa lupa atau di-bypass, constraint database tidak.

Uang selalu integer rupiah

Harga disimpan sebagai bigint dalam satuan rupiah penuh, bukan float, numeric, atau money. Kolom diberi sufiks _rupiah agar niat tegas.

Setiap baris bisa dilacak waktunya

created_at dan updated_at ada di hampir semua tabel. Soft delete deleted_at dipakai di entitas yang tidak boleh hilang dari histori.

Laravel Eloquent
  • Model class sering menjadi pusat relasi, query, dan lifecycle hook.
  • Developer tergoda menaruh aturan bisnis di model dan accessor.
  • Migrasi ditulis dalam PHP lewat schema builder.
Go + pgx
  • Skema SQL dan query eksplisit menjadi kontrak utama.
  • Service Go mengatur alur bisnis, repository pgx mengeksekusi query.
  • Migrasi ditulis sebagai file SQL .up/.down yang dibaca langsung.
💡Prinsip utama untuk proyek ini

Normalisasi data untuk katalog dan user, tetapi snapshot fakta checkout di order. Data yang masih berubah boleh direferensikan, data yang menjadi bukti transaksi harus disalin. Aturan ini akan terus muncul di hampir setiap keputusan desain modul ini.

Kenapa constraint di database, bukan hanya di service Go

Developer JS sering terbiasa memvalidasi di handler atau di middleware, lalu menganggap database sebagai penyimpan pasif. Itu rapuh. Selama umur proyek, data masuk dari banyak jalur: handler API, script seed, job background, perbaikan manual lewat psql, dan import data. Satu-satunya titik yang dilewati semua jalur itu adalah database. Maka aturan yang benar-benar tidak boleh dilanggar (harga tidak negatif, rating antara 1 dan 5, satu cart aktif per user) lebih aman dijaga oleh constraint.

🛡️Analogi: constraint seperti pagar pembatas tol

Validasi di service seperti rambu “kurangi kecepatan”, berguna tapi bisa diabaikan. Constraint database seperti pagar beton di tepi tol. Saat ada satu jalur tak terduga (script manual, bug di handler baru), pagar itu yang mencegah data kotor masuk dan merusak laporan keuangan.

03

Primary Key, Uang, dan Tipe Data

Tiga keputusan tipe yang menentukan kualitas skema seumur proyek

Sebelum menggambar tabel, kita kunci tiga keputusan tipe yang akan diulang ratusan kali di seluruh skema: bentuk primary key, representasi uang, dan pilihan tipe kolom umum.

Primary key: bigint identity, bukan serial atau UUID di mana-mana

Untuk primary key internal, proyek ini memakai bigint GENERATED ALWAYS AS IDENTITY. Ini adalah cara standar SQL untuk membuat kolom auto-increment di PostgreSQL modern, dan menggantikan serial lama yang punya kejutan soal kepemilikan sequence. Hasilnya angka 64-bit yang rapat, cepat di-join, dan hemat di index.

UUID di mana-mana
  • 16 byte per nilai, index lebih besar, join lebih berat.
  • Aman diekspos publik karena tidak bisa ditebak.
  • Bisa dibuat di client sebelum insert.
bigint identity (pilihan kita)
  • 8 byte, kompak, index dan foreign key efisien.
  • Berurutan, bagus untuk locality dan pagination keyset.
  • Dibuat oleh database saat insert, satu sumber kebenaran.

Lalu kapan UUID dipakai? Ketika ID benar-benar diekspos ke pihak luar dan urutannya tidak boleh bocor, misalnya token publik atau referensi yang muncul di URL pihak ketiga. Untuk online shop skincare ini, kita tetap memakai bigint sebagai PK internal, dan untuk hal yang harus aman ditebak kita pakai kolom terpisah yang sengaja unik, seperti order_number (nomor invoice yang dilihat customer) dan idempotency_key.

🌉Jembatan: dari id auto-increment Laravel

Di Laravel, $table->id() membuat bigint unsigned auto_increment. Konsep bigint GENERATED ALWAYS AS IDENTITY di PostgreSQL adalah padanannya yang lebih standar SQL. Bedanya, GENERATED ALWAYS menolak insert manual ke kolom id kecuali kamu pakai OVERRIDING SYSTEM VALUE, sehingga lebih sulit salah.

Uang: selalu bigint rupiah, tidak pernah float

Inilah aturan paling keras di proyek ini. Uang disimpan sebagai bigint dalam satuan rupiah penuh. Rp150.000 disimpan sebagai 150000. Nama kolomnya selalu berakhiran _rupiah: price_rupiah, unit_price_rupiah, subtotal_rupiah, total_rupiah, amount_rupiah. Di Go, field-nya int64 dengan tag JSON yang ringkas seperti price atau total.

Kenapa bukan float, numeric, atau tipe money bawaan? float punya galat pembulatan biner yang fatal untuk uang (0.1 + 0.2 tidak persis 0.3). numeric akurat tetapi lebih lambat dan mengundang desimal yang tidak kita butuhkan, karena rupiah praktis tidak memakai sen. Tipe money PostgreSQL bergantung pada locale server dan sulit dipindahkan. bigint rupiah menghindari semua itu: aritmetika integer yang eksak, cepat, dan jelas.

⚠️Hindari float untuk uang, selamanya

Sekali ada satu kolom uang bertipe float atau double precision, galat pembulatan akan menyebar ke subtotal, diskon, dan total. Bug ini muncul belakangan saat angka sudah besar dan sulit dilacak. Pakai bigint rupiah dari awal, dan jadikan CHECK (price_rupiah >= 0) sebagai pengaman.

Tipe kolom umum lainnya

Beberapa pilihan tipe diulang di seluruh skema, jadi kita sepakati sekali di sini.

KebutuhanTipe yang dipakaiAlasan singkat
Teks bebas (nama, slug, deskripsi)textTanpa batas panjang artifisial. Sama cepat dengan varchar(n) di PostgreSQL. Tambahkan CHECK (length(col) <= n) hanya bila perlu batas nyata.
WaktutimestamptzDisimpan UTC dan tz-aware, memetakan ke time.Time Go. Hindari timestamp tanpa zona.
Status terbatastext + CHECK (... IN (...))Mudah diubah saat status bertambah, tanpa migrasi tipe ENUM yang kaku.
Daftar tag (skin type, concern)text[]Array native PostgreSQL, cocok untuk filter && dan @>.
Data semi-terstruktur (snapshot, payload)jsonbFleksibel, bisa diindeks GIN, tetapi bukan pengganti kolom relasional inti.
Boolean (is_active, is_default)booleanEksplisit, dengan DEFAULT yang jelas.
📝Status pakai CHECK, bukan ENUM

Status seperti pending, paid, shipped ditulis sebagai text dengan CHECK (status IN (...)). Saat bisnis menambah status baru, kita cukup ALTER TABLE ... DROP CONSTRAINT lalu pasang ulang dengan daftar baru. Tipe ENUM native lebih sulit diubah dan tidak bisa menghapus nilai tanpa pekerjaan ekstra.

04

Peta Relasi Online Shop

ERD besar dengan layout elk agar hubungan utama tetap terbaca

Diagram berikut menunjukkan semua kelompok data yang akan dipakai oleh API skincare: customer, katalog, stok, cart, checkout, fulfillment, review, dan marketing.

Sebelum menyelami tiap tabel, lihat dulu peta keseluruhannya. ERD ini adalah pusat modul. Tabel-tabel berikutnya hanyalah perbesaran dari kelompok-kelompok yang kamu lihat di sini. Perhatikan tiga gugus besar: gugus customer (kiri atas), gugus katalog dan stok (tengah), dan gugus transaksi order, payment, shipment (kanan bawah).

---
config:
  layout: elk
---
erDiagram
  USERS ||--o{ ADDRESSES : owns
  USERS ||--o{ CARTS : has
  USERS ||--o{ ORDERS : places
  USERS ||--o{ REVIEWS : writes

  BRANDS ||--o{ PRODUCTS : owns
  PRODUCTS ||--o{ PRODUCT_VARIANTS : has
  PRODUCTS ||--o{ PRODUCT_CATEGORIES : tagged_as
  CATEGORIES ||--o{ PRODUCT_CATEGORIES : groups
  CATEGORIES ||--o{ CATEGORIES : parent_of

  PRODUCT_VARIANTS ||--|| INVENTORIES : stocked_as
  CARTS ||--o{ CART_ITEMS : contains
  PRODUCT_VARIANTS ||--o{ CART_ITEMS : selected_as

  ORDERS ||--o{ ORDER_ITEMS : contains
  PRODUCT_VARIANTS ||--o{ ORDER_ITEMS : snapshot_from
  ORDERS ||--o{ PAYMENTS : paid_by
  ORDERS ||--o| SHIPMENTS : shipped_by

  ORDER_ITEMS ||--o| REVIEWS : reviewed_from
  PRODUCTS ||--o{ REVIEWS : receives
  PROMOTIONS ||--o{ PROMOTION_PRODUCTS : applies_to
  PRODUCTS ||--o{ PROMOTION_PRODUCTS : eligible_for

  USERS {
    bigint id PK
    text name
    text email UK
    text password_hash
    text role
    timestamptz created_at
    timestamptz deleted_at
  }

  ADDRESSES {
    bigint id PK
    bigint user_id FK
    text recipient_name
    text city
    boolean is_default
  }

  BRANDS {
    bigint id PK
    text slug UK
    text name
  }

  CATEGORIES {
    bigint id PK
    bigint parent_id FK
    text slug UK
    text name
  }

  PRODUCTS {
    bigint id PK
    bigint brand_id FK
    text slug UK
    text name
    text status
  }

  PRODUCT_VARIANTS {
    bigint id PK
    bigint product_id FK
    text sku UK
    bigint price_rupiah
    text shade
  }

  PRODUCT_CATEGORIES {
    bigint product_id FK
    bigint category_id FK
  }

  INVENTORIES {
    bigint id PK
    bigint variant_id FK
    integer quantity_available
    integer quantity_reserved
  }

  CARTS {
    bigint id PK
    bigint user_id FK
    text session_id
    text status
  }

  CART_ITEMS {
    bigint id PK
    bigint cart_id FK
    bigint variant_id FK
    integer quantity
  }

  ORDERS {
    bigint id PK
    bigint user_id FK
    text order_number UK
    text idempotency_key UK
    text status
    bigint total_rupiah
  }

  ORDER_ITEMS {
    bigint id PK
    bigint order_id FK
    bigint variant_id FK
    text product_name
    text sku
    bigint unit_price_rupiah
    integer quantity
  }

  PAYMENTS {
    bigint id PK
    bigint order_id FK
    text provider
    text status
    bigint amount_rupiah
  }

  SHIPMENTS {
    bigint id PK
    bigint order_id FK
    text courier
    text tracking_number
    text status
  }

  REVIEWS {
    bigint id PK
    bigint user_id FK
    bigint product_id FK
    bigint order_item_id FK
    integer rating
    text status
  }

  PROMOTIONS {
    bigint id PK
    text code UK
    text discount_type
    bigint discount_value
  }

  PROMOTION_PRODUCTS {
    bigint promotion_id FK
    bigint product_id FK
  }

Gambar 1. ERD lengkap untuk blueprint online shop skincare. Notasi crow’s foot menandai kardinalitas: satu-ke-banyak, satu-ke-satu, dan satu-ke-nol-atau-satu. Mermaid memakai layout elk agar diagram sebesar ini tetap terbaca.

🧭Cara membaca ERD ini

Relasi katalog dan user bersifat normalisasi: satu fakta disimpan satu kali dan direferensikan lewat foreign key. Relasi order tetap menyimpan foreign key ke variant untuk pelacakan, tetapi juga menyimpan snapshot nama, SKU, dan harga, agar invoice lama tidak berubah saat katalog diedit.

Membaca kardinalitas: garis crow’s foot

Notasi ERD ini mungkin baru bagi yang terbiasa dengan diagram class React. Cara cepat membacanya: ujung “cabang ayam” (banyak) menempel di tabel anak, ujung lurus (satu) menempel di tabel induk, dan lingkaran berarti opsional (boleh nol).

Satu user, banyak address

Satu sisi di USERS, sisi banyak di ADDRESSES. Customer boleh punya beberapa alamat di address book.

Satu varian, satu stok

Relasi satu-ke-satu antara PRODUCT_VARIANTS dan INVENTORIES. Tepat satu baris stok per SKU.

Satu order, nol atau satu shipment

Relasi satu-ke-nol-atau-satu. Order yang belum dikirim belum punya baris shipment.

Satu produk, banyak review

Sisi banyak di REVIEWS. Satu produk bisa menerima banyak ulasan, atau belum ada sama sekali.

05

Customer, Katalog, dan Varian

Pisahkan produk konseptual dari SKU yang benar-benar dijual

Dalam skincare, satu produk bisa punya beberapa ukuran, shade, bundle, atau varian harga. Karena itu products dan product_variants harus dipisah.

User dan address

users menyimpan identitas login dan role. Kolom role dibatasi CHECK (role IN ('customer', 'admin')) karena toko ini hanya butuh dua peran. email UNIQUE mencegah dua akun dengan email sama. Kolom deleted_at membuat soft delete: saat user “menghapus” akun, kita set deleted_at = now() agar order lama yang mereferensikan user tidak ikut hilang.

addresses dipisah karena satu user bisa punya banyak alamat. Saat checkout, alamat dari addresses tidak cukup hanya direferensikan oleh order. Order harus menyimpan salinan alamat sebagai jsonb (shipping_address), karena user bisa mengubah atau menghapus alamat setelah order dibayar, dan invoice harus tetap menampilkan ke mana barang dikirim saat itu.

🌉Jembatan: alamat seperti props, order seperti receipt

Di React, komponen checkout bisa menerima address sebagai props yang berubah-ubah. Setelah pembayaran berhasil, order bukan props lagi, tetapi receipt yang dibekukan. Receipt harus tetap sama walau user mengubah address book setelahnya. Itulah kenapa orders.shipping_address adalah snapshot, bukan foreign key.

Soft delete punya satu konsekuensi yang sering terlewat: index UNIQUE pada email tetap berlaku untuk baris yang sudah “dihapus”. Jadi jika kamu butuh mengizinkan email lama dipakai ulang setelah soft delete, kamu perlu unique index parsial seperti WHERE deleted_at IS NULL. Untuk proyek ini kita biarkan unik penuh demi kesederhanaan, dan menandainya sebagai keputusan yang sadar.

Brand, category, product, variant

brands dipisah dari products supaya filter brand konsisten dan satu brand bisa punya banyak produk. Foreign key products.brand_id memakai ON DELETE RESTRICT: sebuah brand tidak boleh dihapus selama masih punya produk, karena itu akan meninggalkan produk yatim.

categories mendukung parent_id yang mereferensikan dirinya sendiri, sehingga bisa membentuk pohon seperti Cleanser, Serum, Sunscreen, dan Makeup Remover, dengan subkategori di bawahnya. Relasi produk ke kategori dibuat many-to-many melalui tabel join product_categories, karena satu serum bisa masuk kategori Serum, Brightening, dan Sensitive Skin sekaligus.

flowchart LR
  B["brands"] -->|"1 brand, banyak produk"| P["products"]
  P -->|"1 produk, banyak SKU"| V["product_variants"]
  V -->|"1 SKU, 1 baris stok"| I["inventories"]
  P -. "many-to-many" .-> PC["product_categories"]
  C["categories"] -. "many-to-many" .-> PC
  C -->|"parent_id"| C

Gambar 2. Aliran katalog: brand memayungi product, product punya banyak variant (SKU), dan setiap variant punya satu baris inventory. Kategori terhubung ke produk lewat tabel join.

Pemisahan products dan product_variants adalah inti desain katalog. products mewakili halaman produk yang dilihat pengunjung: nama, slug, deskripsi, brand, status publikasi, dan atribut skincare seperti skin_types dan concerns. product_variants mewakili SKU yang benar-benar dijual dan distok: ukuran 30ml, shade tertentu, barcode, berat, dan yang paling penting, harga jual price_rupiah.

products

Halaman produk konseptual. Tidak punya harga dan tidak punya stok. Punya slug UNIQUE untuk URL, status (draft, active, archived), dan atribut katalog.

product_variants

SKU yang dijual. Memegang price_rupiah, sku UNIQUE, size_label, shade, dan is_active. Inilah unit yang ditambahkan ke cart dan order.

⚠️Harga ada di variant, bukan di product

Kesalahan umum adalah menaruh price di tabel products. Padahal serum 30ml dan 60ml dari produk yang sama berbeda harga. Harga jual hidup di product_variants.price_rupiah. Tabel products sengaja tidak punya kolom harga sama sekali.

SKU

SKU (Stock Keeping Unit) adalah identitas operasional untuk item yang benar-benar bisa dibeli dan distok. Dalam skema ini SKU berada di product_variants.sku dan bersifat UNIQUE, bukan di products.

🌉Jembatan: product vs variant seperti komponen vs instance

Bayangkan di React kamu punya komponen Button (definisi) dan banyak <Button size="sm" /> (instance dengan props berbeda). products adalah definisinya, product_variants adalah instance konkret dengan ukuran, shade, dan harga yang spesifik. Cart dan order selalu menunjuk ke instance (variant), bukan ke definisi (product).

06

Inventory dan Cart

Stok adalah angka bisnis, cart adalah niat belanja yang belum final

Inventory harus aman untuk checkout, sedangkan cart harus fleksibel karena user masih bisa berubah pikiran.

Inventory dipisah dari variant

inventories dibuat one-to-one dengan product_variants lewat foreign key variant_id yang UNIQUE. Kenapa stok tidak jadi kolom di product_variants saja? Karena stok adalah angka yang sering berubah dan sering dikunci (SELECT ... FOR UPDATE) saat checkout, sedangkan data variant relatif stabil. Memisahkannya membuat update stok tidak mengganggu pembacaan katalog dan mengurangi kontensi lock.

Inventory punya dua angka penting yang sering tertukar. quantity_available adalah stok yang siap dijual. quantity_reserved adalah stok yang sedang ditahan sementara, misalnya saat checkout sedang menunggu pembayaran. Table-level CHECK (quantity_available >= quantity_reserved) menjaga agar reservasi tidak pernah melebihi stok nyata.

🌉Jembatan: available vs reserved seperti optimistic UI

Di React kamu mengenal optimistic update: UI langsung menampilkan perubahan sebelum server mengonfirmasi. quantity_reserved adalah versi backend dari ide itu. Stok ditahan dulu agar dua pembeli tidak merebut item terakhir yang sama, lalu dikonfirmasi (dikurangi dari available) saat pembayaran sukses, atau dilepas saat pembayaran gagal.

stateDiagram-v2
  [*] --> Available: stok masuk
  Available --> Reserved: checkout mulai (tahan stok)
  Reserved --> Sold: pembayaran sukses
  Reserved --> Available: pembayaran gagal / timeout (lepas)
  Sold --> [*]

Gambar 3. Siklus hidup satu unit stok. Reservasi adalah jeda aman antara niat beli dan pembayaran sukses, mencegah oversell pada item terakhir.

Operasi penahanan dan pelepasan stok ini akan dijaga dengan transaksi dan row lock di modul transaksi Roadmap 3. Di modul ini cukup kita pastikan strukturnya benar: dua angka terpisah, dengan constraint yang menjaga keduanya tetap masuk akal.

Cart: niat belanja, bukan transaksi

carts mendukung user login dan guest session. Karena itu user_id boleh NULL, tetapi session_id harus ada jika user belum login. Table-level CHECK (user_id IS NOT NULL OR session_id IS NOT NULL) memastikan minimal salah satunya terisi, sehingga tidak ada cart tanpa pemilik.

Yang menarik adalah dua partial unique index. carts_one_active_per_user_idx menjaga satu user hanya punya satu cart berstatus active dalam satu waktu, dan carts_one_active_per_session_idx melakukan hal sama untuk guest. Ini elegan: aturan “satu cart aktif” dijaga database, bukan kode, dan tetap mengizinkan banyak cart lama yang sudah converted atau abandoned.

🌉Jembatan: partial index seperti where pada useEffect dependency

Partial unique index ... WHERE status = 'active' hanya memberlakukan keunikan pada baris yang cocok kondisi, mirip cara kamu membatasi efek hanya pada kondisi tertentu. Cart lama yang abandoned tidak ikut diperiksa, jadi user bisa punya riwayat banyak cart, tapi hanya satu yang aktif.

Keputusan paling penting tentang cart: cart_items tidak menyimpan harga. Hanya menyimpan variant_id dan quantity. Harga dibaca segar saat checkout. Ini sengaja, karena harga di cart hanyalah preview yang bisa kedaluwarsa, sedangkan harga yang mengikat secara hukum adalah harga saat order dibuat.

Cart di frontend
  • Bisa disimpan di state, localStorage, atau cache.
  • Harga dan stok yang tampil hanya preview.
  • Boleh dibentuk ulang kapan saja.
Cart di database
  • Menjadi sumber sementara untuk checkout.
  • Tidak menyimpan harga, hanya variant dan quantity.
  • Harus divalidasi ulang sebelum order dibuat.
⚠️Jangan percaya cart saat checkout

Cart item tidak menyimpan harga final. Saat checkout, service harus membaca price_rupiah dan stok terbaru, menghitung diskon, lalu membuat orders dan order_items dalam satu transaksi. Memakai harga dari cart yang basi adalah celah yang bisa dipakai membeli barang dengan harga lama.

07

Order, Payment, dan Shipment

Bagian ini adalah audit trail bisnis, bukan sekadar cache dari katalog

Checkout adalah momen ketika data yang sebelumnya fleksibel berubah menjadi fakta transaksi yang dibekukan.

orders menyimpan header transaksi: user, nomor order, status, total uang, alamat pengiriman snapshot, dan kode promosi yang dipakai. order_items menyimpan daftar item yang dibeli beserta snapshot-nya. Keputusan desain terpenting dalam modul ini hidup di sini: order menyalin fakta, tidak hanya mereferensikannya.

Snapshot harga: membekukan fakta saat checkout

order_items menyimpan product_name, variant_name, sku, dan unit_price_rupiah sebagai salinan, bukan sebagai join ke katalog. Harga produk bisa berubah besok, nama produk bisa diedit, variant bisa dinonaktifkan, bahkan brand bisa ganti nama. Invoice dan laporan penjualan tetap harus menunjukkan fakta saat order dibuat.

flowchart TD
  subgraph Katalog["Katalog (bisa berubah)"]
    PV["product_variants<br/>price_rupiah = 159000"]
  end
  subgraph Order["Order (dibekukan saat checkout)"]
    OI["order_items<br/>unit_price_rupiah = 159000<br/>sku, product_name disalin"]
  end
  PV -->|"checkout: SALIN nilai"| OI
  PV -. "FK untuk pelacakan saja" .-> OI
  PV2["Besok: price_rupiah = 179000"] -.->|"tidak mengubah"| OI

Gambar 4. Saat checkout, harga dan identitas item disalin ke order_items. Perubahan harga katalog esok hari tidak menyentuh order yang sudah jadi. Foreign key ke variant tetap ada, tapi hanya untuk pelacakan, bukan sumber harga.

💡Kenapa harga di order_items harus snapshot

Tanpa snapshot, kamu harus join ke product_variants untuk menampilkan invoice lama, dan harga yang muncul akan ikut harga sekarang, bukan harga saat pembelian. Itu salah secara akuntansi dan bisa jadi masalah hukum. Maka order_items.unit_price_rupiah disalin saat checkout dan tidak pernah diubah lagi.

Total order dijaga konsisten oleh constraint, bukan oleh kepercayaan pada kode. orders punya CHECK (total_rupiah = subtotal_rupiah + shipping_rupiah - discount_rupiah). Sementara order_items.line_total_rupiah adalah generated column yang dihitung database dari (unit_price_rupiah * quantity) - item_discount_rupiah. Aritmetika uang tidak diserahkan ke aplikasi sepenuhnya.

🌉Jembatan: generated column seperti computed property

Di Vue ada computed property, di React ada nilai turunan yang dihitung dari state. line_total_rupiah GENERATED ALWAYS AS (...) STORED adalah versi database-nya: kolom yang nilainya selalu dihitung dari kolom lain dan tidak bisa di-set manual. Konsistensinya dijaga PostgreSQL, bukan oleh ingatan kita untuk menghitung ulang.

Idempotency: mencegah double order

orders punya idempotency_key yang UNIQUE. Saat tombol “Bayar” diklik dua kali, atau jaringan mengirim ulang request, client mengirim idempotency key yang sama. Karena kolom itu UNIQUE, insert kedua gagal di level database, dan service bisa mengembalikan order yang sudah ada alih-alih membuat order ganda.

🌉Jembatan: idempotency key seperti dedup di React Query

Di React Query, mutationKey membantu menghindari mutation ganda. idempotency_key adalah pengaman yang sama tapi di sisi server dan permanen di database. Bedanya, ini bukan sekadar optimasi UI, melainkan jaminan keras bahwa satu intent pembayaran menghasilkan tepat satu order, dijaga oleh unique constraint.

Payment dan shipment dipisah dari order

payments dibuat terpisah dari orders karena satu order bisa punya lebih dari satu attempt pembayaran. Payment pertama bisa expired, lalu user mencoba lagi dengan metode lain. Setiap attempt adalah baris sendiri dengan status dan amount_rupiah. Kolom event_id menyimpan id event dari provider untuk idempotency webhook, dan partial unique index mencegah memproses event yang sama dua kali.

shipments dipisah karena pengiriman punya lifecycle sendiri: pending, packed, shipped, delivered, failed, atau returned. Relasinya one-to-one dengan order (order_id UNIQUE). Seperti order, shipment juga menyimpan address_snapshot dan recipient_name, sehingga label pengiriman tetap benar walau alamat asal berubah.

sequenceDiagram
  participant FE as React Checkout
  participant API as Go API
  participant DB as PostgreSQL
  FE->>API: POST /v1/orders (idempotency_key)
  API->>DB: BEGIN
  API->>DB: SELECT ... FOR UPDATE pada inventories
  API->>DB: INSERT orders (idempotency_key UNIQUE)
  API->>DB: INSERT order_items (snapshot harga & sku)
  API->>DB: INSERT payments (attempt pertama)
  API->>DB: COMMIT
  API-->>FE: 201 Created + order_number

Gambar 5. Order, item snapshot, dan payment attempt dibuat dalam satu transaksi. Row lock pada inventory mencegah oversell, dan idempotency key mencegah order ganda. Alur transaksi ini diperdalam di modul transaksi.

📌Order pakai RESTRICT, bukan CASCADE

Foreign key orders.user_id memakai ON DELETE RESTRICT, bukan CASCADE. Order adalah histori bisnis permanen. Jika user dihapus keras dan order ikut terhapus, kamu kehilangan catatan penjualan. Karena itu user dihapus lewat soft delete (deleted_at), dan order tetap utuh.

08

Review dan Promotion

Data pasca-transaksi dan marketing tetap harus punya batasan yang jelas

Review membantu katalog, promotion membantu conversion, tetapi keduanya tidak boleh merusak fakta order lama.

reviews terhubung ke users, products, dan opsional order_items. Jika review berasal dari pembelian nyata, order_item_id membuat review bisa ditandai sebagai verified purchase. Constraint CHECK (rating BETWEEN 1 AND 5) menjaga rentang nilai, dan UNIQUE (user_id, product_id) mencegah satu user menulis dua review untuk produk yang sama.

🌉Jembatan: verified purchase lewat nullable FK

order_item_id adalah foreign key yang boleh NULL. Saat ada nilainya, ia menautkan review ke item order yang nyata, mirip optional chaining di TS (order?.item). Saat NULL, review tetap valid tapi tanpa lencana verified. ON DELETE SET NULL menjaga review tetap ada walau item order sumbernya hilang.

promotions menyimpan kode, tipe diskon (percentage atau fixed_amount), nilai diskon, periode aktif (starts_at, ends_at), batas penggunaan, dan minimum order. Constraint CHECK (ends_at > starts_at) menjaga periode masuk akal. Tabel join promotion_products membuat promosi bisa dibatasi ke produk tertentu; jika kosong, service menafsirkannya sebagai promo global.

Promotion definition

promotions menjawab aturan diskon yang berlaku saat ini: tipe, nilai, periode, dan batas. Data ini hidup dan bisa diubah.

Order snapshot

orders.promotion_code dan discount_rupiah menyimpan hasil diskon saat checkout. Data ini beku dan tidak ikut berubah saat promosi diedit.

📝Promosi bukan foreign key wajib di order

Order menyimpan promotion_code sebagai teks snapshot, sengaja bukan foreign key. Dengan begitu laporan tetap bisa dibaca walau promosi sudah dinonaktifkan atau dihapus dari katalog aktif. Sama persisnya dengan harga: yang menjadi bukti transaksi disalin, bukan direferensikan.

🌉Jembatan: percentage vs fixed seperti discriminated union

discount_type plus discount_value membentuk pola yang mirip discriminated union di TypeScript: satu kolom menentukan tipe, kolom lain memuat nilainya. Service membaca discount_type untuk memutuskan apakah discount_value ditafsirkan sebagai persen atau sebagai rupiah penuh, seperti switch pada field kind.

09

DDL Penuh dan Tabel Kunci

Satu blueprint SQL yang menjadi fondasi seluruh Roadmap 3

DDL berikut adalah baseline schema lengkap. Di section berikutnya ia dipecah menjadi migration file, tetapi secara konsep inilah blueprint utama proyek.

Sebelum membaca seluruh DDL, perhatikan dua tabel paling kritis. product_variants adalah sumber harga, dan orders adalah audit trail uang. Dua tabel ini paling sering dipakai dan paling tidak boleh salah, jadi kolom kuncinya kita rangkum lebih dulu.

Kolom kunci product_variants

KolomTipeCatatan
idbigint identityPrimary key internal.
product_idbigint FKKe products.id, ON DELETE CASCADE.
skutext UNIQUEIdentitas operasional SKU.
variant_nametextNama varian, misalnya “30ml” atau shade tertentu.
price_rupiahbigintHarga jual rupiah penuh. CHECK (price_rupiah >= 0).
compare_at_price_rupiahbigint NULLHarga coret, harus ≥ price_rupiah bila diisi.
is_activebooleanVarian bisa dimatikan tanpa dihapus.

Kolom kunci orders

KolomTipeCatatan
idbigint identityPrimary key internal.
user_idbigint FKKe users.id, ON DELETE RESTRICT (histori permanen).
order_numbertext UNIQUENomor invoice yang dilihat customer.
idempotency_keytext UNIQUEPencegah order ganda saat retry.
statustext + CHECKpending, paid, processing, shipped, completed, cancelled, refunded.
subtotal_rupiahbigintJumlah harga item sebelum ongkir dan diskon.
discount_rupiahbigintDiskon total, default 0.
shipping_rupiahbigintOngkir, default 0.
total_rupiahbigintDijaga CHECK (total_rupiah = subtotal_rupiah + shipping_rupiah - discount_rupiah).
shipping_addressjsonbSnapshot alamat saat checkout.

Berikut DDL penuh. Bacalah dari atas ke bawah, dari users (akar) menuju orders dan turunannya. Urutan ini penting karena foreign key hanya bisa menunjuk ke tabel yang sudah ada.

schema.sql
-- PostgreSQL 18+ -- Blueprint utama Online Shop Skincare untuk Roadmap 3. -- Uang selalu bigint rupiah penuh. PK selalu bigint GENERATED ALWAYS AS IDENTITY. CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('customer', 'admin')), phone TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE TABLE addresses ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL, recipient_name TEXT NOT NULL, phone TEXT NOT NULL, line1 TEXT NOT NULL, line2 TEXT, city TEXT NOT NULL, province TEXT NOT NULL, postal_code TEXT NOT NULL, country TEXT NOT NULL DEFAULT 'ID', is_default BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX addresses_user_id_idx ON addresses(user_id); CREATE UNIQUE INDEX addresses_one_default_per_user_idx ON addresses(user_id) WHERE is_default; CREATE TABLE brands ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE categories ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, parent_id bigint REFERENCES categories(id) ON DELETE SET NULL, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX categories_parent_id_idx ON categories(parent_id); CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, brand_id bigint NOT NULL REFERENCES brands(id) ON DELETE RESTRICT, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', skin_types TEXT[] NOT NULL DEFAULT '{}'::text[], concerns TEXT[] NOT NULL DEFAULT '{}'::text[], ingredients JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE INDEX products_brand_id_idx ON products(brand_id); CREATE INDEX products_status_idx ON products(status); CREATE TABLE product_variants ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE, sku TEXT NOT NULL UNIQUE, variant_name TEXT NOT NULL, size_label TEXT, shade TEXT, price_rupiah bigint NOT NULL CHECK (price_rupiah >= 0), compare_at_price_rupiah bigint CHECK (compare_at_price_rupiah IS NULL OR compare_at_price_rupiah >= price_rupiah), weight_grams INTEGER CHECK (weight_grams IS NULL OR weight_grams > 0), barcode TEXT, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX product_variants_product_id_idx ON product_variants(product_id); CREATE INDEX product_variants_active_idx ON product_variants(is_active); CREATE TABLE product_categories ( product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE, category_id bigint NOT NULL REFERENCES categories(id) ON DELETE RESTRICT, PRIMARY KEY (product_id, category_id) ); CREATE INDEX product_categories_category_id_idx ON product_categories(category_id); CREATE TABLE inventories ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, variant_id bigint NOT NULL UNIQUE REFERENCES product_variants(id) ON DELETE CASCADE, quantity_available INTEGER NOT NULL DEFAULT 0 CHECK (quantity_available >= 0), quantity_reserved INTEGER NOT NULL DEFAULT 0 CHECK (quantity_reserved >= 0), low_stock_threshold INTEGER NOT NULL DEFAULT 5 CHECK (low_stock_threshold >= 0), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (quantity_available >= quantity_reserved) ); CREATE TABLE carts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint REFERENCES users(id) ON DELETE CASCADE, session_id TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'converted', 'abandoned')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (user_id IS NOT NULL OR session_id IS NOT NULL) ); CREATE INDEX carts_user_id_idx ON carts(user_id); CREATE UNIQUE INDEX carts_one_active_per_user_idx ON carts(user_id) WHERE status = 'active' AND user_id IS NOT NULL; CREATE UNIQUE INDEX carts_one_active_per_session_idx ON carts(session_id) WHERE status = 'active' AND session_id IS NOT NULL; CREATE TABLE cart_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, cart_id bigint NOT NULL REFERENCES carts(id) ON DELETE CASCADE, variant_id bigint NOT NULL REFERENCES product_variants(id) ON DELETE RESTRICT, quantity INTEGER NOT NULL CHECK (quantity > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (cart_id, variant_id) ); CREATE INDEX cart_items_variant_id_idx ON cart_items(variant_id); CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE RESTRICT, order_number TEXT NOT NULL UNIQUE, idempotency_key TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'processing', 'shipped', 'completed', 'cancelled', 'refunded')), payment_status TEXT NOT NULL DEFAULT 'unpaid' CHECK (payment_status IN ('unpaid', 'pending', 'paid', 'failed', 'refunded')), currency TEXT NOT NULL DEFAULT 'IDR', subtotal_rupiah bigint NOT NULL CHECK (subtotal_rupiah >= 0), discount_rupiah bigint NOT NULL DEFAULT 0 CHECK (discount_rupiah >= 0), shipping_rupiah bigint NOT NULL DEFAULT 0 CHECK (shipping_rupiah >= 0), total_rupiah bigint NOT NULL CHECK (total_rupiah >= 0), promotion_code TEXT, shipping_address JSONB NOT NULL, note TEXT, placed_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (subtotal_rupiah + shipping_rupiah >= discount_rupiah), CHECK (total_rupiah = subtotal_rupiah + shipping_rupiah - discount_rupiah) ); CREATE INDEX orders_user_id_created_at_idx ON orders(user_id, created_at DESC); CREATE INDEX orders_status_idx ON orders(status); CREATE TABLE order_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE, variant_id bigint NOT NULL REFERENCES product_variants(id) ON DELETE RESTRICT, product_name TEXT NOT NULL, variant_name TEXT NOT NULL, sku TEXT NOT NULL, unit_price_rupiah bigint NOT NULL CHECK (unit_price_rupiah >= 0), quantity INTEGER NOT NULL CHECK (quantity > 0), item_discount_rupiah bigint NOT NULL DEFAULT 0 CHECK (item_discount_rupiah >= 0), line_total_rupiah bigint GENERATED ALWAYS AS ((unit_price_rupiah * quantity) - item_discount_rupiah) STORED, snapshot_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK ((unit_price_rupiah * quantity) >= item_discount_rupiah) ); CREATE INDEX order_items_order_id_idx ON order_items(order_id); CREATE INDEX order_items_variant_id_idx ON order_items(variant_id); CREATE TABLE payments ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE, provider TEXT NOT NULL, provider_reference TEXT, event_id TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'expired', 'refunded')), amount_rupiah bigint NOT NULL CHECK (amount_rupiah >= 0), raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, paid_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX payments_order_id_idx ON payments(order_id); CREATE UNIQUE INDEX payments_provider_reference_idx ON payments(provider, provider_reference) WHERE provider_reference IS NOT NULL; CREATE UNIQUE INDEX payments_event_id_idx ON payments(event_id) WHERE event_id IS NOT NULL; CREATE TABLE shipments ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL UNIQUE REFERENCES orders(id) ON DELETE CASCADE, courier TEXT NOT NULL, service_level TEXT, tracking_number TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'packed', 'shipped', 'delivered', 'failed', 'returned')), recipient_name TEXT NOT NULL, phone TEXT NOT NULL, address_snapshot JSONB NOT NULL, shipped_at TIMESTAMPTZ, delivered_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX shipments_status_idx ON shipments(status); CREATE INDEX shipments_tracking_number_idx ON shipments(tracking_number); CREATE TABLE reviews ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE, order_item_id bigint REFERENCES order_items(id) ON DELETE SET NULL, rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), title TEXT, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'published', 'rejected')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (user_id, product_id) ); CREATE INDEX reviews_product_id_status_idx ON reviews(product_id, status); CREATE TABLE promotions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, discount_type TEXT NOT NULL CHECK (discount_type IN ('percentage', 'fixed_amount')), discount_value bigint NOT NULL CHECK (discount_value > 0), min_order_rupiah bigint NOT NULL DEFAULT 0 CHECK (min_order_rupiah >= 0), max_discount_rupiah bigint CHECK (max_discount_rupiah IS NULL OR max_discount_rupiah >= 0), starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, usage_limit INTEGER CHECK (usage_limit IS NULL OR usage_limit > 0), used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0), is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (ends_at > starts_at) ); CREATE INDEX promotions_active_period_idx ON promotions(is_active, starts_at, ends_at); CREATE TABLE promotion_products ( promotion_id bigint NOT NULL REFERENCES promotions(id) ON DELETE CASCADE, product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE, PRIMARY KEY (promotion_id, product_id) ); CREATE INDEX promotion_products_product_id_idx ON promotion_products(product_id);
💡Generated column di order_items

line_total_rupiah memakai generated column agar database menghitung total baris dari unit_price_rupiah, quantity, dan item_discount_rupiah. Kata kunci STORED berarti nilainya benar-benar disimpan di disk dan ikut diindeks, bukan dihitung ulang setiap dibaca. PostgreSQL menjamin nilai ini selalu konsisten dengan kolom sumbernya.

📌Tentang updated_at

DDL ini tidak membuat trigger otomatis untuk updated_at. Di proyek Go, kita akan mengatur updated_at = now() secara eksplisit di setiap query UPDATE, supaya perilaku terlihat jelas di repository pgx dan tidak ada magic tersembunyi. Trigger bisa ditambahkan nanti bila diperlukan.

10

Memecah Skema jadi Migration

Dari satu schema.sql menjadi pasangan up dan down yang berurutan

Satu file schema.sql enak dibaca, tetapi produksi butuh migrasi bertahap yang bisa maju dan mundur. Inilah cara kita memecahnya.

Di Laravel kamu menulis migration sebagai class PHP dengan method up() dan down(). Di jalur Go ini kita memakai golang-migrate, yang membaca pasangan file SQL polos: satu .up.sql untuk maju dan satu .down.sql untuk mundur. Tidak ada DSL, hanya SQL yang sama persis dengan yang sudah kamu pahami.

🌉Jembatan: dari Schema::create ke file .up.sql

Schema::create('users', function (Blueprint $table) {...}) di Laravel menjadi sebuah file 000001_create_users.up.sql berisi CREATE TABLE users (...). Method down() yang melakukan Schema::dropIfExists('users') menjadi 000001_create_users.down.sql berisi DROP TABLE IF EXISTS users. Konsepnya identik, hanya bentuknya SQL telanjang.

Penomoran berurutan menentukan urutan eksekusi, dan urutan harus menghormati dependensi foreign key: tabel induk dibuat sebelum tabel anak. Kita kelompokkan tabel yang berkerabat ke dalam satu migration agar jumlah file tetap terkelola.

Struktur folder migrations
  • migrations/
  • 000001_create_users.up.sql users, addresses
  • 000001_create_users.down.sql
  • 000002_create_catalog.up.sql brands, categories, products, product_variants, product_categories
  • 000002_create_catalog.down.sql
  • 000003_create_inventory.up.sql inventories
  • 000003_create_inventory.down.sql
  • 000004_create_cart.up.sql carts, cart_items
  • 000004_create_cart.down.sql
  • 000005_create_orders.up.sql orders, order_items
  • 000005_create_orders.down.sql
  • 000006_create_payments.up.sql payments, shipments
  • 000006_create_payments.down.sql
  • 000007_create_reviews.up.sql reviews
  • 000007_create_reviews.down.sql
  • 000008_create_promotions.up.sql promotions, promotion_products
  • 000008_create_promotions.down.sql

Setiap .up.sql berisi CREATE TABLE dan CREATE INDEX untuk kelompoknya. Setiap .down.sql membatalkannya dengan urutan terbalik: tabel anak di-drop dulu, baru tabel induk, agar foreign key tidak menghalangi.

migrations/000005_create_orders.up.sql
CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE RESTRICT, order_number TEXT NOT NULL UNIQUE, idempotency_key TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'processing', 'shipped', 'completed', 'cancelled', 'refunded')), payment_status TEXT NOT NULL DEFAULT 'unpaid' CHECK (payment_status IN ('unpaid', 'pending', 'paid', 'failed', 'refunded')), currency TEXT NOT NULL DEFAULT 'IDR', subtotal_rupiah bigint NOT NULL CHECK (subtotal_rupiah >= 0), discount_rupiah bigint NOT NULL DEFAULT 0 CHECK (discount_rupiah >= 0), shipping_rupiah bigint NOT NULL DEFAULT 0 CHECK (shipping_rupiah >= 0), total_rupiah bigint NOT NULL CHECK (total_rupiah >= 0), promotion_code TEXT, shipping_address JSONB NOT NULL, note TEXT, placed_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (subtotal_rupiah + shipping_rupiah >= discount_rupiah), CHECK (total_rupiah = subtotal_rupiah + shipping_rupiah - discount_rupiah) ); CREATE INDEX orders_user_id_created_at_idx ON orders(user_id, created_at DESC); CREATE INDEX orders_status_idx ON orders(status); CREATE TABLE order_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE, variant_id bigint NOT NULL REFERENCES product_variants(id) ON DELETE RESTRICT, product_name TEXT NOT NULL, variant_name TEXT NOT NULL, sku TEXT NOT NULL, unit_price_rupiah bigint NOT NULL CHECK (unit_price_rupiah >= 0), quantity INTEGER NOT NULL CHECK (quantity > 0), item_discount_rupiah bigint NOT NULL DEFAULT 0 CHECK (item_discount_rupiah >= 0), line_total_rupiah bigint GENERATED ALWAYS AS ((unit_price_rupiah * quantity) - item_discount_rupiah) STORED, snapshot_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK ((unit_price_rupiah * quantity) >= item_discount_rupiah) ); CREATE INDEX order_items_order_id_idx ON order_items(order_id); CREATE INDEX order_items_variant_id_idx ON order_items(variant_id);
migrations/000005_create_orders.down.sql
DROP TABLE IF EXISTS order_items; DROP TABLE IF EXISTS orders;
⚠️Down harus benar-benar membalik up

Banyak developer menulis .up.sql dengan teliti lalu mengisi .down.sql asal-asalan. Saat rollback dibutuhkan di produksi, down yang salah justru memperparah keadaan. Tulis down dengan urutan drop terbalik (anak dulu, induk belakangan), dan uji rollback di lokal setiap kali menulis migrasi baru.

📝Detail runner ada di modul migration

Modul ini fokus pada isi SQL dan urutan dependensi. Cara menjalankan golang-migrate, embedding lewat go:embed, dan menangani status dirty dibahas tuntas di modul migration Roadmap 3. Yang penting sekarang: skema sudah terpetakan ke file-file yang siap dijalankan.

11

Hands-on: Bangun dan Uji Schema

Jalankan schema lokal sebelum masuk ke pgx repository

Kamu belum perlu menulis Go code di modul ini. Fokusnya adalah memastikan database bisa dibuat, constraint bekerja, dan struct Go memetakan kolom dengan benar.

Buat database lokal

Gunakan nama yang jelas seperti skincare_dev supaya tidak tercampur dengan proyek lain. Jalankan createdb skincare_dev.

Jalankan schema

Simpan DDL penuh sebagai schema.sql, lalu muat dengan psql skincare_dev -f schema.sql. Pastikan tidak ada error urutan foreign key.

Seed data minimal

Masukkan satu brand, satu category, satu product, satu variant, dan satu inventory agar ada data untuk diuji di modul query berikutnya.

Uji constraint

Coba insert price_rupiah = -1 atau rating = 6, lalu perhatikan database menolak. Constraint yang menolak data invalid adalah tanda skema bekerja.

Terminal
createdb skincare_dev psql skincare_dev -f schema.sql

Setelah skema termuat, masukkan data minimal. Perhatikan price_rupiah ditulis sebagai rupiah penuh: 159000 berarti Rp159.000, bukan sen.

seed-minimal.sql
INSERT INTO brands (slug, name) VALUES ('glow-lab', 'Glow Lab') RETURNING id; INSERT INTO categories (slug, name) VALUES ('serum', 'Serum') RETURNING id; INSERT INTO products (brand_id, slug, name, description, status) VALUES ( (SELECT id FROM brands WHERE slug = 'glow-lab'), 'vitamin-c-brightening-serum', 'Vitamin C Brightening Serum', 'Serum pencerah untuk pemakaian malam.', 'active' ) RETURNING id; INSERT INTO product_variants (product_id, sku, variant_name, size_label, price_rupiah) VALUES ( (SELECT id FROM products WHERE slug = 'vitamin-c-brightening-serum'), 'GLB-SERUM-VC-30ML', '30ml', '30ml', 159000 ) RETURNING id; INSERT INTO inventories (variant_id, quantity_available, quantity_reserved) VALUES ( (SELECT id FROM product_variants WHERE sku = 'GLB-SERUM-VC-30ML'), 100, 0 );

Sekarang buktikan constraint benar-benar menjaga. Dua perintah berikut harus ditolak database, bukan diterima diam-diam.

uji-constraint.sql
-- Harga negatif harus ditolak oleh CHECK (price_rupiah >= 0) INSERT INTO product_variants (product_id, sku, variant_name, price_rupiah) VALUES (1, 'BAD-SKU', 'rusak', -5000); -- ERROR: new row violates check constraint "product_variants_price_rupiah_check" -- Rating di luar 1..5 harus ditolak oleh CHECK (rating BETWEEN 1 AND 5) INSERT INTO reviews (user_id, product_id, rating, body) VALUES (1, 1, 6, 'rating tidak valid'); -- ERROR: new row violates check constraint "reviews_rating_check"
💡Error constraint adalah fitur, bukan gangguan

Saat insert di atas gagal, itu pertanda bagus. Database menolak data yang akan merusak laporan keuangan dan katalog. Di modul pgx nanti, kamu akan menangkap error ini di Go dan menerjemahkannya menjadi respons 400 atau 409 yang ramah untuk client.

Struct Go memetakan kolom, bukan mendefinisikannya

Inilah pembuktian filosofi schema-first. Struct Go berikut hanya mengikuti kolom yang sudah kita putuskan di SQL. Perhatikan PriceRupiah int64 dengan tag JSON price, dan tipe pointer untuk kolom nullable.

internal/product/model.go
package product import "time" type Product struct { ID int64 `json:"id"` BrandID int64 `json:"brand_id"` Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description"` SkinTypes []string `json:"skin_types"` Concerns []string `json:"concerns"` Status string `json:"status"` // draft, active, archived CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` } type ProductVariant struct { ID int64 `json:"id"` ProductID int64 `json:"product_id"` SKU string `json:"sku"` VariantName string `json:"variant_name"` SizeLabel string `json:"size_label,omitempty"` Shade string `json:"shade,omitempty"` PriceRupiah int64 `json:"price"` CompareAtPriceRupiah *int64 `json:"compare_at_price,omitempty"` WeightGrams *int `json:"weight_grams,omitempty"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` }
🌉Jembatan: nullable kolom jadi pointer di Go

Di TypeScript kamu menandai opsional dengan compareAtPrice?: number. Di Go, kolom yang NULL-able dipetakan ke pointer (*int64, *time.Time). Pointer nil berarti NULL di database. Ini bukan gaya, melainkan keharusan: men-scan NULL ke int64 biasa akan error di pgx.

⚠️Seed bukan migrasi

DDL membuat struktur, seed membuat data contoh. Jangan campur seed demo dengan migration produksi, karena deployment harus bisa diulang tanpa efek samping. Seed hidup di file terpisah dan hanya dijalankan di lingkungan pengembangan.

12

Jebakan Umum JS dan PHP Developer

Hal yang sering terlihat benar di kode, tetapi salah di data jangka panjang

Banyak bug backend bukan berasal dari handler, tetapi dari asumsi data yang tidak dikunci oleh schema.

Menyimpan harga hanya di products

Membuat invoice lama berubah saat admin mengubah harga katalog, dan tidak bisa menangani varian harga berbeda. Harga hidup di product_variants.price_rupiah, dan disnapshot ke order_items.unit_price_rupiah saat checkout.

Cart dianggap final

Cart hanya niat belanja. Saat checkout, harga, stok, promosi, dan alamat harus divalidasi ulang dari sumber terbaru, bukan dipercaya dari cart yang bisa basi.

Semua relasi pakai cascade

ON DELETE CASCADE cocok untuk address dan cart item, tetapi berbahaya untuk order. Order memakai RESTRICT agar histori penjualan tidak ikut terhapus saat user dihapus.

Uang memakai float

float cocok untuk komputasi ilmiah, bukan uang. Pakai bigint rupiah penuh dengan CHECK (>= 0). Galat pembulatan float pada uang muncul belakangan dan sulit dilacak.

Status pakai string bebas

Tanpa CHECK, kolom status bisa terisi typo seperti 'shiped' atau 'PAID' yang merusak query filter. Batasi dengan CHECK (status IN (...)).

Lupa idempotency di checkout

Tanpa idempotency_key UNIQUE, klik ganda atau retry jaringan bisa membuat dua order untuk satu pembayaran. Unique constraint menutup celah ini di level database.

🌉Jembatan: migration di Laravel vs SQL eksplisit

Laravel migration nyaman untuk membangun tabel lewat PHP. Di jalur Go ini, membaca dan menulis DDL mentah penting karena repository pgx akan berinteraksi langsung dengan SQL, bukan dengan ORM berat. Keterampilan membaca DDL adalah investasi yang terpakai di setiap modul berikutnya.

⚠️Hati-hati JSONB

JSONB berguna untuk snapshot alamat, payload payment, dan metadata fleksibel. Jangan menjadikan semua data JSONB, karena filter produk, laporan order, dan relasi stok jauh lebih kuat bila dimodelkan sebagai kolom dan foreign key. JSONB adalah pelengkap model relasional, bukan penggantinya.

⚠️Placeholder PostgreSQL pakai $1, bukan ?

Saat menulis query di modul pgx nanti, ingat PostgreSQL memakai placeholder posisional $1, $2, $3, bukan ? ala MySQL. Memakai ? akan langsung gagal. Ini kebiasaan yang harus diubah bila kamu datang dari Laravel atau driver MySQL.

13

Ringkasan & Poin Penting

Blueprint data ini adalah fondasi untuk semua query pgx dan transaksi checkout di chapter berikutnya. Apa pun yang kamu tulis di Roadmap 3 akan merujuk ke nama tabel dan kolom yang kita kunci di sini.

Yang Wajib Menempel

  • Primary key internal selalu bigint GENERATED ALWAYS AS IDENTITY. UUID hanya untuk ID yang benar-benar diekspos dan tidak boleh ditebak.
  • Uang selalu bigint rupiah penuh dengan sufiks _rupiah, tidak pernah float, numeric, atau money.
  • products adalah halaman produk, product_variants adalah SKU yang dijual dan memegang price_rupiah, dan inventories adalah stok per SKU.
  • cart_items tidak menyimpan harga dan tidak boleh dianggap fakta final. Checkout membaca harga segar lalu membuat orders, order_items, dan payments dalam satu transaksi.
  • Harga, nama produk, SKU, dan alamat di order disimpan sebagai snapshot agar invoice lama tetap benar walau katalog berubah.
  • CHECK, UNIQUE, foreign key, partial unique index, dan generated column membuat database ikut menjaga aturan bisnis, bukan hanya kode Go.
  • idempotency_key UNIQUE di orders mencegah order ganda, dan ON DELETE RESTRICT menjaga order sebagai histori permanen.

Di modul berikutnya, kita mulai memakai pgx untuk koneksi pool dan menjalankan query terhadap skema ini. Fokusnya bukan lagi menggambar tabel, tetapi membaca produk dengan filter dan pagination, menulis cart item, lalu membangun transaksi checkout yang menahan stok dan menyalin snapshot harga. Skema yang kita kunci hari ini adalah peta yang akan dipakai sepanjang sisa roadmap.

Progress disimpan lokal di browser ini.