Chapter Layered Architecture memberi kita peta empat lapisan, tetapi dalam kerja sehari-hari keempatnya biasa diringkas menjadi trio yang lebih ringkas: controller-service-repository (CSR). Controller mewakili presentation, service merangkum application sekaligus domain, dan repository menjadi data — tiga peran yang cukup untuk sebagian besar aplikasi web, dari CRUD sederhana sampai sistem dengan puluhan use case. Pada chapter ini kita bangun trio lengkap untuk kasus pendaftaran pengguna: repository dengan SQLite sungguhan, service dengan aturan bisnisnya, dan controller dengan status code HTTP — lalu membuktikan bahwa service tersebut bisa diuji tanpa HTTP dan tanpa database.
Tiga Peran dalam Praktik #
Trio ini membagi tanggung jawab dengan batas yang tegas:
| Peran | Tanggung jawab utama | Dilarang menyentuh |
|---|---|---|
| Controller | menerjemahkan request menjadi panggilan service dan hasilnya menjadi respons | SQL dan aturan bisnis |
| Service | aturan bisnis, validasi, keputusan, orkestrasi langkah | $_POST, echo, SQL |
| Repository | menyimpan dan mengambil data dari persistence | aturan bisnis dan HTML |
Controller adalah satu-satunya tempat yang tahu dunia luar: dari sana datang input HTTP, dan ke sana keluar status code beserta body respons. Service adalah otak aplikasi — class biasa tanpa framework yang membawa seluruh aturan bisnis sebuah use case. Repository adalah penjaga pintu penyimpanan: seluruh SQL hidup di dalamnya, dan tidak ada lapisan lain yang boleh menulis query sendiri. Arah dependensinya mengikuti aturan layered architecture: controller bergantung pada service, service bergantung pada repository, dan repository tidak bergantung pada siapa pun di atasnya.
Repository, Satu-satunya yang Tahu SQL #
Kasus kita menyimpan pengguna di tabel SQLite. Agar service tidak terikat pada teknologi penyimpanan tertentu, repository didefinisikan lewat kontrak berupa interface — teknik penyambungan antar lapisan yang kita bedah tuntas pada chapter Interfaces dan Abstractions :
<?php
interface RepositoriPengguna
{
/** @return array{id: int, nama: string, email: string}|null */
public function cariByEmail(string $email): ?array;
/** @return int id pengguna yang baru tersimpan */
public function simpan(string $nama, string $email): int;
}
final class RepositoriPenggunaSqlite implements RepositoriPengguna
{
private PDO $koneksi;
public function __construct()
{
$this->koneksi = new PDO("sqlite::memory:");
$this->koneksi->exec(
"CREATE TABLE pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)"
);
}
public function cariByEmail(string $email): ?array
{
$stmt = $this->koneksi->prepare(
"SELECT id, nama, email FROM pengguna WHERE email = ?"
);
$stmt->execute([$email]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
public function simpan(string $nama, string $email): int
{
$stmt = $this->koneksi->prepare(
"INSERT INTO pengguna (nama, email) VALUES (?, ?)"
);
$stmt->execute([$nama, $email]);
return (int) $this->koneksi->lastInsertId();
}
}Interface RepositoriPengguna menyatakan kemampuan yang dibutuhkan use case: mencari pengguna berdasarkan email dan menyimpan pengguna baru. Implementasi RepositoriPenggunaSqlite memenuhi kontrak itu dengan PDO — koneksi dibuat ke basis data sqlite::memory:, tabel dibuat sekali di constructor, lalu kedua method menjalankan query berparameter. Placeholder ? pada prepared statement adalah kebiasaan wajib yang kita pelajari pada pembahasan SQL injection: nilai tidak pernah ditempelkan langsung ke string query. Ganti class ini dengan versi MySQL atau PostgreSQL, dan seluruh sisa aplikasi tidak perlu berubah sedikit pun.
Service, Aturan Bisnis Tanpa Framework #
Service membawa use case “mendaftarkan pengguna” beserta seluruh keputusannya. Perhatikan bahwa tidak ada satu pun simbol framework di dalamnya:
<?php
final class LayananPendaftaran
{
public function __construct(private readonly RepositoriPengguna $repositori)
{
}
/** @return array{sukses: bool, id?: int, errors: list<string>} */
public function daftar(string $nama, string $email): array
{
$errors = [];
if (trim($nama) === "") {
$errors[] = "nama wajib diisi";
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = "email tidak valid";
} elseif ($this->repositori->cariByEmail($email) !== null) {
$errors[] = "email sudah terdaftar";
}
if ($errors !== []) {
return ["sukses" => false, "errors" => $errors];
}
$id = $this->repositori->simpan(trim($nama), $email);
return ["sukses" => true, "id" => $id, "errors" => []];
}
}Constructor service menerima repository lewat constructor promotion dan menyimpannya sebagai dependency — service tidak pernah membuat koneksi sendiri. Method daftar() menjalankan tiga aturan berurutan: nama tidak boleh kosong, email harus berformat valid, dan email belum boleh terdaftar — aturan ketiga menuntut pemeriksaan ke penyimpanan, dan di situlah repository dipanggil. Hasil selalu berbentuk array terstruktur dengan kunci sukses dan errors, bukan exception untuk kasus wajar maupun echo — keputusan menafsirkan hasil diserahkan kepada pemanggil.
Controller, Penerjemah HTTP #
Controller menutup rantai dengan menjadi satu-satunya pihak yang berbicara bahasa HTTP:
<?php
final class PenggunaController
{
public function __construct(private readonly LayananPendaftaran $layanan)
{
}
/** @param array<string, string> $kiriman isi $_POST di dunia nyata */
public function simpan(array $kiriman): array
{
$hasil = $this->layanan->daftar(
$kiriman["nama"] ?? "",
$kiriman["email"] ?? ""
);
if (!$hasil["sukses"]) {
return [
"status" => 422,
"body" => "Pendaftaran gagal: " . implode("; ", $hasil["errors"]),
];
}
return [
"status" => 201,
"body" => "Pengguna baru tersimpan dengan id " . $hasil["id"],
];
}
}Method simpan() menerima array kiriman — di dunia nyata isinya adalah $_POST, tetapi parameter berupa array biasa membuat controller bisa diuji dengan kiriman tiruan. Pekerjaannya murni penerjemahan: hasil sukses dari service menjadi status 201, kegagalan validasi menjadi status 422 beserta pesannya. Tidak ada aturan bisnis yang lahir di sini — controller hanya memutuskan bagaimana hasil aturan itu dikemas sebagai respons.
Menyambungkan Ketiganya #
Seluruh definisi di atas tersimpan sebagai berkas pendaftaran.php pada bundle chapter ini. Alur pemakaiannya menyusun rantai dependensi dari dalam ke luar:
<?php
require __DIR__ . "/pendaftaran.php";
// Controller <- Service <- Repository: rantai disambung dari dalam
$controller = new PenggunaController(new LayananPendaftaran(new RepositoriPenggunaSqlite()));
$kiriman = [
["nama" => "Siti Rahayu", "email" => "[email protected]"],
["nama" => "", "email" => "[email protected]"],
["nama" => "Budi", "email" => "[email protected]"],
];
foreach ($kiriman as $i => $data) {
$respon = $controller->simpan($data);
printf("request %d -> status %d: %s\n", $i + 1, $respon["status"], $respon["body"]);
}Output yang Dihasilkan:
request 1 -> status 201: Pengguna baru tersimpan dengan id 1
request 2 -> status 422: Pendaftaran gagal: nama wajib diisi; email sudah terdaftar
request 3 -> status 422: Pendaftaran gagal: email sudah terdaftarBaris new PenggunaController(new LayananPendaftaran(new RepositoriPenggunaSqlite())) adalah seluruh pekerjaan penyambungan: repository dibuat lebih dulu karena paling dalam, disuntikkan ke service, lalu service disuntikkan ke controller. Tiga request simulasi kemudian menempuh jalur yang sama seperti production: kiriman pertama berhasil dengan status 201 dan id 1; kiriman kedua gagal karena nama kosong; kiriman ketiga gagal karena email Siti sudah tersimpan dari request pertama — aturan keunikan email terbukti bekerja lintas request karena data benar-benar hidup di tabel SQLite.
Service Teruji Tanpa HTTP dan Database #
Manfaat terbesar trio ini muncul saat menguji. Karena service hanya bergantung pada kontrak RepositoriPengguna, kita bisa menyuntikkan repository palsu berbasis array biasa:
<?php
require __DIR__ . "/pendaftaran.php";
// Repository palsu berbasis array: tanpa PDO, tanpa SQL, tanpa berkas
final class RepositoriPenggunaArray implements RepositoriPengguna
{
/** @var array<string, int> */
private array $emailKeId = [];
public function cariByEmail(string $email): ?array
{
if (!isset($this->emailKeId[$email])) {
return null;
}
return ["id" => $this->emailKeId[$email], "nama" => "-", "email" => $email];
}
public function simpan(string $nama, string $email): int
{
$id = count($this->emailKeId) + 1;
$this->emailKeId[$email] = $id;
return $id;
}
}
// Service kini bisa diuji langsung, tanpa HTTP dan tanpa database
$layanan = new LayananPendaftaran(new RepositoriPenggunaArray());
$kasus = [
["Siti Rahayu", "[email protected]"],
["Budi", "[email protected]"],
["", "bukan-email"],
];
foreach ($kasus as $i => [$nama, $email]) {
$hasil = $layanan->daftar($nama, $email);
if ($hasil["sukses"]) {
printf("tes %d: sukses (id %d)\n", $i + 1, $hasil["id"]);
} else {
printf("tes %d: gagal (%s)\n", $i + 1, implode("; ", $hasil["errors"]));
}
}Output yang Dihasilkan:
tes 1: sukses (id 1)
tes 2: gagal (email sudah terdaftar)
tes 3: gagal (nama wajib diisi; email tidak valid)RepositoriPenggunaArray memenuhi kontrak yang sama dengan versi SQLite, tetapi menyimpan data dalam property array — tiga kasus uji berjalan dalam hitungan mikrodetik tanpa server database, tanpa berkas sementara, dan tanpa web server. Inilah yang dimaksud testability sebagai manfaat arsitektur: ketiga aturan pendaftaran diuji langsung di CLI, dan kerusakan pada aturan bisnis ketahuan jauh sebelum menyentuh HTTP. Pengujian berbasis penggantian dependency seperti ini — disebut test double — akan kita perdalam bersama PHPUnit pada pembahasan pengujian.
Di Mana Validasi Berada #
Trio CSR menuntut keputusan sadar tentang letak validasi, dan jawabannya bergantung jenisnya. Validasi format — nama tidak kosong, email sesuai pola — hidup di service karena merupakan bagian dari aturan use case; hasilnya berbentuk daftar error yang dikembalikan, bukan exception. Validasi bentuk HTTP — apakah request membawa field yang diharapkan, apakah methodnya POST — hidup di controller; itulah mengapa simpan() memakai ?? "" saat membaca kiriman. Validasi HTML5 seperti required tetap dipasang di form sebagai lapisan kenyamanan, persis seperti yang kita bahas pada chapter HTML Forms dan PHP
.
Kapan Pola Ini Cocok #
CSR bukan obat untuk semua ukuran. Untuk skrip satu halaman, trio penuh jelas berlebihan — pemisahan fungsi sederhana sudah cukup, seperti yang dikerjakan chapter Separation of Concerns. Seiring use case bertambah dan tim membesar, trio ini membayar dirinya: setiap use case punya class sendiri yang bisa dicari berdasarkan namanya, setiap query hidup di satu tempat, dan pengujian berjalan tanpa infrastruktur. Ketika kerumitan domain meningkat lagi, service mulai dipecah mengikuti empat lapisan penuh. Untuk pembenaman pola repository secara lebih dalam — termasuk spesifikasi query dan caching — chapter Repository Pattern di section design patterns menyambung pembahasan ini.
Ringkasan #
Trio controller-service-repository adalah wujud praktis layered architecture: controller menerjemahkan HTTP, service membawa aturan bisnis yang bebas framework, dan repository menyembunyikan seluruh SQL di balik kontrak interface. Demo pendaftaran menunjukkan ketiganya bekerja di atas SQLite sungguhan sekaligus teruji dengan repository palsu berbasis array — bukti bahwa testability bukan janji teoretis. Satu pertanyaan masih menggantung: pemanggilan new PenggunaController(new LayananPendaftaran(...)) kita tulis manual di alur utama — cara yang lebih tertata untuk menyambungkan dependency seperti ini dibahas pada chapter Dependency Injection
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
pendaftaran.php
Unduh berkas<?php
declare(strict_types=1);
// Trio Controller-Service-Repository untuk kasus pendaftaran pengguna.
// Berkas ini hanya berisi definisi; alur request ada pada contoh di index.md.
interface RepositoriPengguna
{
/** @return array{id: int, nama: string, email: string}|null */
public function cariByEmail(string $email): ?array;
/** @return int id pengguna yang baru tersimpan */
public function simpan(string $nama, string $email): int;
}
final class RepositoriPenggunaSqlite implements RepositoriPengguna
{
private PDO $koneksi;
public function __construct()
{
$this->koneksi = new PDO("sqlite::memory:");
$this->koneksi->exec(
"CREATE TABLE pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)"
);
}
public function cariByEmail(string $email): ?array
{
$stmt = $this->koneksi->prepare(
"SELECT id, nama, email FROM pengguna WHERE email = ?"
);
$stmt->execute([$email]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
public function simpan(string $nama, string $email): int
{
$stmt = $this->koneksi->prepare(
"INSERT INTO pengguna (nama, email) VALUES (?, ?)"
);
$stmt->execute([$nama, $email]);
return (int) $this->koneksi->lastInsertId();
}
}
final class LayananPendaftaran
{
public function __construct(private readonly RepositoriPengguna $repositori)
{
}
/** @return array{sukses: bool, id?: int, errors: list<string>} */
public function daftar(string $nama, string $email): array
{
$errors = [];
if (trim($nama) === "") {
$errors[] = "nama wajib diisi";
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = "email tidak valid";
} elseif ($this->repositori->cariByEmail($email) !== null) {
$errors[] = "email sudah terdaftar";
}
if ($errors !== []) {
return ["sukses" => false, "errors" => $errors];
}
$id = $this->repositori->simpan(trim($nama), $email);
return ["sukses" => true, "id" => $id, "errors" => []];
}
}
final class PenggunaController
{
public function __construct(private readonly LayananPendaftaran $layanan)
{
}
/** @param array<string, string> $kiriman isi $_POST di dunia nyata */
public function simpan(array $kiriman): array
{
$hasil = $this->layanan->daftar(
$kiriman["nama"] ?? "",
$kiriman["email"] ?? ""
);
if (!$hasil["sukses"]) {
return [
"status" => 422,
"body" => "Pendaftaran gagal: " . implode("; ", $hasil["errors"]),
];
}
return [
"status" => 201,
"body" => "Pengguna baru tersimpan dengan id " . $hasil["id"],
];
}
}