Elixir untuk Technical Leader — Bab 4 dari 10
Ecto bukan ORM. Ecto itu toolkit — 4 alat terpisah yang masing-masing punya tugas jelas. Ini fondasi semua data di LabaBersih.
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."
Ecto terdiri dari 4 komponen terpisah yang bisa dipakai sendiri-sendiri:
| Komponen | Tugas | Analogi |
|---|---|---|
| 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 |
| Aspek | Ecto (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 |
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
field :customer_name, :string → kolom customer_name di tabel, tipe textfield :total_harga, :decimal → kolom total_harga, tipe Decimal (uang)belongs_to :store → kolom store_id di tabel, FK ke tabel storeshas_many :items → 1 order punya banyak order_items (FK di tabel items)
| Tipe Ecto | Di Database | Contoh Pakai |
|---|---|---|
:string | VARCHAR | nama, SKU, status |
:integer | INTEGER | stok, quantity |
:decimal | NUMERIC | harga, total, HPP |
:boolean | BOOLEAN | is_active, is_header |
:utc_datetime | TIMESTAMP | shipped_at, inserted_at |
:date | DATE | opname_date, period_start |
:map | JSONB | marketplace_data, fee_mapping |
{:array, :string} | TEXT[] | tags |
:binary_id | UUID | ID internal (UUID auto-generate) |
# 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 = 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):
| Step | Function | Tugas | Kalau 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" |
# 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
{: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.
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
# 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!
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 = satu-satunya cara bicara ke database. Semua operasi lewat sini. Gak ada jalan pintas.
# ── 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)
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)!, artinya programmer sadar ada risiko crash dan memang sengaja.
Ini yang paling KRITIS untuk LabaBersih. Repo.transaction menjamin: kalau 1 langkah gagal, SEMUA langkah batal. Gak ada keadaan setengah-setengah.
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
Repo.transaction — itu BUG SERIUS. Tanya: "Kalau step ke-3 gagal, apa yang terjadi dengan step 1 dan 2?"# 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 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.
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
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
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.
# ^ 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
Ini masalah klasik database yang Ecto handle secara eksplisit.
# 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!
# 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()
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.
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.
# 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!
# 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
pg_advisory_xact_lock(12345) → DAPAT kuncixact = transaction-scoped). Kalau transaction gagal/rollback, kunci juga lepas. Gak bisa deadlock.
LabaBersih pakai advisory lock di setiap ID generation:
| Entity | Format | Lock Key |
|---|---|---|
| Order | PS2603-00001 | {org_id, "order"} |
| Purchase Order | PO2603-00001 | {org_id, "po"} |
| Fulfillment Order | FO2603-00001 | {org_id, "fo"} |
| Shipment | SHP-260331-001 | {org_id, "shp"} |
| Handover | HO-260331-001 | {org_id, "ho"} |
| COA Account | 12-201 | {org_id, "coa_gen", platform} |
Ini bukan soal Ecto aja — ini prinsip fundamental semua software keuangan. Jangan pernah pakai float 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!
# 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.
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").# 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
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()
| Pertanyaan | Jawaban yang benar | Red 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 |
Repo.transaction — ubah 3 tabel berurutan tanpa transaction = data bisa setengah-setengah:float untuk uang — harus :decimal. Selalu. Tanpa pengecualian.org_id — bisa bocor data ke organisasi lain. Multi-tenant wajib filter org_id.order.items tanpa preload — N+1 query atau crash NotLoadedRepo.rollback("error") — alasan rollback harus spesifik, bukan string generic. Harus bisa di-handle caller.
belongs_to dan has_many untuk relasicast → validate_required → validate_inclusionRepo.preload(:items) bukan lazy load