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)
}
}
- Kedua class punya
all,findByResi,findByStatus, dansave, dengan argumen dan bentuk hasil yang sama. Itu yang disebut interface repository: daftar method yang dijanjikan. new Map(...)menyimpan kiriman dengan resi sebagai kunci, jadithis.rows.get("KCL-0002")langsung menemukannya.{ ...p }membuat salinan. Data yang lewat API juga selalu salinan, jadi versi in-memory harus berperilaku sama: mengubah hasilfindByResigak boleh mengubah data sebelumsavedipanggil.- URL cuma ditulis di
ApiShipmentRepository. Server pindah ke/v2/parcels? Satu class yang diubah. - Di aplikasi beneran,
api.getmemanggilfetch, jadi hasilnya Promise: semua method di kedua repository ditulisasyncdan dipanggil denganawait, yang in-memory juga, supaya bentuknya tetap sama. Di lesson iniapiadalah API palsu yang sinkron dan mencetak tiap request, supaya hasilnya langsung terlihat di console.
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
- Satu sumber data, satu atau dua halaman, dan gak ada yang perlu ditukar. Memanggil Eloquent atau
api.getlangsung masih jelas dibaca. Banyak aplikasi Laravel kecil memakai Eloquent langsung di controller, dan itu wajar. - Repository yang cuma meneruskan semuanya. Method
where(kolom, nilai)yang diteruskan apa adanya ke database gak menyembunyikan apa-apa. Pemanggilnya tetap menulis query sendiri, cuma lewat satu lapisan tambahan.
⚠️ Jebakan yang sering ketemu
- Query yang bocor.
repo.all().filter(...)lolos di test in-memory, tapi di produksi mengunduh 5.000 baris untuk menampilkan 40. Taruh pencariannya di method repository. - Interface yang cuma sama namanya. Repo in-memory mengembalikan array, repo API mengembalikan Promise.
laporanTransityang lulus di test lalu berhenti di produksi denganTypeError: list.map is not a function. Bentuk hasilnya harus sama, termasukasync. - Repo in-memory yang mengembalikan object aslinya. Kode yang lupa memanggil
save()tetap terlihat benar di test, karena object di memori sudah berubah. Di produksi, perubahannya gak pernah sampai ke server. Kembalikan salinan. - URL atau query di luar repository. Satu
api.get("/shipments...")di halaman lain berarti halaman itu gak ikut saat repository ditukar.
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.