Elixir untuk Technical Leader — Bab 3 dari 10
Cara kerja "mesin" di balik LabaBersih — dari departemen (module) sampai jalur produksi (pipe).
Kalau Bab 2 tentang data (bahan baku), bab ini tentang mesin yang mengolah data. Di Elixir, mesin itu terdiri dari:
| # | Konsep | Analogi | Contoh LabaBersih |
|---|---|---|---|
| 1 | Module | Departemen/divisi | Lababersih.Orders, Lababersih.Accounting |
| 2 | Function | Tugas/operasi | ship_order, create_journal_entry |
| 3 | Pipe |> | Jalur produksi / assembly line | query |> filter() |> sort() |> Repo.all() |
| 4 | Enum | Swiss Army knife untuk list | Enum.map, Enum.filter, Enum.reduce |
Setelah bab ini, kamu bisa baca SEMUA function di codebase LabaBersih dan paham strukturnya.
Module = tempat function tinggal. Di LabaBersih, setiap module = 1 domain bisnis:
defmodule Lababersih.Orders do
# Semua function terkait pesanan tinggal di sini:
# - create_order
# - ship_order
# - list_orders
# - delete_order
end
defmodule = 1 departemen:
Lababersih.Orders = Tim Pesanan (terima, kirim, batalkan)Lababersih.Accounting = Tim Akuntansi (jurnal, laporan, tutup buku)Lababersih.Inventory = Tim Gudang (stok, FEFO, mutasi)Lababersih.Sales = Tim Toko (channel, fee mapping)Lababersih.Returns = Tim Retur (RTS, klaim kurir)Cara manggil function dari module lain:
# Panggil function dari module lain pakai NamaModule.nama_function()
Lababersih.Accounting.create_journal_entry(attrs)
Lababersih.Inventory.consume_lots(product_id, qty)
# Setelah di-alias (shortcut), cukup:
Accounting.create_journal_entry(attrs)
Inventory.consume_lots(product_id, qty)
Ada aturan 1:1 — setiap file = 1 module:
| File | Module | Isinya |
|---|---|---|
lib/lababersih/orders/orders.ex | Lababersih.Orders | Context — business logic |
lib/lababersih/orders/order.ex | Lababersih.Orders.Order | Schema — definisi tabel |
lib/lababersih/orders/order_item.ex | Lababersih.Orders.OrderItem | Schema — line item |
orders.ex, itu red flag. Logic stok harusnya di inventory.ex. Setiap module punya tanggung jawab sendiri — ini prinsip yang bikin codebase gak jadi mie goreng.
Ada 2 jenis function di Elixir:
def = Public (Pintu Depan)def ship_order(id, email)
defp = Private (Dapur)defp do_consume_lots(lots, qty)
defmodule Lababersih.Orders do
# PUBLIC — bisa dipanggil dari LiveView, test, module lain
def ship_order(order_id, actor_email) do
order = get_order!(order_id)
do_ship_order(order, actor_email) # panggil function private
end
# PRIVATE — cuma bisa dipanggil dari dalam module ini
defp do_ship_order(order, actor_email) do
# 1. potong stok
# 2. generate jurnal
# 3. update status
# 4. audit trail
end
end
def = menu yang bisa dipesan pelanggan ("Nasi Goreng Spesial")defp = resep di dapur ("potong bawang 2mm, tumis 3 menit pada suhu 180C")ship_order/2.
defp yang dipanggil dari LiveView atau test — itu salah. Private function gak bisa diakses dari luar. Biasanya artinya: function itu harusnya def, atau ada function public yang hilang.
Di LabaBersih, nama function HARUS menjelaskan apa yang dilakukan. Ini bukan sekedar convention — ini kontrak bisnis:
| Prefix | Artinya | Contoh | Catatan |
|---|---|---|---|
create_ | Buat 1 record baru | create_order, create_journal_entry | Insert ke database |
get_! | Ambil 1, CRASH kalau gak ada | get_order!, get_product! | Tanda seru = bahaya kalau nil |
get_ | Ambil 1, return nil kalau gak ada | get_account_by_code | Aman, gak crash |
list_ | Ambil banyak (daftar) | list_orders, list_products | Return list, bisa kosong [] |
update_ | Update 1 record | update_product | Partial update |
delete_ | Hapus 1 record | delete_order | + reverse side effects |
process_ | Operasi multi-step atomic | process_rts, process_reconciliation | Repo.transaction wajib |
ship_ | Kirim (verb spesifik domain) | ship_order | Status + stok + jurnal + audit |
void_ | Batalkan/void | void_journal_entry | Soft delete, tetap di DB |
calculate_ | Hitung (pure, no side effect) | calculate_estimated_fees | Gak sentuh database |
generate_ | Generate data (pure) | generate_sale_journal | Return data, belum save |
parse_ | Parse file/data | parse_settlement_xlsx | XLSX, CSV, API response |
create_order → "bikin order baru"get_order! → "ambil 1 order, crash kalau gak ada"process_reconciliation → "proses rekonsiliasi, multi-step, atomic"calculate_estimated_fees → "hitung fee, cuma hitung, gak save"do_stuff atau handle_data — red flag. Nama harus spesifik.
! dan ? di Nama Function# ! = bahaya (raise/crash kalau gagal)
get_order!(id) # → %Order{} atau CRASH (Ecto.NoResultsError)
# ? = return boolean
String.contains?("hello", "ell") # → true
Enum.empty?([]) # → true
Enum.any?(items, &(&1.qty > 0)) # → true/false
|> — Jalur ProduksiIni fitur Elixir yang paling sering bikin orang jatuh cinta. Pipe = ambil hasil kiri, lempar jadi parameter pertama fungsi kanan.
# Baca dari DALAM ke LUAR — pusing:
Repo.preload(Repo.all(offset(limit(where(Order, [o], o.org_id == ^org_id), 20), page * 20)), :items)
# Baca dari ATAS ke BAWAH — jelas:
Order
|> where([o], o.org_id == ^org_id)
|> limit(20)
|> offset(page * 20)
|> Repo.all()
|> Repo.preload(:items)
# x |> f(a, b) adalah f(x, a, b)
# Hasil kiri MASUK jadi parameter PERTAMA fungsi kanan
" hello "
|> String.trim() # String.trim(" hello ") → "hello"
|> String.upcase() # String.upcase("hello") → "HELLO"
|> String.replace("L", "R") # String.replace("HELLO", "L", "R") → "HERRO"
# Di Orders context — list orders dengan filter + pagination
def list_orders(org_id, opts \\ []) do
Order
|> where([o], o.org_id == ^org_id)
|> maybe_filter_status(opts[:status])
|> maybe_filter_platform(opts[:platform])
|> order_by([o], [desc: o.inserted_at])
|> limit(^per_page)
|> offset(^offset)
|> Repo.all()
|> Repo.preload(:items)
end
Kadang kamu butuh function kecil yang cuma dipakai sekali — gak perlu nama. Ini disebut anonymous function atau lambda.
# Anonymous function — bentuk panjang:
Enum.map(items, fn item -> item.sku end)
# → ["FRB01", "RTN01", "ORG01"]
# Shorthand — pakai & dan &1:
Enum.map(items, &(&1.sku))
# → ["FRB01", "RTN01", "ORG01"]
# &1 = parameter pertama (item)
Cara bacanya:
| Code | Cara baca |
|---|---|
fn item -> item.sku end | "untuk setiap item, ambil field sku" |
&(&1.sku) | Sama persis, cuma lebih pendek |
fn item -> item.qty * item.price end | "untuk setiap item, kalikan qty dengan price" |
&(&1.qty * &1.price) | Sama, shorthand. &1 = parameter pertama |
# Ambil semua SKU dari order items:
skus = Enum.map(order.items, &(&1.sku))
# → ["FRB01", "RTN01"]
# Hitung total harga semua items:
total = Enum.reduce(order.items, 0, fn item, acc ->
acc + item.qty * item.price
end)
# Filter cuma items yang qty > 0:
valid_items = Enum.filter(items, fn item -> item.qty > 0 end)
# Shorthand yang sama:
valid_items = Enum.filter(items, &(&1.qty > 0))
&(&1.xxx) dan bingung — ganti di kepala dengan "untuk setiap elemen, ambil field xxx". Itu aja. &1 = "elemen yang sedang diproses".
Enum adalah module bawaan Elixir yang berisi function untuk mengolah list. Ini yang paling sering muncul di codebase LabaBersih.
| Function | Apa yang dilakukan | Contoh LabaBersih |
|---|---|---|
Enum.map |
Transform setiap elemen | Enum.map(items, &(&1.sku)) → list SKU |
Enum.filter |
Saring yang memenuhi syarat | Enum.filter(orders, &(&1.status == "dikirim")) |
Enum.each |
Jalankan sesuatu per elemen (no return) | Enum.each(items, fn i -> deduct_stock(i) end) |
Enum.reduce |
Akumulasi jadi 1 nilai | Enum.reduce(items, 0, &(&1.total + &2)) → grand total |
Enum.find |
Cari 1 yang pertama cocok | Enum.find(lots, &(&1.qty_remaining > 0)) |
Enum.any? |
Ada yang cocok? (boolean) | Enum.any?(items, &(&1.product_id == nil)) |
Enum.count |
Hitung jumlah | Enum.count(orders, &(&1.status == "rts")) |
Enum.sum |
Jumlahkan (shortcut reduce) | Enum.sum(Enum.map(items, &(&1.qty))) |
items = [
%{sku: "FRB01", qty: 3, price: 45_000},
%{sku: "RTN01", qty: 2, price: 60_000},
%{sku: "SAM01", qty: 0, price: 30_000}
]
# Ambil semua SKU:
Enum.map(items, &(&1.sku))
# → ["FRB01", "RTN01", "SAM01"]
# Filter cuma yang qty > 0:
Enum.filter(items, &(&1.qty > 0))
# → [%{sku: "FRB01", ...}, %{sku: "RTN01", ...}]
# Hitung total value (qty × price per item, lalu sum):
items
|> Enum.filter(&(&1.qty > 0))
|> Enum.map(fn i -> i.qty * i.price end)
|> Enum.sum()
# → 255_000 (3×45000 + 2×60000)
# Cek ada item tanpa product_id (SKU unknown)?
Enum.any?(items, &(&1.product_id == nil))
# → true/false
map, filter, reduce, find, each), kamu bisa baca 80% logic.
Guard = syarat ekstra setelah parameter, pakai keyword when:
# Function hanya jalan kalau qty positif:
def reserve_stock(product_id, qty) when qty > 0 do
# proses reservasi...
end
# Function untuk qty = 0 atau negatif → error:
def reserve_stock(_product_id, qty) when qty <= 0 do
{:error, :invalid_quantity}
end
Contoh lain di LabaBersih:
# Cek tipe data:
def format_money(amount) when is_number(amount) do
# format angka jadi "Rp 200.000"
end
# Cek apakah list:
def bulk_create(items) when is_list(items) do
# proses batch...
end
# Guard umum yang sering muncul:
# when is_binary(x) — x adalah string
# when is_integer(x) — x adalah angka bulat
# when is_list(x) — x adalah list
# when is_nil(x) — x adalah nil
# when x > 0 — x positif
# when x in [:a, :b] — x salah satu dari list
def foo(x) when x > 0 dibaca: "function foo yang cuma jalan kalau x lebih dari 0". Kalau x = -1, function ini di-skip dan Elixir cari versi lain.
\\Backslash-backslash \\ = nilai default kalau parameter gak diisi:
# opts punya default [] (list kosong)
def list_orders(org_id, opts \\ []) do
# opts bisa berisi: status, platform, page, per_page
end
# Bisa dipanggil tanpa opts:
list_orders("org-123")
# → opts = []
# Atau dengan opts:
list_orders("org-123", status: "dikirim", page: 2)
# → opts = [status: "dikirim", page: 2]
Pattern ini SANGAT sering di LabaBersih — hampir semua list_* function pakai opts \\ [] supaya filter opsional.
opts \\ []. Artinya: "filter ini opsional — gak perlu diisi semua, cuma yang dibutuhkan." Ini bikin function fleksibel tanpa bikin 10 versi berbeda.
3 kata kunci ini sering muncul di awal module. Fungsinya:
# Tanpa alias (panjang):
Lababersih.Orders.Order
Lababersih.Accounting.Accounting
# Dengan alias (pendek):
alias Lababersih.Orders.Order
alias Lababersih.Accounting
# Sekarang cukup tulis:
Order # = Lababersih.Orders.Order
Accounting # = Lababersih.Accounting
# Tanpa import:
Ecto.Query.where(Order, ...)
Ecto.Query.limit(query, 20)
# Dengan import:
import Ecto.Query
# Sekarang bisa langsung:
where(Order, ...)
limit(query, 20)
# use = "pakai template/setup dari module ini"
defmodule Lababersih.Orders.Order do
use Ecto.Schema # setup untuk jadi database schema
import Ecto.Changeset # bawa fungsi validasi
# sekarang bisa pakai schema, field, cast, validate_required, dll
end
| Keyword | Fungsi | Analogi |
|---|---|---|
alias | Shortcut nama | Kontak HP: "Budi Gudang" bukan "+628123456789" |
import | Bawa function masuk | Bawa toolbox ke meja kerja |
use | Inject setup/template | Pakai template surat resmi (header + footer otomatis) |
alias di tengah function — aneh (valid tapi unusual). Normalnya di atas, setelah defmodule.
Elixir punya sistem dokumentasi built-in. Tulis sekali → langsung jadi halaman docs:
defmodule Lababersih.Orders do
@moduledoc """
Context untuk manajemen pesanan.
Semua operasi terkait order: create, ship, delete, list.
Dependency: Inventory (stok), Accounting (jurnal), Sales (toko).
"""
@doc """
Kirim pesanan — atomic 5 step.
1. Validasi status = dikemas
2. Potong stok (FEFO lot consumption)
3. Generate jurnal penjualan + HPP
4. Update status → dikirim
5. Audit trail
Returns `{:ok, result}` atau `{:error, reason}`.
"""
def ship_order(order_id, actor_email) do
# ...
end
end
@moduledoc dan @doc itu BUKAN komentar biasa. Ini jadi halaman HTML saat di-generate pakai ExDoc (mix docs). Jadi siapapun bisa buka browser dan baca dokumentasi tanpa buka code.
@moduledoc. Kalau gak ada → red flag.
@nama di level module = konstanta yang di-set saat compile:
defmodule Lababersih.Orders.Order do
# Konstanta — gak bisa berubah saat runtime
@order_statuses ~w(dibuat diproses selesai dibatalkan)
@fulfillment_statuses ~w(unfulfilled picking packed shipped in_transit
delivered rts_detected hold_requested retrying returned)
@payment_statuses ~w(unpaid settled refunded)
# Dipakai untuk validasi:
def changeset(order, attrs) do
order
|> cast(attrs, [:order_status, :fulfillment_status])
|> validate_inclusion(:order_status, @order_statuses)
|> validate_inclusion(:fulfillment_status, @fulfillment_statuses)
end
end
@order_statuses bilang: "status order yang valid CUMA 4 ini. Titik."
Yang sering muncul di LabaBersih:
| Attribute | Contoh | Fungsi |
|---|---|---|
@moduledoc | @moduledoc "Context pesanan" | Dokumentasi module |
@doc | @doc "Kirim pesanan" | Dokumentasi function |
@statuses | @statuses ~w(dibuat dikemas) | Valid values untuk validasi |
@primary_key | @primary_key {:id, :binary_id, ...} | Konfigurasi Ecto schema |
Sekarang coba baca function nyata. Dengan semua yang kamu pelajari di bab ini, kamu harusnya bisa paham 90%:
defmodule Lababersih.Inventory do
@moduledoc "Context untuk produk, stok, FEFO lots, warehouse."
alias Lababersih.Repo
alias Lababersih.Inventory.Product
import Ecto.Query
@doc "List produk per organisasi dengan filter opsional."
def list_products(org_id, opts \\ []) do
Product
|> where([p], p.org_id == ^org_id)
|> maybe_search(opts[:search])
|> order_by([p], asc: p.name)
|> Repo.all()
end
defp maybe_search(query, nil), do: query
defp maybe_search(query, search) do
search_term = "%#{search}%"
where(query, [p], ilike(p.name, ^search_term) or ilike(p.sku, ^search_term))
end
end
Cara bacanya baris per baris:
defmodule Lababersih.Inventory do — module "Departemen Gudang"@moduledoc "..." — deskripsi modulealias + import — shortcut dan tools yang dibutuhkandef list_products(org_id, opts \\ []) — function PUBLIC, terima org_id (wajib) dan opts (opsional)Product |> where... |> maybe_search... |> order_by... |> Repo.all() — assembly line querydefp maybe_search(query, nil) — PRIVATE function, kalau search = nil, kembalikan query apa adanyadefp maybe_search(query, search) — PRIVATE function, kalau search ada isinya, tambahkan filter ILIKEmaybe_search punya 2 versi — ini multi-clause function dari Bab 2! Kalau search = nil, skip. Kalau ada, filter. Gak ada if-else. Elegan.
| Function | Input → Output | Contoh |
|---|---|---|
Enum.map(list, fn) | [a, b, c] → [f(a), f(b), f(c)] | Transform semua elemen |
Enum.filter(list, fn) | [a, b, c] → subset yang true | Saring yang memenuhi syarat |
Enum.reject(list, fn) | [a, b, c] → subset yang false | Kebalikan filter |
Enum.reduce(list, acc, fn) | [a, b, c] → 1 nilai | Akumulasi (total, concat, dll) |
Enum.each(list, fn) | [a, b, c] → :ok | Side effect per elemen |
Enum.find(list, fn) | [a, b, c] → elemen / nil | Cari pertama yang cocok |
Enum.any?(list, fn) | [a, b, c] → true/false | Ada yang cocok? |
Enum.all?(list, fn) | [a, b, c] → true/false | Semua cocok? |
Enum.empty?(list) | [] → true, [x] → false | List kosong? |
Enum.count(list) | [a, b, c] → 3 | Jumlah elemen |
Enum.count(list, fn) | [a, b, c] → N yang true | Jumlah yang memenuhi |
Enum.sum(list) | [1, 2, 3] → 6 | Total (angka) |
Enum.sort(list) | [3, 1, 2] → [1, 2, 3] | Urutkan |
Enum.sort_by(list, fn) | Sort berdasarkan field | Sort by inserted_at |
Enum.uniq(list) | [1, 1, 2] → [1, 2] | Hilangkan duplikat |
Enum.uniq_by(list, fn) | Unique berdasarkan field | Unique by SKU |
Enum.group_by(list, fn) | list → %{key => [items]} | Group orders by status |
Enum.flat_map(list, fn) | map + flatten | Order items dari banyak orders |
Enum.chunk_every(list, n) | [a,b,c,d] → [[a,b],[c,d]] | Batch processing 50 per batch |
Enum.zip(a, b) | [1,2] + [:a,:b] → [{1,:a},{2,:b}] | Gabungkan 2 list |
| Pertanyaan | Jawaban yang benar | Red flag |
|---|---|---|
| "Function ini def atau defp?" | def = public API, defp = internal helper |
Logic bisnis penting pakai defp ← gak bisa dipanggil/ditest dari luar |
"Kenapa ada \\ di parameter?" |
"Default value. opts \\ [] = filter opsional." |
Parameter wajib diberi default ← bisa bikin bug kalau harusnya required |
| "Siapa yang panggil function ini?" | "LiveView panggil context, context panggil context lain atau Repo" | LiveView langsung panggil Repo ← melanggar separation of concerns |
| "Module ini butuh import/alias apa aja?" | "alias schema, import Ecto.Query, alias Repo" | Import semua (import Ecto.Query, warn: false) ← lazy, bisa masking bugs |
| "Ada @moduledoc?" | "Ya, semua module wajib punya @moduledoc" | @moduledoc false atau gak ada sama sekali ← hutang dokumentasi |
| "Nama function jelas?" | "Ya, prefix menjelaskan aksi: create_, list_, process_, ship_" | Nama generik: handle, do_stuff, run ← gak bisa ditebak isinya |
| "Pipe-nya kebaca?" | "Ya, bisa dinarasikan dari atas ke bawah" | Pipe 15+ langkah ← terlalu panjang, pecah jadi helper function |
| "Punya test?" | "Setiap function def harus punya minimal 1 test" |
Function tanpa test ← utang teknis |
Repo.insert, Repo.update, atau kalkulasi bisnis di file *_live.ex — itu salah. Logic HARUS di context module. LiveView cuma panggil function dari context.
def) harusnya punya @doc yang jelaskan: apa yang dilakukan, parameter apa, return apa. Tanpa ini, orang (dan AI) lain harus tebak-tebakan.
Orders panggil Inventory = OK (satu arah).Inventory panggil balik Orders = RED FLAG (circular).Repo.all(Order) |> Enum.filter(&(&1.status == "dikirim")) ← load SEMUA order, filter di Elixir.Order |> where([o], o.status == "dikirim") |> Repo.all() ← filter di database, jauh lebih cepat.defmodule = departemen, 1 file = 1 modulecreate_, get_!, list_, process_, ship_, void_fn x -> ... end dan shorthand &(&1.field)when qty > 0 = syarat tambahanopts \\ [] = opsional