Context adalah modul yang menjadi pintu masuk ke satu subdomain aplikasi sekaligus
batas antara lapisan web dan logika domain. Topik ini membahas
prinsip context pada Phoenix, membangun sebuah context Katalog yang tidak
menyentuh HTTP sama sekali, lalu memanggilnya dari controller pada demo
mandiri. Setelah membaca, pemisahan tugas antara Katalog, controller, dan
router dapat ditetapkan dengan aturan yang jelas.
Context sebagai API domain#
Sebuah proyek Phoenix menempatkan context di folder lib/nama_proyek/, sedangkan
lapisan web di lib/nama_proyek_web/. Aturannya tegas: kode di bawah
_web/ hanya boleh memanggil fungsi context, dan context tidak boleh
menyentuh conn, rute, maupun template. Dengan batas ini, logika domain dapat
diuji tanpa menjalankan lapisan web, dapat digunakan dari LiveView, Task, maupun skrip
Mix, dan lapisan web dapat berubah tanpa menyentuh aturan bisnis.
Ketergantungan yang mengalir satu arah inilah yang membuat context berguna.
LiveView, controller, dan pekerjaan latar belakang memanggil fungsi context
yang sama, sehingga aturan domain tidak bergantung pada pemanggilnya.
Batas ini juga mempercepat pengujian: modul Katalog pada bagian berikutnya
dapat diuji dengan ExUnit polos tanpa menjalankan endpoint, dan kegagalan
tidak perlu dikaitkan dengan lapisan HTTP.
Generator resmi mengikuti prinsip yang sama. Perintah mix phx.gen.html Katalog Catatan catatan judul isi, misalnya, membuat Katalog sebagai context
berisi fungsi list_catatan/0, get_catatan!/1, dan create_catatan/1.
Controller yang dihasilkan hanya mengatur conn dan meneruskan pekerjaan ke
Katalog. Nama fungsi context sengaja berbahasa domain, bukan berbahasa
HTTP, sehingga create_catatan/1 tidak pernah mengembalikan status 201,
melainkan {:ok, catatan} atau {:error, changeset}.
Context Katalog tanpa HTTP#
Demo pada topik ini menyusun context penyimpanan dalam memori dengan Agent,
karena fokusnya adalah batas lapisan, bukan database. Tiga fungsi yang
disediakan Katalog memadai untuk kasus nyata kecil, yaitu buat_catatan/1
yang menambah catatan dan mengembalikan hasilnya, daftar_catatan/0 yang
mengembalikan seluruh catatan, dan ambil_catatan/1 yang mengembalikan satu
catatan atau nil.
Perhatikan bentuk nilai kembaliannya. buat_catatan/1 menerima map kunci
string dan mengembalikan struct Katalog.Catatan, bukan JSON dan bukan conn. Nilai
nil untuk catatan yang tidak ada juga berbahasa domain, dan keputusan
menerjemahkannya menjadi status 404 didelegasikan ke lapisan web. Context yang
sama nantinya dapat mengganti Agent dengan Ecto.Repo tanpa mengubah
controller sama sekali.
Pilihan Agent di sini murni demi kemudahan demo, dan keputusannya patut
dijelaskan. Agent menyimpan satu map berisi penghitung id dan catatan yang
sudah dibuat, sehingga nomor id diberikan oleh domain, bukan oleh database.
Fungsi Agent.get_and_update/3 menggabungkan pembacaan dan penulisan state
dalam satu operasi atomik, sehingga dua permintaan yang bersamaan tidak
pernah mendapat id yang sama. Struktur Katalog.Catatan mendisiplinkan bentuk
data, dan seluruh mekanisme ini dapat digantikan tanpa memodifikasi fungsi
aksi satu baris pun.
Memanggil context dari controller mandiri#
Skrip berikut memuat kedua lapisan sekaligus. Bagian atasnya adalah Katalog
yang murni, bagian tengahnya adalah controller dan router yang menerjemahkan
HTTP menjadi panggilan Katalog, dan bagian bawahnya memperlihatkan penggunaan
kedua jalur tersebut.
Mix.install([{:phoenix, "~> 1.8"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)
# context: API domain yang tidak tahu HTTP sama sekali
defmodule Katalog do
defmodule Catatan do
defstruct [:id, :judul, :isi]
end
def start_link do
Agent.start_link(fn -> %{id: 0, data: %{}} end, name: __MODULE__)
end
def buat_catatan(attrs) do
Agent.get_and_update(__MODULE__, fn state ->
id = state.id + 1
catatan = %Catatan{id: id, judul: attrs["judul"], isi: attrs["isi"]}
{catatan, %{state | id: id, data: Map.put(state.data, id, catatan)}}
end)
end
def daftar_catatan do
Agent.get(__MODULE__, fn state -> Map.values(state.data) end)
end
def ambil_catatan(id) do
Agent.get(__MODULE__, fn state -> Map.get(state.data, id) end)
end
end
# lapisan web: hanya menerjemahkan HTTP menjadi panggilan Katalog
defmodule CatatanController do
use Phoenix.Controller, formats: [:json]
def index(conn, _params) do
data = Enum.map(Katalog.daftar_catatan(), fn c -> %{id: c.id, judul: c.judul} end)
json(conn, %{data: data})
end
def show(conn, %{"id" => id}) do
case Katalog.ambil_catatan(String.to_integer(id)) do
%Katalog.Catatan{} = catatan ->
json(conn, %{id: catatan.id, judul: catatan.judul, isi: catatan.isi})
nil ->
conn |> put_status(:not_found) |> json(%{error: "tidak ditemukan"})
end
end
end
defmodule CatatanRouter do
use Phoenix.Router
pipeline :api do
plug :accepts, ["json"]
end
scope "/api" do
pipe_through :api
get "/catatan", CatatanController, :index
get "/catatan/:id", CatatanController, :show
end
end
# pemakaian context secara langsung, tanpa HTTP
Katalog.start_link()
Katalog.buat_catatan(%{"judul" => "Puisi hujan", "isi" => "Sketsa pertama"})
Katalog.buat_catatan(%{"judul" => "Rumput beton", "isi" => "Sketsa kedua"})
IO.inspect(Katalog.ambil_catatan(1), label: "langsung")
# pemakaian context dari controller melalui pipeline
conn =
Phoenix.ConnTest.build_conn(:get, "/api/catatan", nil)
|> CatatanRouter.call(CatatanRouter.init([]))
IO.inspect(conn.status, label: "status daftar")
IO.inspect(Jason.decode!(conn.resp_body), label: "daftar")
conn =
Phoenix.ConnTest.build_conn(:get, "/api/catatan/2", nil)
|> CatatanRouter.call(CatatanRouter.init([]))
IO.inspect(conn.status, label: "status satu")
IO.inspect(Jason.decode!(conn.resp_body), label: "satu")
conn =
Phoenix.ConnTest.build_conn(:get, "/api/catatan/99", nil)
|> CatatanRouter.call(CatatanRouter.init([]))
IO.inspect(conn.status, label: "status hilang")
IO.inspect(Jason.decode!(conn.resp_body), label: "hilang")Jalankan skripnya dengan elixir katalog.exs. Bagian pertama keluaran
menunjukkan penggunaan langsung tanpa HTTP, sedangkan bagian berikutnya
memperlihatkan jalur yang sama melalui pipeline router dan controller.
langsung: %Katalog.Catatan{id: 1, judul: "Puisi hujan", isi: "Sketsa pertama"}
status daftar: 200
daftar: %{
"data" => [
%{"id" => 1, "judul" => "Puisi hujan"},
%{"id" => 2, "judul" => "Rumput beton"}
]
}
status satu: 200
satu: %{"id" => 2, "isi" => "Sketsa kedua", "judul" => "Rumput beton"}
status hilang: 404
hilang: %{"error" => "tidak ditemukan"}Aksi show memperlihatkan pola terjemahan status. Nilai nil dari
Katalog.ambil_catatan/1 dicocokkan dengan case, dan cabang nil menerapkan
put_status(:not_found) sebelum json/2. Terjemahan dari nil menjadi 404
berada di controller, tepat di batas lapisan web. Seluruh keputusan domain,
termasuk cara id diberi nomor, tetap ada di Katalog.
Urutan definisi modul pada skrip juga menyampaikan satu pelajaran. Controller
dan router menyebut modul Katalog tanpa kebutuhan khusus apa pun,
karena Elixir menyelesaikan nama modul saat runtime. Pada proyek nyata,
pemisahan yang sama berbentuk berkas terpisah di folder yang berbeda, dan
Katalog cukup diberi alias di bagian atas controller.
Kesalahan umum#
Kesalahan pertama adalah membiarkan controller mengakses Ecto.Repo atau
Agent secara langsung. Penulisan Repo.all(Catatan) di dalam fungsi aksi
memang berjalan, tetapi batas lapisan jebol, sehingga query yang sama akhirnya
ditulis ulang di beberapa controller. Perbaikannya, pindahkan akses data ke
fungsi context, dan biarkan controller hanya memanggil fungsi itu.
Kesalahan kedua adalah menyusupkan bahasa web ke nama dan nilai context.
Fungsi get_catatan_by_status_and_page/2 yang mengembalikan {:error, 404}
adalah gejala context yang menyentuh HTTP. Perbaikannya, gunakan nama domain seperti
list_catatan_aktif/0 dan nilai error seperti {:error, :not_found}, lalu
biarkan controller yang menerjemahkan :not_found menjadi status 404.
Kesalahan ketiga adalah menganggap conn boleh dilewatkan ke context.
Melewatkan conn ke Katalog.buat_catatan(conn.params) pernah terlihat
wajar, tetapi fungsi tersebut kini membutuhkan conn untuk diuji. Melewatkan
data yang sudah dipilih, misalnya Katalog.buat_catatan(params), menjaga
context tetap dapat dipanggil dari tempat mana saja, termasuk dari skrip dan shell
IEx. Tanda tangan fungsi context yang memuat conn adalah pertanda batas
lapisan sudah jebol.
Ringkasan#
- Context adalah pintu masuk ke satu subdomain dan menjadi batas antara
nama_proyekdannama_proyek_web. - Fungsi context berbahasa domain dan mengembalikan nilai domain, seperti
struct,
{:ok, _}, atau{:error, _}, bukan status HTTP. - Controller menerjemahkan hasil context menjadi respons, termasuk
mengubah
nilmenjadi 404 denganput_status/2. - Context yang tidak menyentuh
conndapat diuji dan dipanggil dari skrip, IEx, LiveView, maupun controller dengan perilaku yang sama.
Referensi#
- Contexts — Phoenix Framework — Panduan resmi context sebagai batas API domain.
- Agent — Elixir — Dokumentasi Agent yang digunakan demo sebagai penyimpan dalam memori.
- Phoenix.Controller — Phoenix Framework
— Dokumentasi
put_status/2dan fungsi respons.