Repository Pattern — Design Patterns

Repository Pattern menyediakan abstraksi antara business logic dan data access. Kode bisnis tidak peduli apakah data datang dari database, API, cache, atau…

Repository Pattern menaruh semua cara mengambil dan menyimpan data di satu object (disebut repository). Kode bisnis cukup minta "kiriman yang sedang transit" atau "simpan paket ini", tanpa tahu datanya ada di memori, di server lewat API, atau di database. Bayangin petugas gudang Kurir Kancil. Kamu sebut nomor resinya, dan petugas yang tahu paketnya ada di rak depan, di gudang belakang, atau harus diminta dari cabang lain. Kamu gak perlu tahu letak raknya. Tapi kalau kamu minta "keluarkan semua paket, nanti saya pilih sendiri", petugasnya akan mengangkut seisi gudang ke meja.

🧪 Tebakan cepat: halaman admin Kurir Kancil menulis repo.all().filter((p) => p.status === "transit"). Di test, repo-nya in-memory dengan 6 kiriman, dan test-nya hijau. Di produksi, repo-nya mengambil data dari API, dan server menyimpan 5.000 kiriman, 40 di antaranya transit. Berapa baris data yang diunduh halaman admin untuk menampilkan 40 paket itu? Tebak dulu, jawabannya ada di bagian "Query yang bocor keluar dari repository".

Masalahnya: tiap halaman bicara langsung ke API

Di versi pertama, tiap halaman menulis request-nya sendiri:

// admin.js
const transit = api.get("/shipments?status=transit")

// lacak.js
const parcel = api.get("/shipments/" + resi)

// aplikasi-kurir.js
api.put("/shipments/" + parcel.resi, parcel)

Format URL tersebar di banyak file. Server pindah ke /v2/parcels? Semua file diubah. Ingin mengetes laporan di admin.js? Harus ada server yang jalan, karena gak ada cara menukar sumber datanya.

Repository: satu pintu ke data kiriman

Repository mengumpulkan semua akses data kiriman di satu object dengan method yang punya nama jelas. Dua implementasi berikut punya method yang sama, jadi bisa saling ditukar:

// Untuk test: data disimpan di memori, tanpa jaringan
class InMemoryShipmentRepository {
  constructor(parcels) {
    this.rows = new Map(parcels.map((p) => [p.resi, { ...p }]))
  }
  all() {
    return [...this.rows.values()].map((p) => ({ ...p }))
  }
  findByResi(resi) {
    const p = this.rows.get(resi)
    return p ? { ...p } : null
  }
  findByStatus(status) {
    return this.all().filter((p) => p.status === status)
  }
  save(parcel) {
    this.rows.set(parcel.resi, { ...parcel })
  }
}

// Untuk aplikasi: data ada di server, diambil lewat API
class ApiShipmentRepository {
  constructor(api) {
    this.api = api
  }
  all() {
    return this.api.get("/shipments")
  }
  findByResi(resi) {
    return this.api.get("/shipments/" + resi)
  }
  findByStatus(status) {
    return this.api.get("/shipments?status=" + status) // server yang menyaring
  }
  save(parcel) {
    this.api.put("/shipments/" + parcel.resi, parcel)
  }
}

Kode bisnis yang sama, dua sumber data

const DATA = [
  { resi: "KCL-0001", status: "terkirim" },
  { resi: "KCL-0002", status: "transit" },
  { resi: "KCL-0003", status: "diantar" },
  { resi: "KCL-0004", status: "dijemput" },
  { resi: "KCL-0005", status: "transit" },
  { resi: "KCL-0006", status: "terkirim" },
]

function laporanTransit(repo) {
  const list = repo.findByStatus("transit")
  return list.length + " paket transit: " + list.map((p) => p.resi).join(", ")
}

console.log(laporanTransit(new InMemoryShipmentRepository(DATA)))   // di test
console.log(laporanTransit(new ApiShipmentRepository(createFakeApi(DATA)))) // di aplikasi
repository request ke server baris lewat jaringan hasil
InMemoryShipmentRepository 0 0 2 paket transit: KCL-0002, KCL-0005
ApiShipmentRepository 1: GET /shipments?status=transit 2 2 paket transit: KCL-0002, KCL-0005

laporanTransit gak berubah satu huruf pun. Dia cuma kenal method findByStatus. Test memakai repo in-memory (cepat, tanpa server), aplikasi memakai repo API. Itu manfaat utamanya: logika akses data terpisah dari logika bisnis, jadi sumber datanya bisa diganti tanpa mengubah kode bisnis.

Query yang bocor keluar dari repository

Halaman admin butuh paket transit, tapi penulisnya memakai all() lalu menyaring sendiri:

function laporanTransitBocor(repo) {
  const list = repo.all().filter((p) => p.status === "transit") // query ditulis di luar repository
  return list.length + " paket transit: " + list.map((p) => p.resi).join(", ")
}

Dengan repo in-memory, hasilnya sama dan gak ada request, jadi test-nya hijau. Dengan repo API, all() mengirim GET /shipments, dan server mengirim semua kiriman. Untuk 6 kiriman, 6 baris diunduh untuk menampilkan 2. Untuk 5.000 kiriman, 5.000 baris diunduh untuk menampilkan 40. Itu jawaban tebakannya. Dengan findByStatus("transit"), server yang menyaring dan cuma 40 baris yang lewat.

Penyaringan yang ditulis di luar repository disebut query yang bocor: kode bisnis jadi tahu cara mencari data, dan biayanya cuma kelihatan di implementasi yang mahal. Butuh pencarian baru, misalnya paket kilat yang transit? Tambahkan method di repository (findTransitByService("kilat")), jangan saring di pemanggil.

Repository di Laravel

Di Laravel, model Eloquent sendiri bukan repository: $shipment->save() adalah gaya Active Record, di mana object datanya tahu cara menyimpan dirinya. Repository adalah class yang kamu tulis di sekeliling Eloquent. Praktik yang umum: buat interface, buat implementasinya, lalu daftarkan pasangan itu di Service Provider.

interface ShipmentRepository
{
    public function findByStatus(string $status): Collection;
}

class EloquentShipmentRepository implements ShipmentRepository
{
    public function findByStatus(string $status): Collection
    {
        return Shipment::where('status', $status)->get();
    }
}

// di method register() milik app/Providers/AppServiceProvider.php
$this->app->bind(ShipmentRepository::class, EloquentShipmentRepository::class);

// constructor LaporanController: minta interface-nya, Laravel yang mengisi implementasinya
public function __construct(private ShipmentRepository $shipments) {}

Di test, cukup $this->app->bind(ShipmentRepository::class, InMemoryShipmentRepository::class), dan controller yang sama menerima versi in-memory.

Kapan gak perlu repository

⚠️ Jebakan yang sering ketemu

TL;DR: Repository menaruh semua akses data di satu object dengan method bernama jelas (all, findByResi, findByStatus, save), jadi kode bisnis terpisah dari sumber datanya dan sumbernya bisa diganti tanpa mengubah kode bisnis. Dua implementasi dengan interface yang sama, InMemoryShipmentRepository untuk test dan ApiShipmentRepository untuk aplikasi, membuat laporanTransit menghasilkan 2 paket transit: KCL-0002, KCL-0005 di keduanya. Penyaringan di luar repository (all().filter) adalah query yang bocor: hijau di test, 5.000 baris di produksi. Bentuk hasilnya harus sama persis (array atau Promise, salinan). Di Laravel: buat interface, implementasi Eloquent, lalu $this->app->bind(...) di Service Provider. Untuk aplikasi kecil dengan satu sumber data, query langsung sudah cukup.

Yang akan kamu pelajari