Elixir dan Phoenix Framework: Panduan Lengkap untuk Developer Modern
Elixir adalah bahasa fungsional yang berjalan di BEAM VM, menawarkan concurrency luar biasa, fault tolerance, dan produktivitas tinggi. Dengan Phoenix Framework, Anda bisa membangun web application yang sangat scalable.
π Daftar Isi
1. Pengenalan Elixir
Elixir dibuat oleh JosΓ© Valim pada tahun 2011. Bahasa ini berjalan di atas Erlang Virtual Machine (BEAM), yang telah terbukti selama puluhan tahun dalam membangun sistem yang sangat fault-tolerant dan concurrent.
Keunggulan utama Elixir:
- Fault Tolerance β "Let it crash" philosophy dari Erlang/OTP
- Concurrency β Jutaan proses lightweight yang bisa berjalan bersamaan
- Distribution β Built-in support untuk distributed computing
- Hot Code Reloading β Update kode tanpa restart sistem
- Immutable Data β Tidak ada state yang berubah, menghindari banyak bug
- Productivity β Sintaks yang indah dan ekspresif
BEAM (Bogdan/BjΓΆrn's Erlang Abstract Machine) adalah virtual machine yang menjalankan Erlang dan Elixir. BEAM mampu menangani jutaan proses concurrent dengan overhead yang sangat kecil. WhatsApp menggunakan Erlang/BEAM untuk melayani 2 juta koneksi per server.
2. Instalasi dan Setup
# macOS brew install elixir # Ubuntu/Debian wget https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb sudo dpkg -i erlang-solutions_2.0_all.deb sudo apt-get update sudo apt-get install elixir # Windows # Download installer dari https://elixir-lang.org/install.html # Verifikasi elixir --version # Erlang/OTP 26, Elixir 1.16.x # Install Hex dan rebar mix local.hex --force mix local.rebar --force
Membuat Proyek Pertama
# Buat proyek baru mix new hello_elixir cd hello_elixir # Jalankan di interactive shell iex -S mix # Jalankan test mix test # Kompilasi mix compile
3. Dasar-Dasar Elixir
Basic Types dan Operators
# Numbers
iex> 42
42
iex> 3.14
3.14
iex> 0xFF # hexadecimal
255
iex> 0b1010 # binary
10
# Atoms (seperti symbols)
iex> :hello
:hello
iex> :world
:world
iex> true == :true # boolean adalah atom
true
# Strings
iex> "Hello, Elixir!"
"Hello, Elixir!"
iex> "Interpolasi: #{1 + 1}"
"Interpolasi: 2"
# Lists
iex> [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
iex> [head | tail] = [1, 2, 3, 4, 5]
iex> head
1
iex> tail
[2, 3, 4, 5]
# Tuples
iex> {:ok, "berhasil"}
{:ok, "berhasil"}
iex> {status, message} = {:ok, "data loaded"}
# Maps
iex> %{nama: "Andi", umur: 25}
%{nama: "Andi", umur: 25}
iex> map = %{nama: "Andi", umur: 25}
iex> map.nama
"Andi"
iex> %{map | umur: 26} # update
%{nama: "Andi", umur: 26}
# Keyword lists
iex> [name: "Andi", age: 25]
[name: "Andi", age: 25]
Modules dan Functions
defmodule Kalkulator do
@moduledoc """
Modul kalkulator sederhana untuk demonstrasi Elixir.
"""
# Named functions
def tambah(a, b) do
a + b
end
# Single-line syntax
def kurang(a, b), do: a - b
def kali(a, b), do: a * b
# Guard clauses
def bagi(_a, 0), do: {:error, "Tidak bisa bagi nol"}
def bagi(a, b), do: {:ok, a / b}
# Pattern matching pada parameter
def hitung({:tambah, a, b}), do: a + b
def hitung({:kurang, a, b}), do: a - b
def hitung({:kali, a, b}), do: a * b
def hitung({:bagi, a, b}), do: a / b
# Private functions
defp validate_number(n) when is_number(n), do: true
defp validate_number(_), do: false
# Recursive functions
def factorial(0), do: 1
def factorial(n) when n > 0 do
n * factorial(n - 1)
end
# Higher-order functions
def apply_operation(a, b, operation_fn) do
operation_fn.(a, b)
end
end
# Penggunaan
# iex> Kalkulator.tambah(5, 3)
# 8
# iex> Kalkulator.bagi(10, 0)
# {:error, "Tidak bisa bagi nol"}
# iex> Kalkulator.factorial(5)
# 120
Anonymous Functions
# Anonymous functions
tambah = fn a, b -> a + b end
IO.puts tambah.(3, 5) # 8
# Shorthand syntax (&)
tambah2 = &(&1 + &2)
IO.puts tambah2.(3, 5) # 8
# Pattern matching dalam anonymous function
handle_result = fn
{:ok, data} -> "Berhasil: #{data}"
{:error, reason} -> "Gagal: #{reason}"
end
IO.puts handle_result.({:ok, "data loaded"})
IO.puts handle_result.({:error, "timeout"})
# Closures
buat_multiplier = fn faktor ->
fn angka -> angka * faktor end
end
kali_dua = buat_multiplier.(2)
kali_tiga = buat_multiplier.(3)
IO.puts kali_dua.(10) # 20
IO.puts kali_tiga.(10) # 30
4. Pattern Matching
Pattern matching adalah fitur paling powerful di Elixir. Ini bukan hanya assignment, tapi cara untuk mengekstrak dan memeriksa data.
defmodule PatternDemo do
# Basic pattern matching
def contoh_dasar do
# Match pada tuple
{:ok, hasil} = {:ok, 42}
IO.puts "Hasil: #{hasil}"
# Match pada list
[a, b, c] = [1, 2, 3]
IO.puts "a=#{a}, b=#{b}, c=#{c}"
# Head | Tail
[head | tail] = [1, 2, 3, 4, 5]
IO.puts "Head: #{head}" # 1
IO.inspect tail # [2, 3, 4, 5]
# Map pattern matching
%{nama: nama, umur: umur} = %{nama: "Andi", umur: 25, kota: "Jakarta"}
IO.puts "#{nama}, #{umur}"
end
# Pin operator (^) - match existing variable
def contoh_pin do
x = 10
^x = 10 # OK, karena x sudah 10
# ^x = 20 # MatchError! karena x bukan 20
end
# Case expression
def classify(value) do
case value do
{:ok, data} when is_binary(data) ->
"String OK: #{data}"
{:ok, data} when is_number(data) ->
"Number OK: #{data}"
{:error, reason} ->
"Error: #{reason}"
_ ->
"Unknown format"
end
end
# With expression (railway-oriented programming)
def proses_data(input) do
with {:ok, parsed} <- parse(input),
{:ok, validated} <- validate(parsed),
{:ok, saved} <- save(validated) do
{:ok, saved}
else
{:error, :invalid_format} -> {:error, "Format tidak valid"}
{:error, :validation_failed} -> {:error, "Validasi gagal"}
error -> error
end
end
defp parse(input) when is_binary(input), do: {:ok, String.to_integer(input)}
defp parse(_), do: {:error, :invalid_format}
defp validate(n) when n > 0, do: {:ok, n}
defp validate(_), do: {:error, :validation_failed}
defp save(data), do: {:ok, "saved_#{data}"}
end
Guards dan Advanced Pattern Matching
defmodule GuardsDemo do
# Guard clauses
def classify_number(n) when is_integer(n) and n > 0, do: :positif
def classify_number(n) when is_integer(n) and n < 0, do: :negatif
def classify_number(0), do: :nol
def classify_number(_), do: :bukan_angka
# Multiple pattern matching
def handle_message({:chat, from, message}) do
IO.puts "Chat dari #{from}: #{message}"
end
def handle_message({:join, user, room}) do
IO.puts "#{user} bergabung ke #{room}"
end
def handle_message({:leave, user, room}) do
IO.puts "#{user} meninggalkan #{room}"
end
def handle_message(_) do
IO.puts "Pesan tidak dikenal"
end
# List pattern matching
def sum_list([]), do: 0
def sum_list([head | tail]) do
head + sum_list(tail)
end
# Map pattern matching dengan partial match
def greet(%{nama: nama, role: :admin}) do
"Selamat datang, Admin #{nama}!"
end
def greet(%{nama: nama}) do
"Halo, #{nama}!"
end
end
5. Pipe Operator dan Data Transformation
Pipe operator |> adalah salah satu fitur paling dicintai di Elixir. Ini memungkinkan Anda merangkai operasi secara berurutan dengan sangat readable.
defmodule PipeDemo do
# Tanpa pipe operator (nested, sulit dibaca)
def tanpa_pipe(data) do
String.trim(
String.upcase(
String.replace(data, " ", "_")
)
)
end
# Dengan pipe operator (linear, mudah dibaca)
def dengan_pipe(data) do
data
|> String.replace(" ", "_")
|> String.upcase()
|> String.trim()
end
# Real-world example: data processing pipeline
def proses_pengguna(data_list) do
data_list
|> Enum.filter(fn %{umur: umur} -> umur >= 17 end)
|> Enum.map(fn %{nama: nama, email: email} ->
%{
nama: String.upcase(nama),
email: String.downcase(email),
verified: true
}
end)
|> Enum.sort_by(& &1.nama)
|> Enum.take(10)
end
# Pipeline dengan error handling
def proses_dengan_error(data) do
data
|> String.trim()
|> parse_integer()
|> validate_range(1, 100)
|> format_output()
end
defp parse_integer(str) do
case Integer.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, "Bukan integer"}
end
end
defp validate_range({:ok, n}, min, max) when n >= min and n <= max do
{:ok, n}
end
defp validate_range({:ok, _n}, _min, _max) do
{:error, "Di luar range"}
end
defp validate_range(error, _min, _max), do: error
defp format_output({:ok, n}), do: "Valid: #{n}"
defp format_output({:error, msg}), do: "Error: #{msg}"
end
# Enum module - best friend untuk data transformation
defmodule DataProcessing do
def demo do
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Map - transformasi
kuadrat = Enum.map(data, &(&1 * &1))
# Filter
genap = Enum.filter(data, &(rem(&1, 2) == 0))
# Reduce
total = Enum.reduce(data, 0, &+/2)
# Group by
grouped = Enum.group_by(data, &(if rem(&1, 2) == 0, do: :genap, else: :ganjil))
# Flat map
nested = [[1, 2], [3, 4], [5, 6]]
flat = Enum.flat_map(nested, &(&1))
IO.inspect kuadrat, label: "Kuadrat"
IO.inspect genap, label: "Genap"
IO.inspect total, label: "Total"
IO.inspect grouped, label: "Grouped"
IO.inspect flat, label: "Flat"
end
end
6. GenServer
GenServer (Generic Server) adalah salah satu behaviour paling penting di Elixir/OTP. Ini digunakan untuk membangun stateful processes yang bisa menerima pesan secara asynchronous.
GenServer adalah abstraction untuk membangun server process yang mengelola state, menangani synchronous dan asynchronous requests, dan bisa di-terminate dengan bersih. Ini adalah building block utama untuk sistem concurrent di Elixir.
defmodule CounterServer do
use GenServer
# ============ Client API ============
def start_link(initial_value \\ 0) do
GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
end
def increment do
GenServer.call(__MODULE__, :increment)
end
def decrement do
GenServer.call(__MODULE__, :decrement)
end
def get_value do
GenServer.call(__MODULE__, :get_value)
end
def reset do
GenServer.cast(__MODULE__, :reset)
end
def add_async(value) do
GenServer.cast(__MODULE__, {:add, value})
end
# ============ Server Callbacks ============
@impl true
def init(initial_value) do
IO.puts("CounterServer dimulai dengan nilai: #{initial_value}")
{:ok, %{value: initial_value, history: []}}
end
# Synchronous requests (menunggu response)
@impl true
def handle_call(:increment, _from, %{value: val, history: hist} = state) do
new_val = val + 1
{:reply, new_val, %{state | value: new_val, history: [{:inc, new_val} | hist]}}
end
@impl true
def handle_call(:decrement, _from, %{value: val, history: hist} = state) do
new_val = val - 1
{:reply, new_val, %{state | value: new_val, history: [{:dec, new_val} | hist]}}
end
@impl true
def handle_call(:get_value, _from, %{value: val} = state) do
{:reply, val, state}
end
# Asynchronous requests (tidak menunggu response)
@impl true
def handle_cast(:reset, state) do
{:noreply, %{state | value: 0, history: [{:reset, 0} | state.history]}}
end
@impl true
def handle_cast({:add, amount}, %{value: val, history: hist} = state) do
new_val = val + amount
{:noreply, %{state | value: new_val, history: [{:add, new_val} | hist]}}
end
# Handle info (messages yang bukan call/cast)
@impl true
def handle_info(:tick, state) do
IO.puts("Tick! Current value: #{state.value}")
{:noreply, state}
end
# Termination callback
@impl true
def terminate(reason, state) do
IO.puts("CounterServer terminated: #{inspect(reason)}")
IO.puts("Final value: #{state.value}")
:ok
end
end
# Penggunaan:
# {:ok, _pid} = CounterServer.start_link(0)
# CounterServer.increment() => 1
# CounterServer.increment() => 2
# CounterServer.get_value() => 2
# CounterServer.reset() => :ok
# CounterServer.add_async(10) => :ok
GenServer dengan Timer
defmodule TimerServer do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, %{count: 0, interval: 5000})
end
@impl true
def init(state) do
schedule_tick()
{:ok, state}
end
@impl true
def handle_info(:tick, %{count: count, interval: interval} = state) do
IO.puts("[Timer] Tick #{count + 1} setiap #{interval}ms")
schedule_tick()
{:noreply, %{state | count: count + 1}}
end
defp schedule_tick do
Process.send_after(self(), :tick, 5000)
end
end
7. Phoenix Framework
Phoenix adalah web framework untuk Elixir yang terinspirasi dari Rails. Phoenix menawarkan performa tinggi, real-time features, dan arsitektur yang sangat terstruktur.
Membuat Phoenix Project
# Install Phoenix installer mix archive.install hex phx_new # Buat project baru dengan database mix phx.new belajar_phoenix --database postgres # Masuk ke project cd belajar_phoenix # Setup database mix ecto.create mix ecto.migrate # Jalankan server mix phx.server # Buka http://localhost:4000
Router dan Controller
defmodule BelajarPhoenixWeb.Router do
use BelajarPhoenixWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {BelajarPhoenixWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", BelajarPhoenixWeb do
pipe_through :browser
get "/", PageController, :home
get "/tentang", PageController, :about
end
scope "/api", BelajarPhoenixWeb do
pipe_through :api
resources "/users", UserController, except: [:new, :edit]
resources "/posts", PostController
end
# LiveView routes
live "/dashboard", DashboardLive
live "/chat", ChatLive
live "/counter", CounterLive
end
Controller
defmodule BelajarPhoenixWeb.UserController do
use BelajarPhoenixWeb, :controller
alias BelajarPhoenix.Accounts
def index(conn, _params) do
users = Accounts.list_users()
render(conn, :index, users: users)
end
def show(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
render(conn, :show, user: user)
end
def create(conn, %{"user" => user_params}) do
case Accounts.create_user(user_params) do
{:ok, user} ->
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/users/#{user}")
|> render(:show, user: user)
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render(:errors, changeset: changeset)
end
end
def update(conn, %{"id" => id, "user" => user_params}) do
user = Accounts.get_user!(id)
case Accounts.update_user(user, user_params) do
{:ok, user} ->
render(conn, :show, user: user)
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render(:errors, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
{:ok, _user} = Accounts.delete_user(user)
send_resp(conn, :no_content, "")
end
end
Ecto (Database)
defmodule BelajarPhoenix.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
field :age, :integer
field :role, Ecto.Enum, values: [:admin, :user, :moderator], default: :user
has_many :posts, BelajarPhoenix.Content.Post
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :age, :role])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
|> validate_number(:age, greater_than: 0, less_than: 150)
|> unique_constraint(:email)
end
end
defmodule BelajarPhoenix.Accounts do
import Ecto.Query
alias BelajarPhoenix.Repo
alias BelajarPhoenix.Accounts.User
def list_users do
Repo.all(User)
end
def get_user!(id), do: Repo.get!(User, id)
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
def update_user(%User{} = user, attrs) do
user
|> User.changeset(attrs)
|> Repo.update()
end
def delete_user(%User{} = user) do
Repo.delete(user)
end
def list_users_with_posts do
User
|> preload(:posts)
|> Repo.all()
end
end
8. Phoenix LiveView
Phoenix LiveView memungkinkan Anda membangun real-time, interactive web applications tanpa menulis JavaScript. Semua state management dan rendering terjadi di server.
defmodule BelajarPhoenixWeb.CounterLive do
use BelajarPhoenixWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0, step: 1)}
end
@impl true
def handle_event("increment", _params, socket) do
new_count = socket.assigns.count + socket.assigns.step
{:noreply, assign(socket, count: new_count)}
end
@impl true
def handle_event("decrement", _params, socket) do
new_count = socket.assigns.count - socket.assigns.step
{:noreply, assign(socket, count: new_count)}
end
@impl true
def handle_event("reset", _params, socket) do
{:noreply, assign(socket, count: 0)}
end
@impl true
def handle_event("set_step", %{"step" => step}, socket) do
case Integer.parse(step) do
{n, ""} when n > 0 ->
{:noreply, assign(socket, step: n)}
_ ->
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
π’ Counter: <%= @count %>
Step: <%= @step %>
"""
end
end
LiveView dengan Form
defmodule BelajarPhoenixWeb.TodoLive do
use BelajarPhoenixWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
assign(socket,
todos: [],
new_todo: "",
filter: :all
)}
end
@impl true
def handle_event("add_todo", %{"todo" => %{"text" => text}}, socket) do
if String.trim(text) != "" do
new_todo = %{
id: System.unique_integer([:positive]),
text: String.trim(text),
completed: false,
created_at: DateTime.utc_now()
}
{:noreply, assign(socket, todos: socket.assigns.todos ++ [new_todo], new_todo: "")}
else
{:noreply, socket}
end
end
@impl true
def handle_event("toggle_todo", %{"id" => id}, socket) do
todos =
Enum.map(socket.assigns.todos, fn todo ->
if to_string(todo.id) == id do
%{todo | completed: !todo.completed}
else
todo
end
end)
{:noreply, assign(socket, todos: todos)}
end
@impl true
def handle_event("delete_todo", %{"id" => id}, socket) do
todos = Enum.reject(socket.assigns.todos, &(to_string(&1.id) == id))
{:noreply, assign(socket, todos: todos)}
end
@impl true
def handle_event("set_filter", %{"filter" => filter}, socket) do
filter_atom = String.to_existing_atom(filter)
{:noreply, assign(socket, filter: filter_atom)}
end
defp filtered_todos(todos, :all), do: todos
defp filtered_todos(todos, :active), do: Enum.reject(todos, & &1.completed)
defp filtered_todos(todos, :completed), do: Enum.filter(todos, & &1.completed)
@impl true
def render(assigns) do
~H"""
π Todo List
<%= for todo <- filtered_todos(@todos, @filter) do %>
-
<%= todo.text %>
<% end %>
<%= length(Enum.reject(@todos, & &1.completed)) %> item tersisa
"""
end
end
9. Channels dan Real-time
Phoenix Channels memungkinkan komunikasi real-time antara client dan server menggunakan WebSocket.
defmodule BelajarPhoenixWeb.ChatChannel do
use BelajarPhoenixWeb, :channel
@impl true
def join("chat:" <> room_name, _params, socket) do
send(self(), {:after_join, room_name})
{:ok, %{room: room_name}, assign(socket, room: room_name)}
end
@impl true
def handle_info({:after_join, room_name}, socket) do
broadcast!(socket, "user:joined", %{
user: socket.assigns.user_name,
room: room_name,
timestamp: DateTime.utc_now()
})
{:noreply, socket}
end
@impl true
def handle_in("new_message", %{"body" => body}, socket) do
message = %{
id: System.unique_integer([:positive]),
user: socket.assigns.user_name,
body: body,
room: socket.assigns.room,
timestamp: DateTime.utc_now()
}
broadcast!(socket, "new_message", message)
{:reply, :ok, socket}
end
@impl true
def handle_in("typing", _params, socket) do
broadcast_from!(socket, "user:typing", %{
user: socket.assigns.user_name
})
{:noreply, socket}
end
@impl true
def terminate(_reason, socket) do
broadcast!(socket, "user:left", %{
user: socket.assigns.user_name,
room: socket.assigns.room
})
:ok
end
end
10. PubSub dan Distributed Systems
Phoenix PubSub memungkinkan komunikasi antar process dan antar node dalam cluster.
defmodule BelajarPhoenix.NotificationService do
use GenServer
# Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def subscribe(topic) do
Phoenix.PubSub.subscribe(BelajarPhoenix.PubSub, topic)
end
def broadcast(topic, message) do
Phoenix.PubSub.broadcast(BelajarPhoenix.PubSub, topic, {:notification, message})
end
# Server Callbacks
@impl true
def init(_opts) do
Phoenix.PubSub.subscribe(BelajarPhoenix.PubSub, "notifications")
{:ok, %{notifications: []}}
end
@impl true
def handle_info({:notification, message}, state) do
IO.puts("[Notification] #{message}")
new_notifications = [message | state.notifications]
{:noreply, %{state | notifications: new_notifications}}
end
end
# Usage:
# NotificationService.subscribe("user:123")
# NotificationService.broadcast("user:123", "Anda punya pesan baru!")
11. ETS (Erlang Term Storage) dan Caching
ETS adalah in-memory storage yang sangat cepat, tersedia karena Elixir berjalan di BEAM VM.
defmodule BelajarPhoenix.Cache do
use GenServer
@table :app_cache
# Client API
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def get(key) do
case :ets.lookup(@table, key) do
[{^key, value, expiry}] ->
if System.system_time(:millisecond) < expiry do
{:ok, value}
else
:ets.delete(@table, key)
:miss
end
[] ->
:miss
end
end
def put(key, value, ttl_seconds \\ 3600) do
expiry = System.system_time(:millisecond) + (ttl_seconds * 1000)
:ets.insert(@table, {key, value, expiry})
:ok
end
def delete(key) do
:ets.delete(@table, key)
:ok
end
def flush do
:ets.delete_all_objects(@table)
:ok
end
# Server Callbacks
@impl true
def init(_) do
table = :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
schedule_cleanup()
{:ok, %{table: table}}
end
@impl true
def handle_info(:cleanup, state) do
now = System.system_time(:millisecond)
:ets.select_delete(@table, [{{:_, :_, :"$1"}, [{:<, :"$1", now}], [true]}])
schedule_cleanup()
{:noreply, state}
end
defp schedule_cleanup do
Process.send_after(self(), :cleanup, 60_000) # Cleanup setiap 1 menit
end
end
# Usage:
# Cache.put("user:123", %{name: "Andi"}, 3600)
# Cache.get("user:123") => {:ok, %{name: "Andi"}}
π§ Kuis: Elixir dan Phoenix
Uji pemahaman Anda tentang Elixir dan Phoenix: