Chapter Separation of Concerns memisahkan input, logika, dan output sebagai fungsi lalu berkas. Pertanyaan berikutnya adalah bagaimana menyusun pemisahan itu menjadi kerangka aplikasi utuh — dan di sinilah layered architecture masuk: aplikasi dibagi menjadi sejumlah lapisan yang tersusun dari atas ke bawah, masing-masing dengan jenis kode yang boleh hidup di dalamnya. Pada chapter ini kita pelajari empat lapisan klasik, satu aturan arah dependensi yang menjaga semuanya tetap sehat, lalu membangun demo toko buku kecil yang benar-benar berjalan di atas keempat lapisan tersebut.

Empat Lapisan dan Tugasnya #

Literatur arsitektur menyebut banyak varian, tetapi bentuk yang paling umum untuk aplikasi web PHP memakai empat lapisan berikut:

LapisanIsi kode PHPContoh di demo ini
Presentationcontroller, template, echo, header()renderStruk() dan alur utama
Applicationuse case dan orkestrasi langkahLayananCheckout::buatPesanan()
Domainentitas dan aturan bisnis intiKeranjang beserta aturan potongan
Datarepository, SQL, koneksi penyimpananRepositoriBuku

Presentation adalah lapisan terluar yang berbicara dengan dunia: membaca request, mencetak HTML atau JSON, mengatur status code. Application berisi use case — satu class untuk satu cerita pengguna seperti “melakukan checkout” — dan bertugas mengorkestrasi langkah-langkahnya. Domain menyimpan jantung aplikasi: entitas seperti Keranjang beserta aturan bisnis yang melekat padanya. Data menyentuh penyimpanan: membaca dan menulis ke database, berkas, maupun API eksternal.

Pemetaan ini menjawab pertanyaan praktis “kode ini seharusnya ditaruh di mana?” dengan cepat. Kode yang mengandung echo, $_POST, atau header() jelas milik presentation. Pernyataan SQL hanya boleh muncul di data. Keputusan yang berbunyi seperti kalimat bisnis — “belanja di atas seratus ribu dapat potongan” — adalah aturan domain. Langkah-langkah yang menyusun sebuah proses — “ambil data buku, hitung total, tolak bila di bawah minimum” — adalah application.

Aturan Arah Dependensi #

Satu aturan mengikat seluruh lapisan: dependensi hanya boleh mengalir ke bawah. Presentation boleh memanggil application; application boleh memanggil domain dan data; domain tidak boleh memanggil siapa pun. Elemen di lapisan bawah tidak boleh use, require, maupun menyebut nama apapun dari lapisan di atasnya.

Mengapa arahnya harus satu arah? Karena setiap panah dependensi adalah titik rentan terhadap perubahan. Ketika domain mengerti HTML, pergantian tampilan ikut mengubah aturan bisnis; ketika domain mengerti SQL, migrasi database ikut mengubah kalkulasi potongan. Arah yang benar membalik situasi: presentation boleh rombak total karena tidak ada lapisan di atasnya yang bergantung pada presentation; database boleh diganti dari MySQL ke PostgreSQL karena hanya lapisan data yang menyentuhnya; dan aturan bisnis — yang paling berharga dan paling jarang berubah — hidup paling dalam, bersih dari urusan teknis.

Pelanggaran aturan ini punya nama yang akrab: kode SQL di dalam controller, echo di dalam method service, atau pembacaan $_POST di dalam class domain. Masing-masing adalah lapisan bawah yang menyibak diri ke atas, dan biayanya dibayar setiap kali Anda ingin mengganti satu bagian tanpa menyentuh bagian lain.

Perjalanan Sebuah Request #

Mari kita petakan bagaimana satu request HTTP melintasi lapisan-lapisan tersebut:

Panah pada diagram berarti “memanggil dan bergantung pada”. Request datang dari browser ke presentation, yang menerjemahkannya menjadi pemanggilan use case di application. Use case memakai entitas domain untuk menghitung, dan memakai lapisan data untuk membaca serta menyimpan. Perhatikan bahwa Domain tidak memiliki satu pun panah keluar — lapisan tersebut hanya dipanggil, tidak pernah memanggil. Hasil perjalanan mengalir berlawanan arah panah: domain mengembalikan nilai ke application, application mengembalikan hasil ke presentation, dan presentation mencetaknya sebagai respons.

Demo Toko Buku Berlapis #

Saatnya melihat keempat lapisan hidup dalam kode nyata. Cerita bisnisnya adalah toko buku dengan dua aturan: potongan 10% untuk belanja di atas Rp100.000, dan minimum pesanan Rp50.000. Kita mulai dari lapisan paling dalam:

php
<?php
final class ItemKeranjang
{
    public function __construct(
        public readonly string $judul,
        public readonly int $harga,
        public readonly int $jumlah,
    ) {
    }

    public function subtotal(): int
    {
        return $this->harga * $this->jumlah;
    }
}

final class Keranjang
{
    /** @var list<ItemKeranjang> */
    private array $items = [];

    public function tambah(ItemKeranjang $item): void
    {
        $this->items[] = $item;
    }

    public function total(): int
    {
        return array_sum(array_map(fn (ItemKeranjang $item) => $item->subtotal(), $this->items));
    }

    public function potongan(): int
    {
        // Aturan bisnis: belanja >= Rp100.000 mendapat potongan 10 persen
        return $this->total() >= 100_000 ? intdiv($this->total(), 10) : 0;
    }
}

$keranjang = new Keranjang();
$keranjang->tambah(new ItemKeranjang("Belajar PHP Modern", 75_000, 1));
$keranjang->tambah(new ItemKeranjang("Praktik SQLite", 45_000, 2));

printf("Total item  : Rp%d\n", $keranjang->total());
printf("Potongan    : Rp%d\n", $keranjang->potongan());
printf("Total bayar : Rp%d\n", $keranjang->total() - $keranjang->potongan());

Output yang Dihasilkan:

text
Total item  : Rp165000
Potongan    : Rp16500
Total bayar : Rp148500

Bedah kodenya:

  1. ItemKeranjang — entitas kecil yang membawa satu baris belanja; method subtotal() menjembatani data dan aturan.
  2. Keranjang — agregat yang menampung baris-baris; total() menjumlahkan subtotal seluruh item dengan array_map().
  3. potongan() — aturan bisnis murni: satu baris match-gaya ekspresi yang memutuskan besaran potongan dari total belanja. Tidak ada SQL, tidak ada HTML, tidak ada $_POST.
  4. Empat baris terakhir — penggunaan langsung di CLI sebagai bukti bahwa domain bisa hidup sendiri tanpa lapisan lain.

Kode domain di atas tersimpan sebagai berkas tokobuku-domain.php pada bundle chapter ini. Sekarang kita tambahkan tiga lapisan sisanya — versi lengkapnya tersimpan sebagai tokobuku.php:

php
<?php
// Lapisan Data, Application, dan Presentation untuk toko buku.
// Lapisan Domain berada di tokobuku-domain.php.

require __DIR__ . "/tokobuku-domain.php";

final class RepositoriBuku
{
    /** @param array<string, array{judul: string, harga: int}> $basisData */
    public function __construct(private readonly array $basisData)
    {
    }

    public function cari(string $id): ?array
    {
        return $this->basisData[$id] ?? null;
    }
}

final class LayananCheckout
{
    private const MINIMUM_PESANAN = 50_000;

    public function __construct(private readonly RepositoriBuku $repositori)
    {
    }

    /** @param array<string, int> $pesanan id buku => jumlah */
    public function buatPesanan(array $pesanan): array
    {
        $keranjang = new Keranjang();
        foreach ($pesanan as $id => $jumlah) {
            $buku = $this->repositori->cari($id);
            if ($buku === null) {
                throw new InvalidArgumentException("Buku dengan id $id tidak ditemukan");
            }
            $keranjang->tambah(new ItemKeranjang($buku["judul"], $buku["harga"], $jumlah));
        }

        $total = $keranjang->total() - $keranjang->potongan();
        if ($total < self::MINIMUM_PESANAN) {
            throw new DomainException("Pesanan di bawah minimum Rp50.000");
        }

        return [
            "judul" => count($pesanan),
            "potongan" => $keranjang->potongan(),
            "total" => $total,
        ];
    }
}

function renderStruk(array $pesanan): string
{
    return sprintf(
        "Terima kasih! %d judul, potongan Rp%s, total bayar Rp%s\n",
        $pesanan["judul"],
        number_format($pesanan["potongan"], 0, ",", "."),
        number_format($pesanan["total"], 0, ",", "."),
    );
}

Tiga bagian baru tersebut masing-masing memegang satu lapisan. RepositoriBuku adalah lapisan data versi sederhana — di aplikasi nyata isinya pernyataan SQL dengan PDO, tetapi bentuknya tetap sama: satu class yang menyembunyikan detail penyimpanan di balik method bermakna seperti cari(). LayananCheckout adalah lapisan application sekaligus use case “melakukan checkout”: method buatPesanan() menerima daftar id => jumlah, memvalidasi keberadaan buku lewat repository, menghitung dengan bantuan domain, lalu menegakkan kebijakan minimum pesanan. Fungsi renderStruk() adalah presentation: satu-satunya tempat yang tahu bagaimana hasil pesanan ditampilkan, dan fungsi ini mengembalikan string alih-alih langsung mencetak.

Alur utamanya sendiri menjadi ringkas karena tinggal menyambungkan lapisan-lapisan itu:

php
<?php
require __DIR__ . "/tokobuku.php";

// Presentation menyusun object, memanggil use case, lalu mencetak hasil
$repositori = new RepositoriBuku([
    "b1" => ["judul" => "Belajar PHP Modern", "harga" => 75_000],
    "b2" => ["judul" => "Praktik SQLite", "harga" => 45_000],
]);

$layanan = new LayananCheckout($repositori);
echo renderStruk($layanan->buatPesanan(["b1" => 1, "b2" => 2]));

Output yang Dihasilkan:

text
Terima kasih! 2 judul, potongan Rp16.500, total bayar Rp148.500

Baris new RepositoriBuku([...]) menyiapkan sumber data, baris new LayananCheckout($repositori) menyuntikkan sumber itu ke use case, dan baris terakhir menjalankan seluruh perjalanan: presentation memanggil application, application memanggil data dan domain, hasilnya mengalir kembali untuk dicetak. Di aplikasi web nyata, tiga baris alur ini adalah isi controller Anda.

Ketika Aturan Dilanggar #

Lapisan bukan sekadar penataan folder — aturan arah dependensi benar-benar menjaga perilaku. Lihat apa yang terjadi ketika use case menolak pesanan yang melanggar kebijakan minimum:

php
<?php
require __DIR__ . "/tokobuku.php";

$repositori = new RepositoriBuku([
    "b1" => ["judul" => "Belajar PHP Modern", "harga" => 75_000],
    "b2" => ["judul" => "Praktik SQLite", "harga" => 45_000],
]);
$layanan = new LayananCheckout($repositori);

// Pesanan satu buku murah: total di bawah minimum pesanan
try {
    echo renderStruk($layanan->buatPesanan(["b2" => 1]));
} catch (DomainException $e) {
    echo "Pesanan ditolak: " . $e->getMessage() . "\n";
}

Output yang Dihasilkan:

text
Pesanan ditolak: Pesanan di bawah minimum Rp50.000

Penolakan itu lahir di lapisan application — konstanta MINIMUM_PESANAN dan pemeriksaannya ada di LayananCheckout — lalu mengalir ke atas sebagai exception, dan presentation yang memutuskan cara menyampaikannya: di CLI menjadi teks, di web menjadi halaman error dengan status 422. Bandingkan dengan arah yang salah: andai LayananCheckout langsung memanggil echo atau http_response_code(), use case yang sama tidak bisa dipakai ulang untuk endpoint JSON, pekerjaan cron, maupun pengujian otomatis.

Lapisan Tipis atau Gemuk #

Bagaimana membagi beban antar lapisan? Panduan praktisnya: buat presentation dan data setipis mungkin, application sedang, dan domain senyata mungkin. Presentation yang tebal — controller berisi ratusan baris logika — adalah gejala klasik arsitektur rusak, karena logika di sana tidak bisa diuji tanpa HTTP. Data yang gemuk biasanya berarti SQL mulai mengambil keputusan bisnis lewat klausul yang rumit. Domain yang kosong — hanya berisi property tanpa perilaku — menghasilkan apa yang disebut model anemik: semua kecerdasan tersedot ke application, dan aturan bisnis tercecer di banyak use case.

Ringkasan #

Layered architecture menyusun aplikasi menjadi lapisan presentation, application, domain, dan data dengan satu aturan keras: dependensi hanya mengalir ke bawah, dan domain — lapisan paling berharga — tidak memanggil siapa pun. Demo toko buku memperlihatkan pembagian kerjanya: Keranjang menghitung potongan, LayananCheckout mengorkestrasi dan menegakkan kebijakan, RepositoriBuku menyembunyikan penyimpanan, dan renderStruk() satu-satunya yang tahu tampilan. Untuk kerja sehari-hari, keempat lapisan ini biasa diringkas menjadi trio praktis controller-service-repository — bentuk yang kita bangun pada chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

tokobuku-domain.php

Unduh berkas
php
<?php

declare(strict_types=1);

// Lapisan Domain: entitas dan aturan bisnis inti toko buku.

final class ItemKeranjang
{
    public function __construct(
        public readonly string $judul,
        public readonly int $harga,
        public readonly int $jumlah,
    ) {
    }

    public function subtotal(): int
    {
        return $this->harga * $this->jumlah;
    }
}

final class Keranjang
{
    /** @var list<ItemKeranjang> */
    private array $items = [];

    public function tambah(ItemKeranjang $item): void
    {
        $this->items[] = $item;
    }

    public function total(): int
    {
        return array_sum(array_map(fn (ItemKeranjang $item) => $item->subtotal(), $this->items));
    }

    public function potongan(): int
    {
        // Aturan bisnis: belanja >= Rp100.000 mendapat potongan 10 persen
        return $this->total() >= 100_000 ? intdiv($this->total(), 10) : 0;
    }
}

tokobuku.php

Unduh berkas
php
<?php

declare(strict_types=1);

// Lapisan Data, Application, dan Presentation untuk toko buku.
// Lapisan Domain berada di tokobuku-domain.php.

require __DIR__ . "/tokobuku-domain.php";

final class RepositoriBuku
{
    /** @param array<string, array{judul: string, harga: int}> $basisData */
    public function __construct(private readonly array $basisData)
    {
    }

    public function cari(string $id): ?array
    {
        return $this->basisData[$id] ?? null;
    }
}

final class LayananCheckout
{
    private const MINIMUM_PESANAN = 50_000;

    public function __construct(private readonly RepositoriBuku $repositori)
    {
    }

    /** @param array<string, int> $pesanan id buku => jumlah */
    public function buatPesanan(array $pesanan): array
    {
        $keranjang = new Keranjang();
        foreach ($pesanan as $id => $jumlah) {
            $buku = $this->repositori->cari($id);
            if ($buku === null) {
                throw new InvalidArgumentException("Buku dengan id $id tidak ditemukan");
            }
            $keranjang->tambah(new ItemKeranjang($buku["judul"], $buku["harga"], $jumlah));
        }

        $total = $keranjang->total() - $keranjang->potongan();
        if ($total < self::MINIMUM_PESANAN) {
            throw new DomainException("Pesanan di bawah minimum Rp50.000");
        }

        return [
            "judul" => count($pesanan),
            "potongan" => $keranjang->potongan(),
            "total" => $total,
        ];
    }
}

function renderStruk(array $pesanan): string
{
    return sprintf(
        "Terima kasih! %d judul, potongan Rp%s, total bayar Rp%s\n",
        $pesanan["judul"],
        number_format($pesanan["potongan"], 0, ",", "."),
        number_format($pesanan["total"], 0, ",", "."),
    );
}