Aplikasi PHP kecil biasanya dimulai sebagai satu berkas yang menerima request, mengolah data, dan mencetak HTML — dan pada awalnya itu memang tidak masalah. Masalah muncul saat berkas itu tumbuh: form baru ditambahkan, aturan bisnis bertingkat, dan tampilan berganti-ganti, semuanya di tempat yang sama. Setiap perubahan menuntut kita memahami seluruh berkas, dan setiap kesalahan kecil bisa merambat ke mana-mana. Separation of concerns (SoC) adalah prinsip yang menjawab masalah ini: bagi program menjadi bagian-bagian yang masing-masing punya satu tanggung jawab. Section ini membahas arsitektur aplikasi dari prinsip dasar hingga pola berlapis, dan kita mulai dari fondasinya.

Prinsip Satu Alasan Berubah #

Cara paling praktis mengukur sebuah pemisahan adalah bertanya: “kalau kebutuhan ini berubah, berapa banyak bagian yang ikut berubah?”. Desain yang baik membuat setiap jenis perubahan hanya menyentuh satu tempat. Ketika aturan validasi berubah, hanya kode validasi yang diedit; ketika tampilan dirombak, kode bisnis tidak tersentuh; ketika sumber input berganti dari form HTML menjadi JSON dari API, logika tidak perlu tahu.

Pada level yang paling halus, prinsip ini dikenal sebagai Single Responsibility Principle dalam dunia object-oriented programming: satu class, satu alasan untuk berubah. SoC adalah versi luasnya yang berlaku untuk fungsi, berkas, hingga keseluruhan modul — dan berlaku sama baiknya pada kode prosedural maupun berkelas. Patokan konkretnya selalu sama: kalau Anda sulit menjelaskan tanggung jawab sebuah fungsi dalam satu kalimat tanpa kata “dan”, fungsi itu kemungkinan besar mengerjakan terlalu banyak hal.

Masalah Skrip Spaghetti #

Mari kita lihat wujud masalahnya. Skrip berikut menangani halaman kontak: membersihkan input, memvalidasi, menentukan prioritas tiket, lalu mencetak HTML — semuanya dalam satu alur tanpa pemisah:

php
<?php
// kontak-lama.php - seluruh tanggung jawab tercampur dalam satu alur
$input = ["nama" => "Budi", "email" => "[email protected]", "pesan" => "Halo, saya ingin bertanya tentang produk"];

// --- campuran 1: pengambilan dan pembersihan input ---
$nama = trim($input["nama"]);
$email = trim($input["email"]);
$pesan = trim($input["pesan"]);

// --- campuran 2: validasi ---
$errors = [];
if ($nama === "") {
    $errors[] = "nama wajib diisi";
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors[] = "email tidak valid";
}
if (mb_strlen($pesan) < 10) {
    $errors[] = "pesan minimal 10 karakter";
}

// --- campuran 3: logika bisnis ---
$prioritas = str_contains($pesan, "mendesak") ? "tinggi" : "normal";
$nomorTiket = "TKT-" . strtoupper(substr(md5($nama . $pesan), 0, 8));

// --- campuran 4: output HTML dibangun langsung ---
echo "<h1>Konfirmasi Pesan</h1>\n";
if ($errors !== []) {
    echo "<ul>\n";
    foreach ($errors as $error) {
        echo "<li>" . htmlspecialchars($error, ENT_QUOTES) . "</li>\n";
    }
    echo "</ul>\n";
} else {
    echo "<p>Terima kasih, " . htmlspecialchars($nama, ENT_QUOTES)
        . ". Tiket Anda: $nomorTiket (prioritas $prioritas)</p>\n";
}

Output yang Dihasilkan:

text
<h1>Konfirmasi Pesan</h1>
<p>Terima kasih, Budi. Tiket Anda: TKT-2B3561AE (prioritas normal)</p>

Pada 30 baris, skrip ini masih bisa diikuti — dan justru itulah yang membuatnya berbahaya: rasa aman di ukuran kecil membuatnya dibiarkan tumbuh. Empat tanggung jawab yang berbeda hidup berdampingan tanpa batas yang jelas. Menguji aturan prioritas berarti menjalankan seluruh alur sampai echo tercetak; mengubah format tiket berarti menyisir kode yang bercampur dengan validasi; dan memakai logika yang sama untuk endpoint JSON berarti menyalin-tempel — lalu merawat dua salinan yang perlahan berbeda.

Memisahkan Input, Logika, dan Output #

Refaktor pertama tidak butuh framework — cukup memindahkan tiap tanggung jawab ke dalam fungsinya sendiri. Tiga fungsi hasil pemisahan:

php
<?php
// --- lapisan 1: penanganan input ---
function prosesInput(array $input): array
{
    $nama = trim($input["nama"] ?? "");
    $email = trim($input["email"] ?? "");
    $pesan = trim($input["pesan"] ?? "");

    $errors = [];
    if ($nama === "") {
        $errors[] = "nama wajib diisi";
    }
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = "email tidak valid";
    }
    if (mb_strlen($pesan) < 10) {
        $errors[] = "pesan minimal 10 karakter";
    }

    return ["nama" => $nama, "email" => $email, "pesan" => $pesan, "errors" => $errors];
}

// --- lapisan 2: logika bisnis ---
function buatTiket(string $nama, string $pesan): array
{
    $prioritas = str_contains($pesan, "mendesak") ? "tinggi" : "normal";
    $nomor = "TKT-" . strtoupper(substr(md5($nama . $pesan), 0, 8));

    return ["prioritas" => $prioritas, "nomor" => $nomor];
}

// --- lapisan 3: output ---
function renderKonfirmasi(array $data, array $tiket): string
{
    if ($data["errors"] !== []) {
        $items = "";
        foreach ($data["errors"] as $error) {
            $items .= "<li>" . htmlspecialchars($error, ENT_QUOTES) . "</li>\n";
        }
        return "<h1>Konfirmasi Pesan</h1>\n<ul>\n$items</ul>\n";
    }

    return "<h1>Konfirmasi Pesan</h1>\n"
        . "<p>Terima kasih, " . htmlspecialchars($data["nama"], ENT_QUOTES)
        . ". Tiket Anda: {$tiket['nomor']} (prioritas {$tiket['prioritas']})</p>\n";
}

// --- alur utama menjadi ringkas ---
$input = ["nama" => "Budi", "email" => "[email protected]", "pesan" => "Halo, saya ingin bertanya tentang produk"];

$data = prosesInput($input);
$tiket = $data["errors"] === [] ? buatTiket($data["nama"], $data["pesan"]) : [];

echo renderKonfirmasi($data, $tiket);

Output yang Dihasilkan:

text
<h1>Konfirmasi Pesan</h1>
<p>Terima kasih, Budi. Tiket Anda: TKT-2B3561AE (prioritas normal)</p>

Output di atas identik dengan versi spaghetti — refaktor harus selalu mempertahankan perilaku, dan kesamaan output adalah buktinya. Bedah per fungsi:

  1. prosesInput() adalah satu-satunya yang menyentuh input mentah. Ia mengembalikan data bersih beserta daftar error dalam bentuk array terstruktur, tanpa mencetak apa pun.
  2. buatTiket() murni logika bisnis: menerima data yang sudah bersih, mengembalikan prioritas dan nomor tiket. Ia tidak tahu data itu datangnya dari form, API, maupun berkas.
  3. renderKonfirmasi() satu-satunya yang mengerti HTML, dan hanya mengembalikan string — tidak langsung echo, sehingga pemanggil bebas menentukan nasib outputnya.
  4. Alur utama tinggal tiga langkah yang terbaca seperti kalimat: proses input, buat tiket bila valid, render hasilnya.

Bukti nyata manfaat pemisahan datang dari kemudahan menguji. Karena prosesInput() dan buatTiket() tidak bergantung pada HTTP maupun HTML, keduanya bisa dites langsung di CLI:

php
<?php
require __DIR__ . "/kontak-logika.php";

// Logika kini bisa diuji tanpa HTML, tanpa web server, tanpa browser
$kasus = [
    ["nama" => "", "email" => "bukan-email", "pesan" => "pendek"],
    ["nama" => "Budi", "email" => "[email protected]", "pesan" => "Halo, saya ingin bertanya"],
];

foreach ($kasus as $i => $input) {
    $data = prosesInput($input);
    echo "kasus " . ($i + 1) . ": " . count($data["errors"]) . " error\n";
}

$tiket = buatTiket("Budi", "Server down, ini mendesak sekali");
echo "prioritas: {$tiket['prioritas']}\n";

Output yang Dihasilkan:

text
kasus 1: 3 error
kasus 2: 0 error
prioritas: tinggi

Pada versi spaghetti, menguji tiga aturan validasi berarti menyiapkan request HTTP dan memilah HTML keluarannya. Di sini, uji kasus hanyalah array dua elemen, dan hasilnya angka yang mudah dilihat: input kacau menghasilkan 3 error, input bersih menghasilkan 0, dan pesan ber kata “mendesak” menaikkan prioritas menjadi tinggi.

Naik ke Level Berkas #

Pemisahan fungsi adalah langkah pertama; langkah berikutnya memindahkan lapisan ke berkasnya masing-masing. Berkas pendukung chapter ini sudah disusun demikian:

text
separation-of-concerns/
├── kontak.php            # controller: alur input -> logika -> output
├── kontak-logika.php     # logika bisnis: validasi + pembuatan tiket
└── kontak-tampilan.php   # presentasi: satu-satunya yang tahu HTML

kontak-logika.php berisi prosesInput() dan buatTiket() persis seperti di atas; kontak-tampilan.php berisi renderKonfirmasi(). Yang baru adalah kontak.php — pintu masuk yang tinggal menyusun alur:

php
<?php
// kontak.php - controller: mengatur alur dari input hingga output.
require __DIR__ . "/kontak-logika.php";
require __DIR__ . "/kontak-tampilan.php";

// Di aplikasi web nyata, baris ini menjadi: $input = $_POST;
$input = [
    "nama" => "Budi",
    "email" => "[email protected]",
    "pesan" => "Halo, saya ingin bertanya tentang produk",
];

$data = prosesInput($input);
$tiket = $data["errors"] === [] ? buatTiket($data["nama"], $data["pesan"]) : [];

echo renderKonfirmasi($data, $tiket);

Output yang Dihasilkan:

text
<h1>Konfirmasi Pesan</h1>
<p>Terima kasih, Budi. Tiket Anda: TKT-2B3561AE (prioritas normal)</p>

Output masih identik — perilaku tidak berubah, hanya penataannya. Namun strukturnya kini memberi manfaat nyata: kontak-logika.php bisa di-require oleh halaman lain yang butuh aturan yang sama, kontak-tampilan.php bisa diganti total tanpa menyentuh logika, dan orang yang mencari aturan bisnis tahu persis berkas mana yang dibuka. Prinsip yang sama nanti berlaku pada level class dan package: satu class satu tanggung jawab, satu namespace satu tema — pembahasan section ini lanjut ke pola berlapis pada chapter Layered Architecture .

Batas Tanggung Jawab di Aplikasi Web #

Dalam aplikasi web, lapisan paling luar punya tanggung jawab yang khusus: controller hanya mengurus HTTP. Ia membaca request, memanggil logika, lalu mengirim response — tidak lebih. Versi web nyata dari kontak.php terlihat seperti ini:

php
<?php
// kontak-web.php - controller versi web nyata
require __DIR__ . "/kontak-logika.php";
require __DIR__ . "/kontak-tampilan.php";

$input = $_POST; // satu-satunya sentuhan ke superglobal HTTP

$data = prosesInput($input);
if ($data["errors"] !== []) {
    http_response_code(422); // input tidak valid
}
$tiket = $data["errors"] === [] ? buatTiket($data["nama"], $data["pesan"]) : [];

echo renderKonfirmasi($data, $tiket);

Saat diuji dengan built-in web server dan curl, versi ini mengembalikan status 200 berikut halaman konfirmasi untuk POST yang lengkap, dan status 422 berikut daftar tiga error untuk POST yang kosong — status code menjadi bagian dari kontrak controller. Perhatikan batas-batasnya: $_POST dan http_response_code() hanya muncul di controller, aturan bisnis tetap di kontak-logika.php, dan HTML tetap dihasilkan renderKonfirmasi(). Ketika suatu hari tampilan harus menjadi JSON, hanya controller dan lapisan presentasi yang berubah — aturan prioritas tiket tidak ikut disentuh.

Ringkasan #

Separation of concerns membagi program menjadi bagian dengan satu alasan berubah: penanganan input, logika bisnis, dan output dipisah dulu sebagai fungsi, lalu sebagai berkas, dan akhirnya sebagai lapisan aplikasi. Refaktor kontak menunjukkan prosesnya bertahap dengan perilaku yang terjaga — output identik di setiap langkah — sementara batas HTTP ditaruh tegas di controller. Dengan pemisahan di tangan, pertanyaan berikutnya adalah bagaimana menyusun lapisan-lapisan itu menjadi arsitektur utuh, yang kita bahas pada chapter Layered Architecture .

Referensi #

Berkas Sumber

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

kontak-logika.php

Unduh berkas
php
<?php
// kontak-logika.php - logika bisnis halaman kontak.
// Berkas ini tidak tahu apa pun tentang HTTP maupun HTML.

/** Memvalidasi input mentah dan mengembalikan data bersih beserta daftar error. */
function prosesInput(array $input): array
{
    $nama = trim($input["nama"] ?? "");
    $email = trim($input["email"] ?? "");
    $pesan = trim($input["pesan"] ?? "");

    $errors = [];
    if ($nama === "") {
        $errors[] = "nama wajib diisi";
    }
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = "email tidak valid";
    }
    if (mb_strlen($pesan) < 10) {
        $errors[] = "pesan minimal 10 karakter";
    }

    return ["nama" => $nama, "email" => $email, "pesan" => $pesan, "errors" => $errors];
}

/** Menentukan prioritas dan nomor tiket dari pesan yang sudah valid. */
function buatTiket(string $nama, string $pesan): array
{
    $prioritas = str_contains($pesan, "mendesak") ? "tinggi" : "normal";
    $nomor = "TKT-" . strtoupper(substr(md5($nama . $pesan), 0, 8));

    return ["prioritas" => $prioritas, "nomor" => $nomor];
}

kontak-tampilan.php

Unduh berkas
php
<?php
// kontak-tampilan.php - lapisan presentasi halaman kontak.
// Berkas ini satu-satunya yang tahu cara merender HTML.

/** Merender halaman konfirmasi dalam bentuk string HTML. */
function renderKonfirmasi(array $data, array $tiket): string
{
    if ($data["errors"] !== []) {
        $items = "";
        foreach ($data["errors"] as $error) {
            $items .= "<li>" . htmlspecialchars($error, ENT_QUOTES) . "</li>\n";
        }
        return "<h1>Konfirmasi Pesan</h1>\n<ul>\n$items</ul>\n";
    }

    return "<h1>Konfirmasi Pesan</h1>\n"
        . "<p>Terima kasih, " . htmlspecialchars($data["nama"], ENT_QUOTES)
        . ". Tiket Anda: {$tiket['nomor']} (prioritas {$tiket['prioritas']})</p>\n";
}

kontak.php

Unduh berkas
php
<?php
// kontak.php - controller: mengatur alur dari input hingga output.
require __DIR__ . "/kontak-logika.php";
require __DIR__ . "/kontak-tampilan.php";

// Di aplikasi web nyata, baris ini menjadi: $input = $_POST;
$input = [
    "nama" => "Budi",
    "email" => "[email protected]",
    "pesan" => "Halo, saya ingin bertanya tentang produk",
];

$data = prosesInput($input);
$tiket = $data["errors"] === [] ? buatTiket($data["nama"], $data["pesan"]) : [];

echo renderKonfirmasi($data, $tiket);