Elixir untuk Technical Leader — Bab 4 dari 10

Ecto — The Database Layer

Ecto bukan ORM. Ecto itu toolkit — 4 alat terpisah yang masing-masing punya tugas jelas. Ini fondasi semua data di LabaBersih.


Ecto Bukan ORM — Ini Penting

Kalau kamu pernah dengar istilah ORM (Object-Relational Mapping), lupakan sebentar. ORM tradisional seperti ActiveRecord (Ruby) atau Prisma (JavaScript) punya filosofi: "sembunyikan database, anggap aja semua object."

Ecto kebalikannya: "database itu penting, kamu harus tau apa yang terjadi."

Analogi ORM = autopilot mobil. Kamu gak perlu tau jalan, mobil yang setir. Tapi kalau ada jalan buntu, kamu bingung.

Ecto = GPS canggih. Kamu yang setir, tapi GPS kasih tau rute terbaik. Kamu selalu tau posisi kamu di mana.

Ecto terdiri dari 4 komponen terpisah yang bisa dipakai sendiri-sendiri:

KomponenTugasAnalogi
Schema Definisikan struktur data — field apa aja, tipe apa, relasi ke tabel lain Blueprint rumah
Changeset Validasi data sebelum masuk database — wajib isi apa, format apa Bouncer di pintu masuk
Query Bangun query database pakai Elixir syntax — gak perlu tulis SQL langsung Mesin pencari
Repo Eksekusi query ke database — insert, update, delete, get Pintu masuk gudang

Perbandingan: Ecto vs Prisma vs Raw SQL

AspekEcto (LabaBersih v2)Prisma (v1 seharusnya)Raw SQL (v1 aktual)
Filosofi Eksplisit, kamu kontrol query Otomatis, hide complexity Manual, tulis semua
Validasi Changeset pipeline (cast → validate) Zod / Yup terpisah Gak ada (cek manual)
Migration Built-in, versioned Built-in, versioned Manual ALTER TABLE
Transaction Repo.transaction natural prisma.$transaction PL/pgSQL function
N+1 query Repo.preload eksplisit include otomatis Manual JOIN
Type safety Compile-time TypeScript types Gak ada
Buat Hafish v1 LabaBersih pakai Supabase RPC (raw SQL) — 16 stored procedures yang logic-nya di database, susah di-test, susah di-debug. v2 pakai Ecto — semua logic di Elixir, database bersih (hanya simpan data). Ini upgrade FUNDAMENTAL.

Schema — Blueprint Data

Schema = cara bilang ke Ecto: "tabel orders punya field apa aja, tipenya apa, dan relasinya ke tabel mana."

Ini schema Order yang benar-benar dipakai di LabaBersih v2:

defmodule Lababersih.Orders.Order do
  use Ecto.Schema
  import Ecto.Changeset

  # Primary key = string ("PS2603-00001"), bukan auto-increment angka
  @primary_key {:id, :string, autogenerate: false}

  schema "orders" do          # ← nama tabel di PostgreSQL
    field :nomor_pesanan, :string
    field :customer_name, :string
    field :customer_phone, :string
    field :platform, :string        # "tiktok", "shopee", "mengantar"
    field :order_status, :string, default: "dibuat"
    field :fulfillment_status, :string, default: "pending"
    field :payment_status, :string, default: "unpaid"
    field :total_harga, :decimal   # ← Decimal, BUKAN float!
    field :shipped_at, :utc_datetime
    field :no_resi, :string

    # Relasi: order milik 1 organization
    belongs_to :organization, Lababersih.Accounts.Organization, foreign_key: :org_id
    belongs_to :store, Lababersih.Sales.Store
    belongs_to :customer, Lababersih.Customers.Customer

    # Relasi: 1 order punya banyak items
    has_many :items, Lababersih.Orders.OrderItem

    timestamps(type: :utc_datetime)  # auto: inserted_at, updated_at
  end
end
Cara baca schema:

field :customer_name, :string → kolom customer_name di tabel, tipe text
field :total_harga, :decimal → kolom total_harga, tipe Decimal (uang)
belongs_to :store → kolom store_id di tabel, FK ke tabel stores
has_many :items → 1 order punya banyak order_items (FK di tabel items)

Field Types yang Paling Sering Muncul

Tipe EctoDi DatabaseContoh Pakai
:stringVARCHARnama, SKU, status
:integerINTEGERstok, quantity
:decimalNUMERICharga, total, HPP
:booleanBOOLEANis_active, is_header
:utc_datetimeTIMESTAMPshipped_at, inserted_at
:dateDATEopname_date, period_start
:mapJSONBmarketplace_data, fee_mapping
{:array, :string}TEXT[]tags
:binary_idUUIDID internal (UUID auto-generate)

belongs_to vs has_many

# 1 Order milik 1 Store (FK ada di tabel orders)
# Order →→ Store
belongs_to :store, Lababersih.Sales.Store

# 1 Order punya banyak Items (FK ada di tabel order_items)
# Order ←← Items
has_many :items, Lababersih.Orders.OrderItem

# Cara ingat:
# belongs_to = aku punya kolom xxx_id yang nunjuk ke tabel lain
# has_many   = tabel lain punya kolom yang nunjuk ke aku

Changeset — Bouncer Data

Changeset = pipeline validasi yang data WAJIB lewati sebelum boleh masuk database. Bayangkan bouncer di pintu club — cek KTP, cek umur, cek dresscode. Kalau ada yang gak lolos, TOLAK, gak ada negosiasi.

def changeset(order, attrs) do
  order
  |> cast(attrs, [:nomor_pesanan, :customer_name, :platform,
                   :total_harga, :org_id, :store_id])
  |> validate_required([:customer_name, :platform, :org_id])
  |> validate_inclusion(:platform, ["tiktok", "shopee", "mengantar", "pos"])
  |> validate_inclusion(:order_status, @order_statuses)
  |> foreign_key_constraint(:org_id)
end

Baca dari atas ke bawah — ini pipeline (pakai |> pipe dari Bab 3):

StepFunctionTugasKalau Gagal
1 cast Ambil HANYA field yang diizinkan dari input. Sisanya dibuang. Field gak dikenal = silent drop (aman)
2 validate_required Field ini WAJIB diisi, gak boleh kosong "customer_name wajib diisi"
3 validate_inclusion Nilai harus salah satu dari list yang valid "platform harus salah satu: tiktok, shopee, mengantar, pos"
4 foreign_key_constraint org_id harus nunjuk ke organization yang beneran ada "organization tidak ditemukan"
Kenapa ini bagus Di v1, validasi tersebar — sebagian di React form, sebagian di Edge Function, sebagian di PostgreSQL constraint. Kalau ada yang lolos 1 layer, data busuk masuk database.

Di v2, validasi TERPUSAT di changeset. Satu pintu masuk. Gak bisa di-bypass.

Changeset Return Error, Bukan Throw

# Changeset gak pernah crash/throw. Dia simpan error di struct:
changeset = Order.changeset(%Order{}, %{platform: "lazada"})

changeset.valid?   # false
changeset.errors   # [
                   #   customer_name: {"can't be blank", [validation: :required]},
                   #   platform: {"is invalid", [validation: :inclusion]}
                   # ]

# Kalau kamu coba insert changeset yang invalid:
Repo.insert(changeset)
# => {:error, changeset}  ← database GAK disentuh

# Kalau valid:
Repo.insert(changeset)
# => {:ok, %Order{id: "PS2603-00001", ...}}  ← masuk database
Buat Hafish Ingat dari Bab 2: {:ok, result} dan {:error, reason}. Changeset ikut pattern yang sama. Caller HARUS handle kedua case. Gak ada silent failure — kalau data jelek, kamu PASTI tau.

Migrations — Ubah Struktur Database

Migration = cara aman mengubah struktur database (tambah kolom, buat tabel baru, hapus field). Setiap perubahan di-record sebagai file, jadi bisa di-track di Git dan di-rollback kalau salah.

# File: priv/repo/migrations/20260330_create_orders.exs

defmodule Lababersih.Repo.Migrations.CreateOrders do
  use Ecto.Migration

  def change do
    create table(:orders, primary_key: false) do
      add :id, :string, primary_key: true        # PS2603-00001
      add :org_id, references(:organizations, type: :binary_id), null: false
      add :nomor_pesanan, :string
      add :customer_name, :string, null: false
      add :platform, :string, null: false
      add :order_status, :string, default: "dibuat"
      add :total_harga, :decimal, default: 0

      timestamps(type: :utc_datetime)
    end

    create index(:orders, [:org_id])                 # index untuk filter by org
    create index(:orders, [:org_id, :order_status])  # index untuk filter status
  end
end

Command yang Kamu Perlu Tau

# Jalankan semua migration yang belum dijalankan
$ mix ecto.migrate

# Undo migration terakhir (kalau salah)
$ mix ecto.rollback

# Lihat status migration mana yang sudah/belum dijalankan
$ mix ecto.migrations

# HATI-HATI: hapus database + buat ulang + migrate dari awal
$ mix ecto.reset    # JANGAN di production!
Cara baca migration Nama file selalu diawali timestamp: 20260330155121_create_orders.exs. Angka depan = kapan dibuat. Ecto jalankan migration URUT dari yang terlama. Gak bisa lompat.

LabaBersih v2 sekarang punya 36 migration files. Setiap kali kamu tambah fitur yang butuh perubahan database, file baru ditambahkan di akhir.


Repo — Pintu Masuk Database

Repo = satu-satunya cara bicara ke database. Semua operasi lewat sini. Gak ada jalan pintas.

Operasi Dasar

# ── BACA (Read) ────────────────────────────────

# Ambil 1 record by ID (raise error kalau gak ada)
order = Repo.get!(Order, "PS2603-00001")

# Ambil 1 record by ID (return nil kalau gak ada)
order = Repo.get(Order, "PS2603-00001")

# Ambil semua record
orders = Repo.all(Order)

# Ambil 1 berdasarkan kondisi
order = Repo.get_by(Order, nomor_pesanan: "TK-583220220389")


# ── TULIS (Write) ───────────────────────────────

# Insert record baru
{:ok, order} = %Order{}
               |> Order.changeset(attrs)   # validasi dulu
               |> Repo.insert()            # baru masuk DB

# Update record yang sudah ada
{:ok, order} = order
               |> Order.changeset(%{order_status: "diproses"})
               |> Repo.update()

# Hapus record
{:ok, _} = Repo.delete(order)


# ── HITUNG ──────────────────────────────────────

# Hitung jumlah record
count = Repo.aggregate(Order, :count)
get! vs get — penting! Repo.get!(Order, id) = raise error kalau gak ketemu (pakai kalau ID PASTI ada, kayak dari URL)
Repo.get(Order, id) = return nil kalau gak ketemu (pakai kalau perlu handle "gak ada" gracefully)

Tanda seru (!) di Elixir = "function ini bisa crash." Kalau kamu lihat !, artinya programmer sadar ada risiko crash dan memang sengaja.

Repo.transaction — All or Nothing

Ini yang paling KRITIS untuk LabaBersih. Repo.transaction menjamin: kalau 1 langkah gagal, SEMUA langkah batal. Gak ada keadaan setengah-setengah.

Contoh Nyata: ship_order (5 Langkah Atomic)

Saat Nelly scan barcode untuk kirim pesanan, 5 hal harus terjadi bersamaan:

def ship_order(order_id, actor_email) do
  Repo.transaction(fn ->
    # Ambil order + items dari database
    order = Order
            |> Repo.get!(order_id)
            |> Repo.preload(:items)

    # Validasi: hanya order yang belum shipped yang boleh di-ship
    unless order.fulfillment_status in ~w(pending ready_to_ship picking packed) do
      Repo.rollback({:invalid_status, order.fulfillment_status})
    end

    # Step 1: Update status → shipped
    {:ok, order} = order |> Order.changeset(%{
      order_status: "diproses",
      fulfillment_status: "shipped",
      shipped_at: DateTime.utc_now()
    }) |> Repo.update()

    # Step 2: Potong stok per item (FEFO lot consumption)
    {hpp_total, _} = consume_stock(order)

    # Step 3: Generate jurnal penjualan
    generate_sale_journal(order, hpp_total)

    # Step 4: Generate jurnal HPP
    generate_hpp_journal(order, hpp_total)

    # Step 5: Audit trail
    create_audit_trail(order, actor_email, "shipped")

    order
  end)
end

Apa yang terjadi kalau Step 3 gagal?

Tanpa transaction (BAHAYA)

Step 1: Status jadi "shipped" ✓
Step 2: Stok terpotong ✓
Step 3: Jurnal GAGAL ✗

Hasil: Status sudah shipped, stok sudah terpotong, tapi jurnal gak ada. Laporan keuangan salah. Data korup. Harus manual fix.
Dengan Repo.transaction

Step 1: Status jadi "shipped" ✓
Step 2: Stok terpotong ✓
Step 3: Jurnal GAGAL ✗

Hasil: SEMUA di-rollback. Status balik. Stok balik. Seolah gak pernah terjadi. Data bersih.
Red Flag: Operasi multi-step TANPA transaction Kalau kamu review code dan lihat operasi yang ubah banyak tabel secara berurutan TANPA Repo.transaction — itu BUG SERIUS. Tanya: "Kalau step ke-3 gagal, apa yang terjadi dengan step 1 dan 2?"

Ini aturan LabaBersih: semua operasi multi-step WAJIB pakai Repo.transaction.

Repo.rollback — Batal dengan Alasan

# Di dalam transaction, kalau ada yang salah:
Repo.rollback(:already_shipped)                         # alasan: atom
Repo.rollback({:invalid_status, "selesai"})              # alasan: tuple
Repo.rollback({:insufficient_stock, "Forbest", 3, 5})  # detail: punya 3, butuh 5

# Di luar transaction, caller handle:
case Orders.ship_order(id, email) do
  {:ok, order} ->
    # sukses, lanjut
  {:error, :already_shipped} ->
    # "Order sudah dikirim sebelumnya"
  {:error, {:insufficient_stock, product, has, need}} ->
    # "Stok Forbest tidak cukup (ada: 3, butuh: 5)"
end

Ecto.Query — Bangun Query dengan Pipe

Ecto.Query memungkinkan kamu bangun database query menggunakan Elixir syntax. Kamu gak perlu tulis SQL mentah — tapi Ecto juga BUKAN hide SQL. Setiap Ecto query bisa diterjemahkan 1:1 ke SQL.

Query Sederhana

import Ecto.Query

# Ambil semua order yang status "dikirim"
query = from o in Order,
  where: o.order_status == "diproses",
  where: o.fulfillment_status == "shipped",
  order_by: [desc: o.shipped_at]

orders = Repo.all(query)

# SQL equivalent:
# SELECT * FROM orders
# WHERE order_status = 'diproses' AND fulfillment_status = 'shipped'
# ORDER BY shipped_at DESC

Query Building dengan Pipe (Composable)

Ini kekuatan Ecto — query bisa dibangun step by step dan di-compose pakai pipe:

# Dari code ASLI LabaBersih: Orders.list_orders/2

def list_orders(org_id, opts \\ []) do
  # Mulai dengan query dasar (SELALU filter org_id!)
  query = from o in Order,
    where: o.org_id == ^org_id,
    order_by: [desc: o.inserted_at]

  # Tambah filter status (kalau user pilih tab filter)
  query = case Keyword.get(opts, :status) do
    nil    -> query                                        # gak filter
    status -> from o in query, where: o.order_status == ^status  # tambah WHERE
  end

  # Tambah filter platform (kalau user pilih dropdown)
  query = case Keyword.get(opts, :platform) do
    nil      -> query
    platform -> from o in query, where: o.platform == ^platform
  end

  # Tambah search (kalau user ketik di search bar)
  query = case Keyword.get(opts, :q) do
    nil -> query
    q   -> from o in query,
             where: ilike(o.nomor_pesanan, ^"%#{q}%")
               or ilike(o.customer_name, ^"%#{q}%")
  end

  # Eksekusi query final
  Repo.all(query) |> Repo.preload(:items)
end
Perhatikan pattern-nya Setiap filter = case yang menambah WHERE ke query. Kalau user gak pilih filter, query gak berubah. Kalau pilih, tambah kondisi. Ini composable — kamu bisa tumpuk berapa pun filter tanpa query jadi berantakan.

Tanda ^ (Pin Operator) di Query

# ^ artinya: "pakai NILAI dari variable ini, bukan sebagai kolom database"

org_id = "abc-123"
status = "diproses"

from o in Order,
  where: o.org_id == ^org_id,       # ^org_id = "abc-123" (value)
  where: o.order_status == ^status   # ^status = "diproses" (value)

# Tanpa ^: Ecto kira org_id adalah nama kolom, bukan variable Elixir

Preloading — Hindari N+1 Query

Ini masalah klasik database yang Ecto handle secara eksplisit.

Masalah N+1 Query

N+1 Query (BURUK)
# 1 query ambil 100 orders
orders = Repo.all(Order)

# 100 query ambil items per order!
Enum.each(orders, fn order ->
  items = order.items  # ← QUERY ke DB tiap loop
end)

# Total: 101 queries! Lambat!
Preload (BAGUS)
# 1 query ambil orders
# 1 query ambil SEMUA items sekaligus
orders = Repo.all(Order)
         |> Repo.preload(:items)

# Total: 2 queries! Cepat!
# Ecto auto-match items ke order-nya
# Preload bisa nested:
order = Repo.get!(Order, id)
        |> Repo.preload(items: :product)
# 3 query: orders + order_items + products
# Tanpa preload: 1 + N + N*M queries

# Preload di query langsung:
orders = from(o in Order,
  where: o.org_id == ^org_id,
  preload: [:items, :store]
) |> Repo.all()
Red Flag: Akses relasi tanpa preload Kalau kamu lihat code yang akses order.items tanpa Repo.preload(:items) sebelumnya, itu bug. Ecto akan raise error Ecto.Association.NotLoaded — bukan silent N+1 query kayak ORM lain. Ini BAGUS. Ecto paksa kamu sadar.

Advisory Lock — Anti Race Condition

Ini fitur KRITIS untuk LabaBersih. Order ID kita format PS2603-00001, bukan UUID random. Artinya ada sequential number yang harus unik. Masalah: kalau 2 orang import XLSX bersamaan, bisa generate ID yang sama.

Masalah Race Condition

# User A dan User B import XLSX bersamaan:
#
# Waktu    User A                    User B
# 00:01    Baca max ID → 00042       Baca max ID → 00042
# 00:02    Generate → 00043          Generate → 00043  ← DUPLIKAT!
# 00:03    Insert PS2603-00043       Insert PS2603-00043  ← ERROR!

Solusi: Advisory Lock

# Dari code ASLI LabaBersih: Orders.create_order/2

def create_order(attrs, items_attrs) do
  Repo.transaction(fn ->
    org_id = Map.get(attrs, :org_id)

    # Advisory lock: "kunci" ID generation untuk org ini
    # Request lain ANTRI sampai lock dilepas
    lock_key = :erlang.phash2({org_id, "order"})
    Repo.query!("SELECT pg_advisory_xact_lock($1)", [lock_key])

    # Sekarang AMAN: gak ada request lain yang bisa generate ID bersamaan
    order_id = generate_order_id(org_id)    # PS2603-00043

    # Insert order + items...
  end)
  # Lock otomatis dilepas saat transaction selesai
end
Cara kerja advisory lock

1. User A masuk transaction → kunci pg_advisory_xact_lock(12345) → DAPAT kunci
2. User B masuk transaction → minta kunci yang sama → ANTRI (blocked)
3. User A generate ID 00043, insert, transaction selesai → kunci LEPAS
4. User B akhirnya dapat kunci → baca max ID = 00043 → generate 00044 → AMAN

Kunci otomatis lepas saat transaction selesai (xact = transaction-scoped). Kalau transaction gagal/rollback, kunci juga lepas. Gak bisa deadlock.

LabaBersih pakai advisory lock di setiap ID generation:

EntityFormatLock Key
OrderPS2603-00001{org_id, "order"}
Purchase OrderPO2603-00001{org_id, "po"}
Fulfillment OrderFO2603-00001{org_id, "fo"}
ShipmentSHP-260331-001{org_id, "shp"}
HandoverHO-260331-001{org_id, "ho"}
COA Account12-201{org_id, "coa_gen", platform}

Decimal — Uang WAJIB Decimal, BUKAN Float

Ini bukan soal Ecto aja — ini prinsip fundamental semua software keuangan. Jangan pernah pakai float untuk uang.

Kenapa Float Bahaya untuk Uang

# Float di komputer = APPROXIMASI, bukan exact

# JavaScript (v1 pakai ini):
# 0.1 + 0.2 = 0.30000000000000004  ← BUKAN 0.3!

# Elixir float (JANGAN pakai untuk uang):
0.1 + 0.2   # => 0.30000000000000004

# Elixir Decimal (SELALU pakai untuk uang):
Decimal.add("0.1", "0.2")   # => #Decimal<0.3>  ← exact!

Impact di Skala LabaBersih

# 6.300 order/hari × 30 hari = 189.000 order/bulan
# Kalau setiap order ada selisih Rp 0.0001 karena float rounding...
# 189.000 × Rp 0.0001 = ??? (unpredictable!)
#
# Bisa kecil, bisa juga akumulasi jadi Rp ribuan per bulan.
# Trial balance gak balance. Auditor bingung.
#
# Dengan Decimal: selisih = SELALU Rp 0. Exact.
Red Flag: Float untuk Uang Kalau kamu lihat field :total_harga, :float di schema — itu BUG. Harus :decimal. Kalau lihat price * 0.05 tanpa Decimal — itu BUG. Harus Decimal.mult(price, "0.05").

Ini bukan paranoia — ini kenapa trial balance di v1 kadang gak balance Rp 1-2.

Cara Pakai Decimal di Elixir

# Buat Decimal dari string (AMAN):
price = Decimal.new("200000")

# Operasi matematika:
Decimal.add(price, "50000")      # 250.000
Decimal.sub(price, "16000")      # 184.000
Decimal.mult(price, "0.03")      # 6.000 (fee 3%)
Decimal.div(price, "3")          # 66666.666...

# Perbandingan (JANGAN pakai == untuk Decimal):
Decimal.compare(a, b) == :eq     # equal
Decimal.compare(a, b) == :gt     # a > b
Decimal.gt?(a, b)                 # shortcut: a > b?

# Di LabaBersih, jurnal WAJIB balanced:
total_debit = Decimal.add(piutang, fee_admin)
total_credit = pendapatan
unless Decimal.equal?(total_debit, total_credit) do
  Repo.rollback(:debit_credit_not_balanced)
end

Semuanya Terhubung — Flow Lengkap

Sekarang kamu udah tau 4 komponen Ecto. Ini gimana mereka bekerja sama saat Nelly create order:

# FLOW: User klik "Buat Pesanan" di browser
# ────────────────────────────────────────────

# 1. SCHEMA — define struktur Order
#    (sudah ada, gak dijalankan tiap request)

# 2. CHANGESET — validasi data dari form
changeset = %Order{} |> Order.changeset(%{
  customer_name: "Budi Santoso",
  platform: "tiktok",
  total_harga: "200000",          # string → Decimal otomatis
  org_id: current_org_id
})
# Kalau invalid → return error ke browser, DATABASE GAK DISENTUH

# 3. REPO + TRANSACTION — masukkan ke database (atomic)
Repo.transaction(fn ->
  # Advisory lock → generate unique ID
  Repo.query!("SELECT pg_advisory_xact_lock($1)", [lock_key])
  order_id = "PS2603-00043"

  # Insert order
  {:ok, order} = changeset |> Repo.insert()

  # Insert items (1 QUERY untuk semua items, bukan 1 per item)
  Repo.insert_all(OrderItem, items_data)

  order
end)

# 4. QUERY — Nanti saat tampilkan di list:
orders = from(o in Order,
  where: o.org_id == ^org_id,
  preload: [:items, :store]
) |> Repo.all()

Checklist Review — Pertanyaan Kamu Bisa Tanya

PertanyaanJawaban yang benarRed flag
"Operasi ini atomic?" "Ya, semua di dalam Repo.transaction, gagal 1 = rollback semua." "Setiap step independent." ← BAHAYA, data bisa korup
"Total harga tipenya apa?" ":decimal — uang selalu Decimal, never float." "Float cukup kok." ← SALAH, trial balance bisa gak balance
"ID generation aman dari duplikat?" "Pakai advisory lock di PostgreSQL, concurrent request antri." "Pakai UUID." ← User gak bisa baca UUID. "Pakai timestamp." ← Collision.
"Ini ada N+1 query gak?" "Sudah pakai Repo.preload, total 2-3 query." "Gak masalah, data-nya dikit." ← SALAH, nanti gak dikit
"Validasi di mana?" "Di changeset — satu pintu masuk, gak bisa di-bypass." "Di LiveView / di browser." ← SALAH, validasi harus di backend
"Query ini ada org_id-nya?" "Ya, semua query filter org_id — multi-tenant enforced." "Gak perlu, kan cuma 1 org sekarang." ← BAHAYA
"Migration-nya reversible?" "Ya, pakai def change yang otomatis reversible, bisa rollback." "Pakai def up tanpa def down." ← Gak bisa rollback

Red Flags — Bahaya yang Harus Kamu Kenali

6 Red Flags di Code Database

1. Multi-step tanpa Repo.transaction — ubah 3 tabel berurutan tanpa transaction = data bisa setengah-setengah

2. :float untuk uang — harus :decimal. Selalu. Tanpa pengecualian.

3. Query tanpa org_id — bisa bocor data ke organisasi lain. Multi-tenant wajib filter org_id.

4. Akses order.items tanpa preload — N+1 query atau crash NotLoaded

5. ID generation tanpa lock — concurrent import bisa bikin ID duplikat

6. Repo.rollback("error") — alasan rollback harus spesifik, bukan string generic. Harus bisa di-handle caller.

Ringkasan Bab 4

7 hal yang kamu sekarang tau:

1. Ecto bukan ORM — toolkit 4 bagian: Schema, Changeset, Query, Repo
2. Schema = blueprint data. belongs_to dan has_many untuk relasi
3. Changeset = bouncer validasi. cast → validate_required → validate_inclusion
4. Repo.transaction = all or nothing. ship_order 5 langkah = atomic. WAJIB untuk multi-step
5. Query composable = bangun query pakai pipe, tambah filter step by step
6. Preload = hindari N+1 query. Repo.preload(:items) bukan lazy load
7. Decimal untuk uang = WAJIB. Float = bug. Advisory lock = anti duplikat ID