Elixir untuk Technical Leader — Bab 5 dari 10
Web framework yang bikin v1 (React + 31 Edge Functions + 16 RPC) jadi v2 (1 codebase, 0 JavaScript framework). Ini bab paling penting untuk paham arsitektur LabaBersih.
Phoenix = web framework untuk Elixir. Posisinya sama kayak Rails untuk Ruby, atau Next.js untuk JavaScript. Dibuat oleh Chris McCord tahun 2014.
Tapi Phoenix punya 1 senjata yang gak dimiliki Rails atau Next.js: LiveView — teknologi yang bikin UI real-time tanpa perlu React, Vue, atau framework JavaScript apapun.
Kenapa LabaBersih pakai Phoenix:
| Kebutuhan | Phoenix kasih |
|---|---|
| UI tanpa React (1 bahasa end-to-end) | LiveView — server render HTML, kirim via WebSocket |
| Real-time dashboard (stok, order) | WebSocket persistent — server push update tanpa user refresh |
| Business logic terstruktur | Context pattern — setiap domain 1 modul |
| Background jobs (sync API, daily journal) | Oban — persistent queue, berjalan di proses yang sama |
| Hosting murah | Phoenix di Fly.io = ~$10-15/bulan (v1 di Vercel+Supabase = $45/bulan) |
Ini perbedaan paling fundamental. Pahami ini dan kamu paham kenapa v2 lebih sederhana.
orders.ex, accounting.ex, dll). Ini bukan cuma soal teknologi — ini soal kecepatan kamu mendeteksi masalah.
Ketika user buka halaman atau klik tombol, ini yang terjadi di belakang layar:
Router = peta jalan. "Kalau user buka URL ini, tampilkan halaman ini." File-nya: lib/lababersih_web/router.ex
Ini potongan dari router LabaBersih yang sebenarnya:
# lib/lababersih_web/router.ex (LabaBersih aktual)
scope "/", LababersihWeb do
pipe_through [:browser, :auth] # wajib login dulu
live_session :app,
layout: {LababersihWeb.Layouts, :app},
on_mount: [{LababersihWeb.LiveAuth, :require_auth}] do
# Pesanan
live "/pesanan", App.OrderLive # list semua order
live "/pesanan/new", App.OrderFormLive # form buat order baru
live "/pesanan/import", App.OrderImportLive # upload XLSX
live "/pesanan/:id", App.OrderDetailLive # detail 1 order
# Produk & Stok
live "/produk", App.ProdukLive
live "/produk/:id", App.ProdukDetailLive
# Keuangan
live "/jurnal", App.JurnalLive
live "/bagan-akun", App.CoaLive
live "/laporan", App.LaporanLive
live "/buku-besar", App.BukuBesarLive
live "/rekonsiliasi", App.RekonsiliasiLive
# ... dan 20+ route lainnya
end
end
Cara bacanya sederhana:
| Route | URL | Module | Artinya |
|---|---|---|---|
live "/pesanan" | lababersih.id/pesanan | OrderLive | Halaman list pesanan |
live "/pesanan/:id" | lababersih.id/pesanan/PS2603-00001 | OrderDetailLive | Detail order tertentu. :id = dynamic. |
pipe_through [:auth] | - | - | Semua route di bawah ini WAJIB login |
router.ex dan scroll. Setiap baris live "/..." = 1 halaman. Saat ini ada 40+ halaman.
Ini konsep PALING PENTING di seluruh arsitektur LabaBersih. Kalau kamu cuma bisa ingat 1 hal dari bab ini, ingat ini:
Context = modul Elixir yang berisi semua logic bisnis untuk 1 domain. Gak ada logic bisnis di tempat lain.
| Context | File | Tanggung jawab |
|---|---|---|
Lababersih.Orders | orders/orders.ex | Buat order, ship order, delete order |
Lababersih.Accounting | accounting/accounting.ex | Jurnal, trial balance, laporan, close period |
Lababersih.Inventory | inventory/inventory.ex | Produk, stok, FEFO lot, gudang |
Lababersih.Sales | sales/sales.ex | Toko, fee mapping, channel |
Lababersih.Returns | returns/returns.ex | RTS, inbound, klaim kurir |
Lababersih.Purchasing | purchasing/purchasing.ex | PO, receive, bayar, supplier return |
Lababersih.Reconciliation | reconciliation/reconciliation.ex | Settlement matching, jurnal rekonsil |
Lababersih.Accounts | accounts/accounts.ex | Auth, user, organisasi, member |
# Di OrderFormLive:
def handle_event("ship", _, socket) do
order = Repo.get!(Order, id)
if order.status != "dikemas" do # logic di LiveView!
# potong stok manual
# bikin jurnal manual
# update status manual
end
end
Logic tersebar. Gak bisa di-test terpisah. Gak bisa dipanggil dari tempat lain (API, Oban job).
# Di LiveView: HANYA panggil function
def handle_event("ship", _, socket) do
case Orders.ship_order(id, email) do
{:ok, _} -> put_flash(:info, "Berhasil")
{:error, reason} -> put_flash(:error, ...)
end
end
# Di Context: SEMUA logic ada di sini
def ship_order(order_id, actor_email) do
Repo.transaction(fn ->
# 1. Validasi status
# 2. Potong stok (FEFO)
# 3. Bikin jurnal
# 4. Update status
# 5. Audit trail
end)
end
Logic di 1 tempat. Bisa di-test. Bisa dipanggil dari mana aja.
# ALUR BENAR:
# LiveView → Context → Repo → Database
#
# User klik "Kirim" di browser
# → LiveView.handle_event("ship", ...)
# → Orders.ship_order(order_id, email) ← Context
# → Repo.transaction (stok + jurnal + audit)
# → PostgreSQL
# → {:ok, result} atau {:error, reason}
# → put_flash(:info, "Berhasil") atau put_flash(:error, "Gagal")
# → browser update otomatis
#
# LiveView TIDAK TAU cara potong stok.
# LiveView TIDAK TAU cara bikin jurnal.
# LiveView HANYA tau: panggil function, tampilkan hasil.
LiveView = cara Phoenix render UI. Gak pakai React, Vue, atau framework JS apapun. Semua HTML di-render di server, dikirim ke browser via WebSocket.
| Aspek | v1 (React + API) | v2 (LiveView) |
|---|---|---|
| Bahasa UI | TypeScript + JSX | Elixir + HEEx (HTML) |
| State management | React useState + useEffect + SWR | Assigns (@) — 1 mekanisme |
| API layer | 31 Edge Functions + fetch() | Gak ada — LiveView langsung panggil Context |
| Real-time update | Manual polling atau custom WebSocket | Otomatis (WebSocket built-in) |
| Page reload | Setiap navigasi (kecuali SPA) | Gak pernah (persistent connection) |
| JS bundle size | ~500KB+ (React + deps) | ~30KB (Phoenix LiveView JS client) |
Setiap LiveView module punya 4 function utama. Ini yang kamu akan lihat di SETIAP file LiveView:
mount/3 — Load Data AwalDipanggil SEKALI saat user pertama kali buka halaman. Tugasnya: ambil data dari Context, simpan ke assigns.
# Dari OrderLive (LabaBersih aktual)
def mount(_params, _session, socket) do
org_id = socket.assigns.current_org.id # org user yang login
{orders, total} = Orders.list_orders(org_id, # panggil Context
page: 1, per_page: 20)
fulfillment_counts = Orders.count_orders_by_fulfillment(org_id)
{:ok,
assign(socket,
page_title: "Pesanan",
orders: orders, # data yang akan ditampilkan
total: total,
page: 1,
search: "",
fulfillment_counts: fulfillment_counts
)}
end
mount/3 HANYA panggil Orders.list_orders/2 (Context function). Gak ada SQL query langsung. Gak ada logic filter. Semua delegasi ke Context.
render/1 — Gambar HTMLDipanggil SETIAP KALI data berubah. Phoenix otomatis hitung diff dan kirim hanya yang berubah ke browser.
# Dari OrderLive (LabaBersih aktual, disederhanakan)
def render(assigns) do
~H"""
<.page_header title="Pesanan">
<:action>
<.button navigate={~p"/pesanan/import"} variant="secondary">
Upload XLSX
</.button>
<.button navigate={~p"/pesanan/new"}>
+ Buat Pesanan
</.button>
</:action>
</.page_header>
<.stats_row>
<.stat_card label="Siap Kirim" value={@ready_count} />
<.stat_card label="Dikirim" value={@shipped_count} />
<.stat_card label="Terkirim" value={@delivered_count} />
<.stat_card label="RTS" value={@rts_count} />
</.stats_row>
<div :for={o <- @orders}>
<div phx-click="navigate" phx-value-to={~p"/pesanan/#{o.id}"}>
{o.id} — {o.customer_name} — <.money amount={o.total_harga} />
</div>
</div>
"""
end
handle_event/3 — User Klik SesuatuDipanggil saat user interaksi (klik button, submit form, ketik search). Ini jembatan antara UI dan Context.
# User ketik di search bar → event "search" masuk
def handle_event("search", %{"q" => q}, socket) do
org_id = socket.assigns.current_org.id
{orders, total} = Orders.list_orders(org_id, q: q) # panggil Context
{:noreply, assign(socket, orders: orders, total: total, search: q)}
end
# User klik tab filter "Dikirim" → event "filter_fulfillment"
def handle_event("filter_fulfillment", %{"fulfillment" => f}, socket) do
# ... panggil Context dengan filter baru, update assigns
end
# User submit form "Buat Pesanan"
def handle_event("save_order", params, socket) do
case Orders.create_order(attrs, items) do # panggil Context
{:ok, order} ->
{:noreply, socket
|> put_flash(:info, "Pesanan #{order.id} berhasil dibuat")
|> push_navigate(to: ~p"/pesanan/#{order.id}")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Gagal: #{inspect(reason)}")}
end
end
handle_event SELALU ikut pola ini: (1) ambil data dari event, (2) panggil Context function, (3) handle {:ok, _} atau {:error, _}, (4) update assigns atau flash. Kalau kamu lihat pola lain — sesuatu salah.
handle_params/2 — URL BerubahDipanggil saat URL berubah (navigasi antar halaman dalam live_session). Biasanya untuk halaman detail yang butuh :id dari URL.
# User navigasi ke /pesanan/PS2603-00001
def handle_params(%{"id" => id}, _uri, socket) do
order = Orders.get_order!(id) # panggil Context
{:noreply, assign(socket, order: order)}
end
HEEx = HTML + Embedded Elixir. Ini template engine Phoenix. Kamu nulis HTML biasa tapi bisa sisipkan data Elixir.
| Syntax | Artinya | Contoh |
|---|---|---|
{@variable} | Tampilkan nilai dari assigns | {@order.customer_name} → "Budi" |
:if={kondisi} | Tampilkan elemen HANYA kalau kondisi true | <div :if={@order.no_resi}>Resi: {@order.no_resi}</div> |
:for={item <- list} | Loop — ulangi elemen untuk setiap item | <div :for={o <- @orders}>{o.id}</div> |
<.component /> | Panggil shared component | <.button>Simpan</.button> |
~p"/path" | Generate URL path (aman dari typo) | ~p"/pesanan/#{order.id}" |
phx-click="event" | Saat diklik, kirim event ke server | <button phx-click="delete"> |
<!-- Tampilkan order row (dari OrderLive) -->
<div
:for={o <- @orders}
phx-click="navigate"
phx-value-to={~p"/pesanan/#{o.id}"}
class="hover:bg-gray-50 cursor-pointer"
>
<!-- ID pesanan -->
<div class="font-mono text-sm">{o.id}</div>
<!-- Nomor platform + resi (hanya tampil kalau ada) -->
<div class="text-xs text-gray-400">
{o.nomor_pesanan}
<span :if={o.no_resi}> · {o.no_resi}</span>
</div>
<!-- Customer -->
<div>{o.customer_name}</div>
<!-- Platform badge (shared component) -->
<.platform_badge platform={o.platform} />
<!-- Status badge (shared component) -->
<.status_badge status={o.fulfillment_status} />
<!-- Revenue (shared component) -->
<.money amount={o.total_harga} />
</div>
{@...} = data dinamis, :if = tampilkan kondisional, :for = loop, <.xxx /> = komponen reusable.
LabaBersih punya CoreComponents — kumpulan komponen UI yang dipakai di SEMUA halaman. Ini yang bikin tampilan konsisten (beda dari v1 yang badge-nya beda style tiap halaman).
Semua ada di 1 file: lib/lababersih_web/components/core_components.ex
| Component | Pakai di HEEx | Fungsi |
|---|---|---|
| Button | <.button>Simpan</.button> | Tombol dengan variant (primary/secondary/danger) |
| Input | <.input name="email" label="Email" /> | Input form + label + error |
| Status Badge | <.status_badge status="shipped" /> | Badge warna otomatis sesuai status |
| Platform Badge | <.platform_badge platform="tiktok" /> | Badge khusus platform (hitam TikTok, orange Shopee) |
| Money | <.money amount={200000} /> | "Rp 200.000" — format Rupiah otomatis |
| Stats Row | <.stats_row>...</.stats_row> | 4-kolom stat cards |
| Page Header | <.page_header title="Pesanan"> | Header halaman + action buttons |
| Tab Filter | <.tab_filter options={...} /> | Tab "Semua | Dikirim | Selesai" |
| Filter Bar | <.filter_bar>...</.filter_bar> | Search + filter dropdowns |
| Pagination | <.pagination page={@page} /> | Prev/Next halaman |
| Icon | <.icon name="hero-truck" /> | Heroicons (library icon bawaan Phoenix) |
<.status_badge> yang SEMUA halaman pakai. Ubah di 1 tempat = berubah di SEMUA halaman.
# Di core_components.ex
def status_badge(assigns) do
{bg, text} = Map.get(@badge_colors, assigns.status, {"#E2E7E5", "#3D4B46"})
assigns = assign(assigns, bg: bg, text: text)
~H"""
<span style={"display:inline-flex;padding:4px 10px;border-radius:999px;
font-size:13px;font-weight:600;background:#{@bg};color:#{@text};"}>
{status_label(@status)}
</span>
"""
end
# status_label mengubah kode teknis ke bahasa Indonesia:
# "shipped" → "Dikirim"
# "delivered" → "Terkirim"
# "rts_detected" → "RTS Terdeteksi"
Flash = pesan sementara yang muncul di atas halaman setelah aksi (berhasil/gagal). Di v1 ini pakai toast library. Di v2 built-in.
# Berhasil (hijau)
socket |> put_flash(:info, "Pesanan PS2603-00001 berhasil dibuat")
# Gagal (merah)
socket |> put_flash(:error, "Stok Forbest tidak cukup (ada: 3, butuh: 5)")
# Contoh di handle_event:
case Orders.create_order(attrs, items) do
{:ok, order} ->
socket
|> put_flash(:info, "Pesanan #{order.id} berhasil dibuat")
|> push_navigate(to: ~p"/pesanan/#{order.id}")
{:error, reason} ->
put_flash(socket, :error, "Gagal: #{inspect(reason)}")
end
handle_event yang melakukan aksi (save, delete, ship) HARUS punya flash message untuk case berhasil DAN gagal. Kalau cuma handle {:ok, _} tanpa {:error, _} — itu red flag.
Assigns = data yang tersimpan di LiveView process. Setiap kali assigns berubah, Phoenix otomatis re-render bagian HTML yang pakai data itu.
# Di mount:
assign(socket,
orders: orders, # → akses via @orders di template
page: 1, # → @page
search: "", # → @search
fulfillment_counts: counts # → @fulfillment_counts
)
# Di template:
# {@orders} → list of order data
# {@page} → halaman saat ini (1, 2, 3, ...)
# {@search} → teks yang diketik user di search bar
# Update assigns (misalnya user pindah halaman):
assign(socket, page: 2, orders: new_orders)
# → @page berubah jadi 2
# → @orders berubah jadi data baru
# → Phoenix re-render HANYA bagian yang pakai @page dan @orders
Ini yang bikin LiveView interaktif tanpa JavaScript custom. Tambahkan phx-xxx di HTML, dan LiveView otomatis handle.
| Attribute | Kapan dipanggil | Contoh |
|---|---|---|
phx-click |
User klik elemen | <button phx-click="delete" phx-value-id={@order.id}>Hapus</button> |
phx-submit |
User submit form | <form phx-submit="save_order">...</form> |
phx-change |
User ketik di input (real-time) | <input phx-change="search" name="q" /> (validasi sambil ketik) |
phx-disable-with |
Disable button saat processing | <button phx-disable-with="Menyimpan...">Simpan</button> |
phx-value-xxx |
Kirim data tambahan ke event | phx-value-id="PS2603-00001" → masuk di params %{"id" => "PS2603-..."} |
<!-- Form buat pesanan baru -->
<form phx-submit="save_order"> <!-- submit → server -->
<.input name="customer_name" label="Customer" required />
<.input name="platform" type="select"
options={[{"TikTok", "tiktok"}, {"Shopee", "shopee"}]} />
<!-- Tombol tambah item -->
<.button type="button" phx-click="add_item"> <!-- klik → server -->
+ Tambah item
</.button>
<!-- Tombol simpan -->
<.button type="submit"
phx-disable-with="Menyimpan..."> <!-- disable saat proses -->
Simpan
</.button>
</form>
Setiap phx-click="add_item" di template akan memanggil function ini di LiveView:
def handle_event("add_item", _, socket) do
items = socket.assigns.items ++ [%{product_id: "", quantity: 1, price: 0}]
{:noreply, assign(socket, items: items)}
end
# → items bertambah 1 → template re-render → 1 row baru muncul di browser
# Gak ada JavaScript. Gak ada API call. Gak ada useState.
phx-disable-with="Memproses...". Tanpa ini, user bisa double-click dan submit 2x. Ini bug yang v1 punya dan v2 harus hindari.
Ini perbedaan teknis yang menjelaskan kenapa v2 terasa lebih responsif:
1. Browser buka koneksi TCP ~50ms
2. TLS handshake ~100ms
3. Kirim HTTP request ~10ms
4. Server proses dari NOL ~100ms
5. Kirim SELURUH HTML/JSON ~50ms
6. Tutup koneksi
──────────────
Total: ~310ms PER KLIK
1. Koneksi sudah TERBUKA 0ms
2. Kirim event kecil ~5ms
3. Server proses (state ada) ~50ms
4. Kirim HANYA yang berubah ~10ms
5. Koneksi tetap terbuka
──────────────
Total: ~65ms PER KLIK
5x lebih cepat. Dan itu belum hitung keuntungan lain: server ingat state user (assigns), jadi gak perlu query ulang semua data dari nol.
Setiap kali kamu review code LiveView yang dibuat Claude, cek hal-hal ini:
| # | Cek | Yang benar | Red flag |
|---|---|---|---|
| 1 | Logic di mana? | handle_event panggil Context function (1 baris) | handle_event punya if, Repo.query, atau logic bisnis — TOLAK |
| 2 | Error handling? | case dengan {:ok, _} DAN {:error, _} |
Cuma handle {:ok, _} — error dibuang silent |
| 3 | Loading state? | Button punya phx-disable-with="Memproses..." |
Button tanpa disable — bisa double-submit |
| 4 | Flash message? | put_flash(:info, "...") dan put_flash(:error, "...") |
Gak ada feedback setelah aksi — user gak tau berhasil/gagal |
| 5 | N+1 query? | Data di-load di mount dengan preload, BUKAN loop di template | Template panggil function yang query DB per row (N+1) |
| 6 | Shared components? | Pakai <.button>, <.status_badge>, <.money> |
Inline style atau copy-paste HTML badge per halaman |
| 7 | Empty state? | :if={@orders == []} tampilkan pesan "Belum ada pesanan" |
Halaman kosong tanpa pesan — user bingung |
| 8 | Pagination? | List page punya <.pagination> dan limit data per page |
Load ALL data tanpa limit — lambat kalau data banyak |
| 9 | Title halaman? | assign(socket, page_title: "Pesanan") |
Tab browser tidak berubah saat pindah halaman |
| 10 | Bahasa Indonesia? | Semua label, placeholder, flash, error dalam bahasa Indonesia | "Order created successfully" — user Nelly gak paham |
handle_event punya if order.status == "dikemas", Repo.update, atau kalkulasi HPP. Ini SALAH. Semua logic HARUS di Context.
Repo.all(Order) atau Repo.get!(Product, id) langsung di handle_event. Ini bypass Context — gak ada validasi, gak ada org_id scope.
Accounting.has_journal?(order.id). Itu 20 query tambahan. Seharusnya di-preload di mount (1 query batch).
Orders.ship_order(id, email) dipanggil tanpa case — hasilnya dibuang. Kalau gagal, user gak tau apa-apa. WAJIB handle kedua case.
# Semua file web (LiveView, Components, Router) ada di:
lib/lababersih_web/
router.ex # peta URL → halaman
components/
core_components.ex # <.button>, <.status_badge>, dll
layouts/
app.html.heex # layout dengan sidebar
root.html.heex # layout dasar (HTML head)
live/
app/
order_live.ex # list pesanan
order_detail_live.ex # detail 1 pesanan
order_form_live.ex # form buat pesanan
produk_live.ex # list produk
jurnal_live.ex # list jurnal
coa_live.ex # bagan akun (tree)
laporan_live.ex # laporan keuangan
buku_besar_live.ex # general ledger
settings_live.ex # pengaturan
# ... 30+ file lainnya
# Semua business logic (Context) ada di:
lib/lababersih/
orders/orders.ex # Context: pesanan
accounting/accounting.ex # Context: jurnal, laporan
inventory/inventory.ex # Context: produk, stok
# ... dst
xxx_live.ex (akhiran _live). Context file = xxx.ex (tanpa suffix). Kalau kamu lihat file di live/ yang namanya gak ada _live — itu bukan LiveView.
| Pertanyaan kamu | Jawaban yang benar | Red flag |
|---|---|---|
| "Logic ini kenapa di LiveView, bukan di Context?" | "Saya pindahkan ke Context sekarang." | "Karena ini cuma kecil / simple" ← TOLAK. Gak ada "cuma kecil". |
| "Kalau user double-click, apa yang terjadi?" | "Ada phx-disable-with, button disabled saat proses." | "Gak mungkin user double-click" ← SALAH. Ini terjadi setiap hari. |
| "Kalau data kosong, halaman ini tampilkan apa?" | "Ada empty state: icon + pesan 'Belum ada pesanan'" | "Tabel kosong aja" ← User bingung, kirain error. |
| "Ini load berapa data saat mount?" | "20 per page, ada pagination." | "Semua data di-load" ← BAHAYA. Kalau 10.000 order = lambat. |
| "Error message-nya bahasa apa?" | "Bahasa Indonesia: 'Stok tidak cukup (ada: 3, butuh: 5)'" | "Constraint violation" atau bahasa Inggris ← User gak paham. |
| "Kenapa gak pakai React untuk halaman ini?" | "LiveView sudah handle semua use case ini. Real-time update, form handling, navigasi. Tambah React = tambah complexity tanpa benefit." | "LiveView gak bisa handle case complex" ← Salah. Discord 5 juta concurrent user pakai BEAM. |
live "/pesanan" = halaman pesanan.