Pattern yang kita bahas sejauh ini — factory, strategy, hingga dependency inversion — mengatur hubungan antar objek di dalam memori. Aplikasi nyata punya wilayah lain yang tak kalah berantakannya: tempat objek bertemu dengan media penyimpanan. Repository Pattern adalah pola yang berdiri di perbatasan tersebut, menjadi mediator antara logika domain dan mekanisme persistensi. Pada chapter ini kita bedah sisi teorinya — koleksi di memori, query yang mengungkap niat, dan aggregate root — sementara penerapan konkretnya dengan PDO dan SQLite sudah kita bangun di chapter Repository Pattern pada Section Database dan SQL .

Masalah Domain yang Tahu Terlalu Banyak Soal Penyimpanan #

Bayangkan logika bisnis yang menentukan tugas mana yang wajib ditampilkan ke pengguna. Tanpa pemisahan, keputusan itu bercampur dengan string SQL, panggilan prepare()/execute(), dan bentuk baris hasil query. Kode pembaca terpaksa memilah dua bahasa sekaligus: bahasa bisnis dan bahasa penyimpanan. Efeknya menumpuk — query yang sama menyebar ke banyak berkas, pengujian selalu menuntut database yang hidup, dan perubahan skema memaksa kita memburu kode ke seluruh penjuru aplikasi.

Repository menawarkan kontrak yang berbeda: logika domain berbicara seolah seluruh objeknya hidup dalam satu koleksi di memori. Pertanyaan “tugas mana yang belum selesai?” diajukan sebagai pemanggilan method, bukan sebagai penyusunan WHERE. Bagaimana jawaban itu akhirnya datang dari array PHP, berkas SQLite, atau server MySQL menjadi urusan implementasi repository — detail yang disembunyikan di balik kontrak bersama, persis semangat dependency inversion yang kita pelajari di awal section.

Repository sebagai Koleksi di Memori #

Bentuk paling murni dari pola ini adalah class dengan API menyerupai koleksi: add() untuk memasukkan, remove() untuk mengeluarkan, find() untuk mengambil satu anggota. Mari kita wujudkan repository tugas yang menyimpan datanya pada array PHP biasa:

php
<?php

final class Tugas
{
    private string $status = 'todo';

    public function __construct(
        private readonly int $id,
        private readonly string $judul,
    ) {
    }

    public function id(): int
    {
        return $this->id;
    }

    public function judul(): string
    {
        return $this->judul;
    }

    public function status(): string
    {
        return $this->status;
    }

    public function tandaiSelesai(): void
    {
        $this->status = 'selesai';
    }
}

final class InMemoryTugasRepository
{
    /** @var array<int, Tugas> */
    private array $tugas = [];

    public function add(Tugas $tugas): void
    {
        $this->tugas[$tugas->id()] = $tugas;
    }

    public function remove(int $id): void
    {
        unset($this->tugas[$id]);
    }

    public function find(int $id): ?Tugas
    {
        return $this->tugas[$id] ?? null;
    }

    /** @return list<Tugas> */
    public function semua(): array
    {
        return array_values($this->tugas);
    }
}

$repo = new InMemoryTugasRepository();
$repo->add(new Tugas(1, 'Belajar PHP OOP'));
$repo->add(new Tugas(2, 'Menulis dokumentasi API'));
$repo->add(new Tugas(3, 'Deploy ke produksi'));

$tugas = $repo->find(2);
$tugas->tandaiSelesai();

$repo->remove(3);

foreach ($repo->semua() as $t) {
    echo "#{$t->id()} [{$t->status()}] {$t->judul()}\n";
}

Output yang Dihasilkan:

text
#1 [todo] Belajar PHP OOP
#2 [selesai] Menulis dokumentasi API

Ada perilaku penting yang tersimpan dalam kode di atas:

  1. Properti $tugas memetakan ID ke objek Tugas — array PHP bekerja sebagai “tabel” dalam versi paling sederhananya, dengan kunci array berperan sebagai primary key.
  2. find() mengembalikan objek yang sama dengan yang kita masukkan lewat add(), bukan salinannya. Itulah sebabnya $tugas->tandaiSelesai() di baris pemanggilan langsung terlihat pada hasil semua() — status tugas #2 tercetak selesai meski tidak ada method “update” yang dipanggil. Objek di PHP hidup by reference, dan repository in-memory memanfaatkannya secara alami.
  3. remove() dan find() menerjemahkan keberadaan data sebagai kontrak yang jujur: yang tidak ada menghasilkan null, bukan false atau exception.
  4. Kode pemanggil tidak memuat satu pun detail penyimpanan — tidak ada foreach atas array mentah, tidak ada SQL, hanya bahasa domain: tambah, cari, hapus, daftar.

Query Method yang Mengungkap Niat #

Repository yang baik tidak berhenti di operasi koleksi; method query-nya menamai pertanyaan domain secara eksplisit. Bandingkan dua gaya kontrak berikut: findBy(['status' => 'todo']) yang generik, atau yangBelumSelesai() yang menyebut maksudnya. Gaya kedua membuat kode pemanggil terbaca seperti kalimat, dan menjadikan daftar pertanyaan domain terlihat dalam satu interface:

php
<?php

final class Tugas
{
    private string $status = 'todo';

    public function __construct(
        private readonly int $id,
        private readonly string $judul,
    ) {
    }

    public function id(): int
    {
        return $this->id;
    }

    public function judul(): string
    {
        return $this->judul;
    }

    public function status(): string
    {
        return $this->status;
    }

    public function tandaiSelesai(): void
    {
        $this->status = 'selesai';
    }
}

interface TugasRepository
{
    /** @return list<Tugas> */
    public function yangBelumSelesai(): array;

    public function jumlahSelesai(): int;
}

final class InMemoryTugasRepository implements TugasRepository
{
    /** @var array<int, Tugas> */
    private array $tugas = [];

    public function add(Tugas $tugas): void
    {
        $this->tugas[$tugas->id()] = $tugas;
    }

    public function find(int $id): ?Tugas
    {
        return $this->tugas[$id] ?? null;
    }

    public function yangBelumSelesai(): array
    {
        return array_values(array_filter(
            $this->tugas,
            fn (Tugas $t) => $t->status() === 'todo'
        ));
    }

    public function jumlahSelesai(): int
    {
        return count($this->tugas) - count($this->yangBelumSelesai());
    }
}

$repo = new InMemoryTugasRepository();
$repo->add(new Tugas(1, 'Belajar PHP OOP'));
$repo->add(new Tugas(2, 'Menulis dokumentasi API'));
$repo->add(new Tugas(3, 'Migrasi ke PHP 8.4'));

$repo->find(2)->tandaiSelesai();

echo $repo->jumlahSelesai(), " selesai, ",
    count($repo->yangBelumSelesai()), " menunggu\n";

foreach ($repo->yangBelumSelesai() as $tugas) {
    echo "- {$tugas->judul()}\n";
}

Output yang Dihasilkan:

text
1 selesai, 2 menunggu
- Belajar PHP OOP
- Migrasi ke PHP 8.4

Interface TugasRepository kini berisi dua pertanyaan domain — mana yang belum selesai, dan berapa yang sudah — sedangkan InMemoryTugasRepository hanyalah satu jawaban yang mungkin. Implementasi berbasis SQL (yang kita bangun di section database ) akan menerjemahkan pertanyaan yang sama menjadi klausa WHERE dan COUNT tanpa mengubah satu baris pun di pemanggil. Perjanjian method per pertanyaan ini juga menjaga repository tetap ramping: setiap kebutuhan query baru menjadi method baru bernama jelas, bukan parameter tambahan pada satu method raksasa.

Pertanyaan domainMethod repositoryWujud pada implementasi SQL
Tugas mana yang belum selesai?yangBelumSelesai()WHERE status = 'todo'
Berapa yang sudah selesai?jumlahSelesai()SELECT COUNT(*) ... WHERE status = 'selesai'
Ada tidaknya tugas tertentufind(int $id): ?TugasWHERE id = ?

Aggregate Root: Satu Pintu per Agregat #

Teori repository tidak berhenti di “satu class per entitas”. Dalam domain-driven design, sekumpulan objek yang selalu disimpan dan dimuat sebagai satu kesatuan disebut aggregate, dan hanya pintu masuknya — aggregate root — yang berhak memiliki repository. Anggota agregat tidak pernah diakses langsung dari luar; anggota hidup dan mati bersama root-nya:

php
<?php

final class ItemPesanan
{
    public function __construct(
        private readonly string $produk,
        private readonly int $kuantitas,
    ) {
    }

    public function label(): string
    {
        return "{$this->kuantitas}x {$this->produk}";
    }
}

final class Pesanan
{
    /** @var list<ItemPesanan> */
    private array $item = [];

    private function __construct(private readonly int $id)
    {
    }

    public static function buat(int $id): self
    {
        return new self($id);
    }

    public function id(): int
    {
        return $this->id;
    }

    public function tambahItem(string $produk, int $kuantitas): void
    {
        $this->item[] = new ItemPesanan($produk, $kuantitas);
    }

    public function totalItem(): int
    {
        return count($this->item);
    }

    public function ringkas(): string
    {
        $baris = array_map(fn (ItemPesanan $item) => $item->label(), $this->item);

        return "Pesanan #{$this->id}: " . implode(', ', $baris);
    }
}

final class InMemoryPesananRepository
{
    /** @var array<int, Pesanan> */
    private array $pesanan = [];

    public function simpan(Pesanan $pesanan): void
    {
        $this->pesanan[$pesanan->id()] = $pesanan;
    }

    public function cari(int $id): ?Pesanan
    {
        return $this->pesanan[$id] ?? null;
    }
}

$repo = new InMemoryPesananRepository();

$pesanan = Pesanan::buat(101);
$pesanan->tambahItem('Kopi Arabika 250g', 2);
$pesanan->tambahItem('Gula Aren Cair', 1);

$repo->simpan($pesanan);

$disimpan = $repo->cari(101);
echo $disimpan->ringkas(), "\n";
echo "total item: ", $disimpan->totalItem(), "\n";

Output yang Dihasilkan:

text
Pesanan #101: 2x Kopi Arabika 250g, 1x Gula Aren Cair
total item: 2

Perhatikan tiga keputusan desain pada agregat di atas. Pertama, constructor Pesanan dibuat private dengan named constructor buat() — teknik factory yang memaksa seluruh kelahiran pesanan melewati satu gerbang. Kedua, ItemPesanan tidak punya repository sendiri; item hanya bisa dilahirkan lewat tambahItem() dan hanya bisa dibaca lewat objek Pesanan yang menaunginya. Ketiga, simpan() menyimpan keseluruhan agregat sekaligus — memuat kembali lewat cari() mengembalikan pesanan beserta seluruh itemnya, dalam kondisi yang konsisten. Batas agregat sekaligus menjadi batas konsistensi: aturan seperti “total item tidak boleh nol” cukup dijaga di satu tempat, yaitu di dalam root.

AspekAggregate rootAnggota agregat
Repository milik siapapunya sendiritidak punya — diakses lewat root
Identitasglobal, punya ID sendirilokal, hanya bermakna di dalam root
Contoh dalam kode di atasPesananItemPesanan
Aturan konsistensi dijaga didalam rootoleh root yang menaunginya

Fondasi Pengujian: Implementasi In-Memory #

Manfaat paling nyata dari kontrak repository baru terasa saat menulis test. Karena implementasi in-memory hanya bermain dengan array PHP, logika bisnis yang bergantung padanya bisa diuji secepat eksekusi biasa — tanpa berkas database, tanpa server, tanpa penyiapan data. Mari kita uji sebuah service kecil dengan assertion bergaya PHPUnit :

php
<?php

final class Tugas
{
    private string $status = 'todo';

    public function __construct(
        private readonly int $id,
        private readonly string $judul,
    ) {
    }

    public function id(): int
    {
        return $this->id;
    }

    public function status(): string
    {
        return $this->status;
    }

    public function tandaiSelesai(): void
    {
        $this->status = 'selesai';
    }
}

interface TugasRepository
{
    public function add(Tugas $tugas): void;

    public function find(int $id): ?Tugas;

    public function jumlahSelesai(): int;
}

final class InMemoryTugasRepository implements TugasRepository
{
    /** @var array<int, Tugas> */
    private array $tugas = [];

    public function add(Tugas $tugas): void
    {
        $this->tugas[$tugas->id()] = $tugas;
    }

    public function find(int $id): ?Tugas
    {
        return $this->tugas[$id] ?? null;
    }

    public function jumlahSelesai(): int
    {
        $selesai = array_filter(
            $this->tugas,
            fn (Tugas $t) => $t->status() === 'selesai'
        );

        return count($selesai);
    }
}

final class LayananTugas
{
    public function __construct(private TugasRepository $repo)
    {
    }

    public function selesaikan(int $id): string
    {
        $tugas = $this->repo->find($id);

        if ($tugas === null) {
            return "tugas #$id tidak ditemukan";
        }

        $tugas->tandaiSelesai();

        return "tugas #{$tugas->id()} kini selesai";
    }
}

function cek(bool $kondisi, string $pesan): void
{
    echo $kondisi ? "OK    $pesan\n" : "GAGAL $pesan\n";
}

$repo = new InMemoryTugasRepository();
$repo->add(new Tugas(1, 'Belajar repository pattern'));

$layanan = new LayananTugas($repo);

cek($layanan->selesaikan(1) === 'tugas #1 kini selesai', 'tugas yang ada bisa diselesaikan');
cek($layanan->selesaikan(99) === 'tugas #99 tidak ditemukan', 'tugas yang tidak ada ditolak');
cek($repo->jumlahSelesai() === 1, 'perubahan tersimpan lewat repository');

Output yang Dihasilkan:

text
OK    tugas yang ada bisa diselesaikan
OK    tugas yang tidak ada ditolak
OK    perubahan tersimpan lewat repository

Tiga baris OK pada output menegaskan alur lengkapnya: LayananTugas hanya mengenal interface TugasRepository, berinteraksi lewat method find(), dan perubahan status terekam di repository tanpa satu pun panggilan SQL. Pada test suite sesungguhnya, tiga panggilan cek() akan menjadi tiga test method PHPUnit dengan assertion bawaan; strategi lengkapnya dibahas di Section Testing dan Quality Assurance . Implementasi SQL tetap diuji juga — cukup sekali, di tempat terpisah — karena itulah satu-satunya tempat yang tahu SQL ada.

Repository vs DAO #

Dalam percakapan sehari-hari, istilah DAO (Data Access Object) sering dipakai bergantian dengan repository, padahal keduanya menjanjikan kontrak yang berbeda. DAO lahir dari tradisi yang lebih tua dan berorientasi mekanisme akses data — satu class per tabel dengan method teknis seperti insertRow() atau findByStatus(). Repository mengikat dirinya pada bahasa domain dan kesatuan agregat, bukan pada tabel. Tabel berikut merangkum perbedaan yang paling terasa dalam praktik:

AspekDAORepository
Unit kerjasatu tabel, satu querysatu aggregate root utuh
Kosakata methodteknis: insert, update, findBybahasa domain: yangBelumSelesai, simpan
Nilai kembalianbaris, array mentah, atau DTOobjek domain
Kepedulian pada SQLdiperbolehkan bocor ke kesadaran pemanggildisembunyikan sebagai detail implementasi
Akar teoripola klasik Java EEPoEAA dan domain-driven design

Di proyek nyata kedua gaya sering bercampur — repository yang di dalamnya memanggil DAO kecil bukanlah dosa. Yang penting adalah kejujuran kontrak: begitu kita menamai sesuatu PesananRepository, pemanggil berhak mengasumsikan bahasa domain dan agregat utuh, bukan kebocoran PDOStatement atau baris mentah.

Ringkasan #

Repository Pattern memposisikan logika domain seolah bekerja di atas koleksi objek di memori: API-nya menyerupai add/remove/find, method query-nya menamai pertanyaan domain, dan satu repository hanya melayani satu aggregate root. Implementasi in-memory menjadikan pola ini fondasi pengujian yang cepat, sementara implementasi SQL tinggal menukar jawaban di balik kontrak yang sama. Satu pertanyaan masih menggantung: bagaimana ketika perubahan pada banyak repository harus terjadi bersama-sama dalam satu transaksi? Jawabnya adalah pola penyempurna repository — Unit of Work — yang kita bahas pada chapter berikutnya.

Referensi #