Prinsip pertama SOLID terdengar paling sederhana — “satu class, satu tanggung jawab” — tetapi juga paling sering disalahpahami dan paling sering dilanggar. Kesalahpahamannya: tanggung jawab bukan berarti satu method atau satu tugas kecil, melainkan satu alasan untuk berubah. Class yang menutup pesanan, menyimpannya ke database, dan mengirim email konfirmasi punya tiga alasan berubah sekaligus, dan setiap alasan itu akan datang menghantam di waktu yang berbeda. Pada chapter ini kita bedah rumusannya, belajar mendeteksi pelanggarannya dari nama class dan pola diff, lalu merombak sebuah God class menjadi kolaborasi class-class kecil — dengan perilaku yang terbukti tidak berubah.

Satu Alasan Berubah, Bukan Satu Hal #

Rumusan asli Robert C. Martin untuk Single Responsibility Principle (SRP) berbunyi: sebuah modul harus bertanggung jawab kepada satu, dan hanya satu, aktor. Kata kuncinya adalah aktor — kelompok orang yang bisa menginginkan perubahan pada class tersebut. Aturan harga dan diskon diubah oleh tim keuangan; skema tabel diubah oleh yang mengelola database; isi email konfirmasi diubah oleh tim marketing. Kalau satu class melayani dua aktor itu sekaligus, dua kelompok orang yang tidak saling koordinasi akan mengedit berkas yang sama — dan konfliknya bukan sekadar estetika, melainkan risiko saling menimpa keputusan bisnis.

Dengan kerangka ini, “satu tanggung jawab” berhenti menjadi soal menghitung method. Sebuah class boleh punya sepuluh method selama semua perubahan atas class itu berasal dari satu aktor yang sama. Sebaliknya, class dengan dua method saja bisa melanggar SRP bila satu method berubah karena kebutuhan pelaporan dan satu method karena kebutuhan notifikasi. Pertanyaan yang tepat untuk diajukan bukan “apa yang dikerjakan class ini”, melainkan “siapa yang bisa meminta class ini diubah”.

Gejala Pelanggaran di Lapangan #

Pelanggaran SRP hampir selalu meninggalkan jejak yang bisa dideteksi bahkan sebelum membaca isinya. Gejala paling khas adalah nama class yang samar: OrderManager, UserService, Helper, Utils, Manager saja tanpa objeknya. Nama seperti itu biasanya berarti “tempat menaruh apa saja yang berkaitan dengan X” — dan tempat serbaguna selalu penuh seiring waktu. Nama class yang sehat menyebut satu benda spesifik: OrderCalculator, OrderRepository, EmailNotifier.

Gejala kedua hidup di riwayat Git: class pelanggar SRP muncul di hampir setiap diff besar. Formulir diskon baru, migrasi skema, revisi template email — semuanya menyentuh berkas yang sama, sehingga pull request Anda selalu beririsan dengan orang lain dan merge conflict menjadi rutinitas. Gejala ketiga paling mahal: class tersebut sulit diuji. Ingin menguji rumus diskon saja? Anda tetap wajib menyiapkan database sungguhan karena diskon dan penyimpanan tinggal bersama. Ketiga gejala ini saling menegaskan — menemukan satu biasanya berarti dua lainnya tidak jauh.

Studi Kasus: God Class Sebelum Refactor #

Mari kita hadapi pelanggarannya secara langsung. Class berikut menangani pesanan toko buku: menghitung total dengan aturan diskon grosir, menyimpan pesanan ke database, lalu menyiapkan email konfirmasi. Semuanya berjalan — dan pada aplikasi kecil, kode seperti ini sungguh bisa bertahan bertahun-tahun:

php
<?php
final class OrderManager
{
    public function __construct(private readonly PDO $db) {}

    public function hitungTotal(array $baris): int
    {
        $total = 0;
        foreach ($baris as $item) {
            $subtotal = $item["qty"] * $item["harga"];
            if ($item["qty"] >= 10) {
                $subtotal = intdiv($subtotal * 90, 100); // diskon grosir 10%
            }
            $total += $subtotal;
        }
        return $total;
    }

    public function simpan(array $baris, int $total): int
    {
        $stmt = $this->db->prepare("INSERT INTO pesanan (total) VALUES (?)");
        $stmt->execute([$total]);
        $id = (int) $this->db->lastInsertId();

        $stmtItem = $this->db->prepare(
            "INSERT INTO item_pesanan (pesanan_id, nama, qty, harga) VALUES (?, ?, ?, ?)"
        );
        foreach ($baris as $item) {
            $stmtItem->execute([$id, $item["nama"], $item["qty"], $item["harga"]]);
        }
        return $id;
    }

    public function kirimEmail(int $id, int $total): string
    {
        return "Email ke pelanggan: pesanan #$id sebesar Rp" . number_format($total, 0, ",", ".");
    }
}

$db = new PDO("sqlite::memory:");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, total INTEGER)");
$db->exec("CREATE TABLE item_pesanan (id INTEGER PRIMARY KEY, pesanan_id INTEGER, nama TEXT, qty INTEGER, harga INTEGER)");

$manager = new OrderManager($db);
$baris = [
    ["nama" => "Kopi Gayo 250g", "qty" => 2, "harga" => 85000],
    ["nama" => "Teh Melati 100g", "qty" => 12, "harga" => 32000],
];

$total = $manager->hitungTotal($baris);
$id = $manager->simpan($baris, $total);

echo "Total: Rp", number_format($total, 0, ",", "."), "\n";
echo "Pesanan #$id tersimpan\n";
echo $manager->kirimEmail($id, $total), "\n";

Output yang Dihasilkan:

text
Total: Rp515.600
Pesanan #1 tersimpan
Email ke pelanggan: pesanan #1 sebesar Rp515.600

Mari kita periksa angkanya sebelum melanjutkan. Kopi Gayo dua pcs tanpa diskon menghasilkan 170.000; Teh Melati dua belas pcs kena diskon grosir sehingga 384.000 menjadi 345.600; totalnya 515.600. Simpan angka-angka ini — mereka akan menjadi alat ukur refactor kita.

Masalah OrderManager bukan pada kebenaran kode, melainkan pada siapa yang mengubahnya. hitungTotal() berubah ketika tim keuangan merevisi aturan diskon. simpan() berubah ketika skema tabel dirombak atau berpindah mesin database. kirimEmail() berubah setiap kali marketing ingin menyentuh isi pesan. Tiga aktor, satu class — setiap perubahan apapun sumbernya dilakukan di berkas yang sama, dan setiap pengujian rumus diskon wajib membawa PDO sebagai bagasi.

Hasil Refactor: Class-Class Kecil yang Berkolinaborasi #

Refactor SRP memindahkan setiap tanggung jawab ke classnya sendiri, lalu membiakannya kembali lewat komposisi di alur utama. Berikut hasilnya:

php
<?php
final class OrderCalculator
{
    public function hitungTotal(array $baris): int
    {
        $total = 0;
        foreach ($baris as $item) {
            $subtotal = $item["qty"] * $item["harga"];
            if ($item["qty"] >= 10) {
                $subtotal = intdiv($subtotal * 90, 100); // diskon grosir 10%
            }
            $total += $subtotal;
        }
        return $total;
    }
}

final class OrderRepository
{
    public function __construct(private readonly PDO $db) {}

    public function simpan(array $baris, int $total): int
    {
        $stmt = $this->db->prepare("INSERT INTO pesanan (total) VALUES (?)");
        $stmt->execute([$total]);
        $id = (int) $this->db->lastInsertId();

        $stmtItem = $this->db->prepare(
            "INSERT INTO item_pesanan (pesanan_id, nama, qty, harga) VALUES (?, ?, ?, ?)"
        );
        foreach ($baris as $item) {
            $stmtItem->execute([$id, $item["nama"], $item["qty"], $item["harga"]]);
        }
        return $id;
    }
}

final class OrderNotifier
{
    public function email(int $id, int $total): string
    {
        return "Email ke pelanggan: pesanan #$id sebesar Rp" . number_format($total, 0, ",", ".");
    }
}

$db = new PDO("sqlite::memory:");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, total INTEGER)");
$db->exec("CREATE TABLE item_pesanan (id INTEGER PRIMARY KEY, pesanan_id INTEGER, nama TEXT, qty INTEGER, harga INTEGER)");

$calculator = new OrderCalculator();
$repository = new OrderRepository($db);
$notifier = new OrderNotifier();

$baris = [
    ["nama" => "Kopi Gayo 250g", "qty" => 2, "harga" => 85000],
    ["nama" => "Teh Melati 100g", "qty" => 12, "harga" => 32000],
];

$total = $calculator->hitungTotal($baris);
$id = $repository->simpan($baris, $total);

echo "Total: Rp", number_format($total, 0, ",", "."), "\n";
echo "Pesanan #$id tersimpan\n";
echo $notifier->email($id, $total), "\n";

Output yang Dihasilkan:

text
Total: Rp515.600
Pesanan #1 tersimpan
Email ke pelanggan: pesanan #1 sebesar Rp515.600

Output kedua versi identik sampai ke angka terakhir — refactor yang benar memang tidak boleh mengubah perilaku, hanya menata ulang tempat kode tinggal. Struktur barunya memetakan langsung ke tiga aktor tadi: OrderCalculator milik tim keuangan, OrderRepository milik pengelola data, OrderNotifier milik marketing. Nama class pun berubah dari samar menjadi deskriptif — gejala pelanggaran yang kita bahas di awal ikut hilang. Perhatikan juga bahwa alur utama kini membaca seperti rangkaian langkah bisnis: hitung, simpan, beri tahu.

Sumbu Tanggung Jawab: Aktor Bisnis #

Kesulitan paling umum menerapkan SRP adalah menentukan batas tanggung jawab — dua method terasa “berhubungan”, jadi harus satu class? Tabel berikut memetakan sumbu pemisah yang benar, yaitu aktor bisnis, bukan kemiripan teknis:

Aktor bisnisContoh keputusan yang berubah sendiriTempat tinggal alami
Keuangan / pemilik aturan hargabesaran diskon grosir, pajak, minimum belanjaOrderCalculator
Pengelola data / infrastrukturskema tabel, pilihan database, strategi cachingOrderRepository
Marketing / layanan pelangganisi template email, kanal notifikasi, nada bahasaOrderNotifier

Dua method yang secara teknis mirip — misalnya menghitung diskon dan menghitung pajak — boleh berbeda class bila aktornya berbeda, walaupun keduanya bernama hitung.... Sebaliknya, method yang tampak berbeda boleh segaris bila satu aktor yang mengubah keduanya. Konsekuensi kolaborasinya: class kecil saling menyambung lewat parameter dan return value yang jelas — hitungTotal() mengembalikan int, dan int itulah yang dialirkan ke simpan() dan email(). Setiap sambungan adalah titik di mana dua aktor bersepakat tentang data, dan kesepakatan kecil semacam itu jauh lebih murah daripada berbagi satu class penuh.

Keuntungan pada Testability #

Keuntungan paling terukur dari SRP muncul saat menulis test. Menguji hitungTotal() versi God class menuntut database hidup, sebab method itu tak bisa dipisahkan dari konstruktornya yang minta PDO. Setelah refactor, OrderCalculator tidak memiliki dependensi sama sekali — test-nya hanya function call dan perbandingan angka:

php
<?php
final class OrderCalculator
{
    public function hitungTotal(array $baris): int
    {
        $total = 0;
        foreach ($baris as $item) {
            $subtotal = $item["qty"] * $item["harga"];
            if ($item["qty"] >= 10) {
                $subtotal = intdiv($subtotal * 90, 100);
            }
            $total += $subtotal;
        }
        return $total;
    }
}

$hasilGrosir = (new OrderCalculator())->hitungTotal([
    ["nama" => "Teh Melati 100g", "qty" => 12, "harga" => 32000],
]);
echo ($hasilGrosir === 345600) ? "PASS: diskon grosir benar\n" : "FAIL: dapat $hasilGrosir\n";

$hasilGabungan = (new OrderCalculator())->hitungTotal([
    ["nama" => "Kopi Gayo 250g", "qty" => 2, "harga" => 85000],
    ["nama" => "Teh Melati 100g", "qty" => 12, "harga" => 32000],
]);
echo ($hasilGabungan === 515600) ? "PASS: total gabungan benar\n" : "FAIL: dapat $hasilGabungan\n";

Output yang Dihasilkan:

text
PASS: diskon grosir benar
PASS: total gabungan benar

Test di atas ditulis polos tanpa framework agar fokus pada intinya: menguji aturan diskon kini tidak butuh database, web server, maupun SMTP — hanya array masukan dan angka harapan. Dengan PHPUnit, pola yang sama ditulis sebagai method assertEquals(345600, ...) di dalam class test tersendiri; strukturnya tidak berubah. Efeknya menular: perubahan aturan diskon yang salah sekalipun akan tertangkap dalam hitungan detik, tanpa perlu menyiapkan lingkungan berat. Inilah alasan orang menyebut testability sebagai “detektor SRP” — class yang sulit diuji hampir selalu menampung lebih dari satu tanggung jawab.

Ringkasan #

Single Responsibility Principle membatasi satu class pada satu aktor bisnis: satu kelompok orang yang alasan perubahannya sama. Pelanggarannya terdeteksi dari nama class yang samar, diff yang selalu raksasa, dan test yang mustahil ditulis tanpa lingkungan penuh. Refactornya berjalan dengan aman bila perilaku dikunci lewat output yang identik sebelum dan sesudah — sebagaimana OrderManager yang kita pecah menjadi OrderCalculator, OrderRepository, dan OrderNotifier. Prinsip berikutnya melanjutkan ke pertanyaan wajar setelah pemisahan ini: bagaimana menambah perilaku baru tanpa membuka lagi class-class yang sudah teruji — itulah Open/Closed Principle.

Referensi #