Chapter Dependency Injection membahas cara menyerahterimakan dependency: object dibuat di luar, lalu disuntikkan lewat constructor. Dependency Inversion Principle (DIP) mengajukan pertanyaan yang lebih dalam: ke arah apa dependency itu seharusnya menunjuk? Prinsip ini — huruf D pada SOLID — menyatakan bahwa aturan bisnis tidak boleh menunggu pada detail teknis seperti SQL, koneksi database, atau protokol jaringan. Pada chapter ini kita mengambil satu service yang menulis SQL-nya sendiri, lalu me-refactor-nya sampai arah dependensinya terbalik: detail yang menghadap aturan bisnis, bukan sebaliknya.

Dua Aturan Dependency Inversion #

Dependency Inversion dirumuskan dalam dua aturan yang saling melengkapi:

Dalam konteks aplikasi kita, modul tingkat tinggi adalah kode yang membawa aturan bisnis dan use case — misalnya class yang menjawab “berapa total penjualan per kategori”. Modul tingkat rendah adalah mekanisme yang mewujudkan jawaban itu: koneksi PDO, string SQL, koneksi SMTP, atau panggilan API eksternal. Aturan pertama melarang modul bisnis memegang mekanisme secara langsung; aturan kedua melarang kontrak di antara keduanya bocor berisi urusan teknis. DIP adalah prinsip arsitektur, bukan pola kode — pembahasan formalnya sebagai bagian SOLID ada di chapter Dependency Inversion pada section Design Patterns .

Sebelum: Laporan yang Menyentuh SQL Langsung #

Perhatikan versi awal class laporan berikut. Class ini sekaligus menjadi pemilik koneksi database dan penulis query:

php
<?php
final class LaporanPenjualanLangsung
{
    private readonly PDO $db;

    public function __construct()
    {
        $this->db = new PDO("sqlite::memory:");
        $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->db->exec(
            "CREATE TABLE penjualan (kategori TEXT NOT NULL, jumlah INTEGER NOT NULL)"
        );
        $this->db->exec(
            "INSERT INTO penjualan (kategori, jumlah) VALUES
                ('buku', 3), ('alat tulis', 5), ('buku', 2)"
        );
    }

    /** @return array<string, int> */
    public function totalPerKategori(): array
    {
        $baris = $this->db->query(
            "SELECT kategori, SUM(jumlah) AS total FROM penjualan GROUP BY kategori"
        )->fetchAll(PDO::FETCH_ASSOC);

        $hasil = [];
        foreach ($baris as $b) {
            $hasil[(string) $b["kategori"]] = (int) $b["total"];
        }
        ksort($hasil);

        return $hasil;
    }
}

foreach ((new LaporanPenjualanLangsung())->totalPerKategori() as $kategori => $total) {
    echo "$kategori: $total\n";
}

Output yang Dihasilkan:

text
alat tulis: 5
buku: 5

Mari kita bedah kode di atas:

  1. Constructor membuat koneksi PDO ke database sqlite::memory:, lalu membuat tabel beserta tiga baris data contoh — seluruh urusan penyimpanan hidup di dalam class laporan.
  2. totalPerKategori() menjalankan query GROUP BY dan menjumlahkan SUM(jumlah) langsung melalui $this->db; panggilan ksort() menjaga urutan kategori tetap stabil.
  3. Baris terakhir menjalankan laporan dan mencetak hasilnya — bukti bahwa versi ini memang bekerja dengan benar.

Bekerjanya bukan berarti sehat. Keluhan pertama: menguji totalPerKategori() selalu menyeret PDO dan SQL — tidak ada cara menguji aturan penjumlahan tanpa database. Keluhan kedua: pindah ke MySQL atau PostgreSQL berarti mengedit class laporan, padahal yang berubah seharusnya hanya lapisan penyimpanan. Keluhan ketiga: query menyatu dengan logika sehingga sulit memisahkan mana aturan bisnis dan mana detail akses data.

Menyusun Abstraksi dari Sisi Konsumen #

Pembalikan dimulai dengan bertanya pada konsumen: apa sebenarnya yang dibutuhkan LaporanPenjualan? Jawabannya bukan “PDO”, melainkan sesuatu yang jauh lebih sederhana — “daftar penjualan”. Kebutuhan itulah yang dituangkan menjadi interface:

php
<?php
interface SumberDataPenjualan
{
    /** @return list<array{kategori: string, jumlah: int}> */
    public function ambilSemua(): array;
}

final class SumberDataPenjualanSqlite implements SumberDataPenjualan
{
    private readonly PDO $db;

    public function __construct()
    {
        $this->db = new PDO("sqlite::memory:");
        $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->db->exec(
            "CREATE TABLE penjualan (
                kategori TEXT NOT NULL,
                jumlah INTEGER NOT NULL
            )"
        );
        $this->db->exec(
            "INSERT INTO penjualan (kategori, jumlah) VALUES
                ('buku', 3),
                ('alat tulis', 5),
                ('buku', 2)"
        );
    }

    public function ambilSemua(): array
    {
        $baris = $this->db
            ->query("SELECT kategori, jumlah FROM penjualan")
            ->fetchAll(PDO::FETCH_ASSOC);

        return array_map(
            fn(array $b): array => [
                "kategori" => (string) $b["kategori"],
                "jumlah" => (int) $b["jumlah"],
            ],
            $baris
        );
    }
}

final class LaporanPenjualan
{
    public function __construct(private readonly SumberDataPenjualan $sumber)
    {
    }

    /** @return array<string, int> */
    public function totalPerKategori(): array
    {
        $total = [];
        foreach ($this->sumber->ambilSemua() as $baris) {
            $total[$baris["kategori"]] =
                ($total[$baris["kategori"]] ?? 0) + $baris["jumlah"];
        }
        ksort($total);

        return $total;
    }
}

Tiga perubahan kunci terjadi di atas:

  1. SumberDataPenjualan::ambilSemua() berbicara dalam bahasa konsumen — “ambil semua penjualan” — bukan dalam bahasa penyimpanan seperti SELECT atau fetchAssoc. Kontrak semacam ini milik klien yang memakainya, sebuah gagasan yang akan kita perdalam di chapter Interfaces dan Abstractions .
  2. SumberDataPenjualanSqlite menjadi tempat seluruh PDO dan SQL pindah; class detail ini kini menghadap kontrak lewat implements, dan baris mentahnya dinormalisasi ke bentuk array{kategori, jumlah}.
  3. LaporanPenjualan hanya mengenal kontrak; aturan penjumlahan per kategori tetap tinggal di sini karena itu pengetahuan bisnis, bukan urusan penyimpanan.

Seluruh definisi di atas tersimpan sebagai berkas penjualan.php pada bundle chapter ini. Laporan dijalankan seperti biasa:

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

$laporan = new LaporanPenjualan(new SumberDataPenjualanSqlite());

foreach ($laporan->totalPerKategori() as $kategori => $total) {
    echo "$kategori: $total\n";
}

Output yang Dihasilkan:

text
alat tulis: 5
buku: 5

Output-nya identik dengan versi awal — refactor ini tidak mengubah perilaku, hanya arah dependensi. Perbedaannya baru terasa ketika kebutuhan berubah. Menambah sumber data dari berkas CSV, API eksternal, atau array dalam memori kini berarti menulis class baru yang meng-implements SumberDataPenjualan, tanpa menyentuh LaporanPenjualan sedikit pun.

Menguji Aturan Bisnis Tanpa PDO #

Bukti paling nyata dari pembalikan ini adalah kemampuan menguji ulang laporan dengan sumber data palsu yang tidak mengenal PDO sama sekali:

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

final class SumberDataPenjualanArray implements SumberDataPenjualan
{
    /** @param list<array{kategori: string, jumlah: int}> $baris */
    public function __construct(private readonly array $baris)
    {
    }

    public function ambilSemua(): array
    {
        return $this->baris;
    }
}

$laporan = new LaporanPenjualan(
    new SumberDataPenjualanArray([
        ["kategori" => "elektronik", "jumlah" => 7],
        ["kategori" => "elektronik", "jumlah" => 4],
    ])
);

foreach ($laporan->totalPerKategori() as $kategori => $total) {
    echo "$kategori: $total\n";
}

Output yang Dihasilkan:

text
elektronik: 11

SumberDataPenjualanArray hanya menyimpan baris data di sebuah property, dan totalPerKategori() menjumlahkannya menjadi 11 tanpa satu baris SQL pun. Bandingkan dengan versi awal: menguji aturan penjumlahan per kategori tanpa database saat itu mustahil. Dengan DIP, pengujian aturan bisnis berbiaya mikrodetik dan tidak membutuhkan infrastruktur apa pun. Test double semacam ini sudah kita kenal sejak chapter Controller-Service-Repository ; sekarang kita tahu prinsip yang membuat teknik itu mungkin.

Membaca Diagram Arah Dependensi #

Perbedaan kedua versi paling mudah dilihat sebagai diagram panah dependensi. Sebelum dibalik, panah dari aturan bisnis menunjuk langsung ke detail. Setelah dibalik, kedua pihak menunjuk ke abstraksi yang berdiri di antara mereka:

Pada subgraph sebelum, LaporanPenjualan bergantung pada PDO — setiap perubahan detail, seperti mengganti driver atau memindahkan query, memaksa modul tingkat tinggi ikut berubah. Pada subgraph sesudah, panah dari detail justru berbalik arah: SumberDataPenjualanSqlite menghadap kontrak melalui implements. Di sinilah asal kata inversion: yang dibalik bukan aturan bisnisnya, melainkan arah panah detail yang diputar sehingga detail mengikuti aturan.

Composition Root, Tempat Detail Bertemu #

Pertanyaan terakhir: kalau semua modul menghadap abstraksi, siapa yang berani menyentuh class konkret? Jawaban DIP adalah satu tempat khusus di titik masuk aplikasi yang disebut composition root. Di sanalah new SumberDataPenjualanSqlite() tidak hanya diperbolehkan, tetapi diharapkan:

php
<?php
// index.php — composition root aplikasi
require __DIR__ . "/src/penjualan.php";

// Satu-satunya tempat class konkret saling bertemu:
$sumber = new SumberDataPenjualanSqlite();
$laporan = new LaporanPenjualan($sumber);
$controller = new LaporanController($laporan);

// Setelah titik ini, seluruh aplikasi bekerja di atas object yang sudah jadi.
$controller->tampilkan();

Setelah baris penyambungan itu, seluruh sisa aplikasi menerima object jadi dan tidak pernah menulis new untuk dependency-nya lagi. Pola yang sama menjaga transisi saat pengujian: composition root produksi memasang SumberDataPenjualanSqlite, sedangkan composition root pengujian memasang SumberDataPenjualanArray. Pada aplikasi nyata, composition root berkembang menjadi proses bootstrap yang sekaligus memuat konfigurasi dan environment — dibahas tuntas pada chapter Application Bootstrap , dengan versi otomatisnya pada chapter Dependency Container di section Framework Development.

Ringkasan #

Dependency inversion meletakkan abstraksi di antara aturan bisnis dan detail teknis: modul tingkat tinggi dan rendah sama-sama menghadap kontrak, dan detail-lah yang dipaksa mengikuti. Refactor laporan penjualan menunjukkan hasil eksekusi yang identik dengan arah panah yang berbeda, ditambah kemampuan menguji aturan bisnis tanpa PDO. Prinsip ini adalah bagian dari SOLID yang dibedah lebih formal pada chapter Dependency Inversion di section Design Patterns . Keterampilan berikutnya adalah merancang abstraksi itu sendiri — agar tidak bocor, tidak terlalu lebar, dan stabil menghadapi perubahan; chapter Interfaces dan Abstractions membuka kotak perkakasnya.

Referensi #

Berkas Sumber

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

penjualan.php

Unduh berkas
php
<?php

// Bundle chapter Dependency Inversion: laporan penjualan di atas abstraksi.

interface SumberDataPenjualan
{
    /** @return list<array{kategori: string, jumlah: int}> */
    public function ambilSemua(): array;
}

final class SumberDataPenjualanSqlite implements SumberDataPenjualan
{
    private readonly PDO $db;

    public function __construct()
    {
        $this->db = new PDO("sqlite::memory:");
        $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->db->exec(
            "CREATE TABLE penjualan (
                kategori TEXT NOT NULL,
                jumlah INTEGER NOT NULL
            )"
        );
        $this->db->exec(
            "INSERT INTO penjualan (kategori, jumlah) VALUES
                ('buku', 3),
                ('alat tulis', 5),
                ('buku', 2)"
        );
    }

    public function ambilSemua(): array
    {
        $baris = $this->db
            ->query("SELECT kategori, jumlah FROM penjualan")
            ->fetchAll(PDO::FETCH_ASSOC);

        return array_map(
            fn(array $b): array => [
                "kategori" => (string) $b["kategori"],
                "jumlah" => (int) $b["jumlah"],
            ],
            $baris
        );
    }
}

final class LaporanPenjualan
{
    public function __construct(private readonly SumberDataPenjualan $sumber)
    {
    }

    /** @return array<string, int> */
    public function totalPerKategori(): array
    {
        $total = [];
        foreach ($this->sumber->ambilSemua() as $baris) {
            $total[$baris["kategori"]] =
                ($total[$baris["kategori"]] ?? 0) + $baris["jumlah"];
        }
        ksort($total);

        return $total;
    }
}