Pada chapter sebelumnya kita melihat bahwa lapisan data layak dipisahkan dari service dan presentasi. Sekarang kita bedah lapisan itu secara khusus. Pola repository menyatukan seluruh akses data sebuah domain ke dalam satu object dengan sekumpulan method — satu pintu yang menjadi tempat berlalunya data antara memori aplikasi dan penyimpanan. Manfaat besarnya sederhana namun berdampak: kode di atas repository cukup mengenal method-method tersebut, sehingga penyimpanan di baliknya boleh berganti-ganti tanpa penulisan besar-besaran.
Masalah: Query yang Menyebar #
Bayangkan aplikasi toko dengan belasan rute. Tanpa repository, string SQL atau akses Map tersebar di mana-mana: rute daftar produk menulis SELECT, rute detail menulis SELECT versi lainnya, dan service diskon menulis SELECT versi ketiga. Ketika skema tabel berubah — kolom harga dipecah menjadi harga_beli dan harga_jual — kita harus menelusuri seluruh codebase untuk menemukan setiap tempat yang membaca kolom tersebut. Setiap yang terlewat menjadi bug yang baru muncul saat baris kode itu dijalankan.
Masalah yang sama berlaku untuk pengujian. Service yang memanggil query langsung tidak bisa diuji tanpa penyimpanan sungguhan, padahal kita hanya ingin menguji aturan bisnis. Query yang menyebar juga membuat inkonsistensi mudah masuk: satu tempat memakai parameter terikat, tempat lain merangkai string — celah yang kita bahas tuntas di chapter SQL Injection pada Section Security.
Satu Pintu Menuju Data #
Repository menyelesaikan ketiganya dengan abstraksi : sembunyikan cara data disimpan, tampilkan hanya operasi yang dibutuhkan domain. Kontrak repository untuk domain produk kita terlihat seperti ini:
| Method | Tugas | Hasil |
|---|---|---|
simpan(produk) | Menyimpan produk baru | Object produk lengkap dengan id |
semua() | Mengambil seluruh produk | Array of object produk |
cariNama(nama) | Mencari satu produk berdasarkan nama | Object produk atau null |
hapus(id) | Menghapus produk berdasarkan id | true bila ada, false bila tidak |
Kontrak ini adalah kesepakatan dua arah. Service sebagai konsumen berhak mengandalkan bentuk hasil tiap method; repository sebagai penyedia wajib menepatinya apa pun media simpan yang dipakai. Selama kesepakatan terjaga, konsumen tidak perlu tahu bahwa di baliknya berdiri Map, SQLite, PostgreSQL, atau panggilan HTTP ke layanan lain.
Demo: Dua Repository, Satu Kontrak #
Demo berikut membangun dua repository untuk domain yang sama: buatRepoMemori dengan Map dan buatRepoSqlite dengan node:sqlite
— modul yang sudah kita kenal di chapter SQLite dengan node:sqlite. Fungsi jalankanSkenario adalah konsumen: fungsi tersebut hanya mengenal kontrak, sama sekali tidak tahu repository mana yang sedang dipegangnya.
import { DatabaseSync } from 'node:sqlite';
function buatRepoMemori() {
const produk = new Map();
let idBerikutnya = 1;
return {
simpan(baru) {
const lengkap = { id: idBerikutnya++, ...baru };
produk.set(lengkap.id, lengkap);
return lengkap;
},
semua() { return [...produk.values()]; },
cariNama(nama) {
return [...produk.values()].find((p) => p.nama === nama) ?? null;
},
hapus(id) { return produk.delete(id); },
};
}
function buatRepoSqlite() {
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE produk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
harga INTEGER NOT NULL
)`);
const stmtSimpan = db.prepare('INSERT INTO produk (nama, harga) VALUES (?, ?)');
const stmtSemua = db.prepare('SELECT id, nama, harga FROM produk ORDER BY id');
const stmtCariNama = db.prepare('SELECT id, nama, harga FROM produk WHERE nama = ?');
const stmtHapus = db.prepare('DELETE FROM produk WHERE id = ?');
return {
simpan(baru) {
const hasil = stmtSimpan.run(baru.nama, baru.harga);
return { id: Number(hasil.lastInsertRowid), ...baru };
},
semua() { return stmtSemua.all().map((baris) => ({ ...baris })); },
cariNama(nama) { return stmtCariNama.get(nama) ?? null; },
hapus(id) { return stmtHapus.run(id).changes === 1; },
tutup() { db.close(); },
};
}
function jalankanSkenario(repo, label) {
console.log(`--- skenario dengan ${label} ---`);
repo.simpan({ nama: 'Kopi Gayo', harga: 85000 });
repo.simpan({ nama: 'Teh Melati', harga: 45000 });
repo.simpan({ nama: 'Gula Aren', harga: 30000 });
console.log('cari Teh Melati :', JSON.stringify(repo.cariNama('Teh Melati')));
console.log('cari Kopi Toraja:', JSON.stringify(repo.cariNama('Kopi Toraja')));
repo.hapus(2);
console.log('setelah hapus 2 :', JSON.stringify(repo.semua()));
}
const repoMemori = buatRepoMemori();
jalankanSkenario(repoMemori, 'repo memori');
const repoSqlite = buatRepoSqlite();
jalankanSkenario(repoSqlite, 'repo sqlite');
repoSqlite.tutup();Output yang Dihasilkan:
--- skenario dengan repo memori ---
cari Teh Melati : {"id":2,"nama":"Teh Melati","harga":45000}
cari Kopi Toraja: null
setelah hapus 2 : [{"id":1,"nama":"Kopi Gayo","harga":85000},{"id":3,"nama":"Gula Aren","harga":30000}]
--- skenario dengan repo sqlite ---
cari Teh Melati : {"id":2,"nama":"Teh Melati","harga":45000}
cari Kopi Toraja: null
setelah hapus 2 : [{"id":1,"nama":"Kopi Gayo","harga":85000},{"id":3,"nama":"Gula Aren","harga":30000}]Perhatikan bahwa kedua skenario menghasilkan baris yang identik — satu-satunya perbedaan hanya label. Mari kita bedah bagiannya:
- Baris 3-18 (
buatRepoMemori): Repository pertama menyimpan produk dalamMapprivat dengan penghitung id sendiri. Seluruh detail ini tidak bocor ke luar; yang tampil hanya empat method kontrak.cariNamamemakai?? nullagar konsumen selalu menerimanull— bukanundefined— saat produk tidak ditemukan. - Baris 20-41 (
buatRepoSqlite): Repository kedua menjawab kontrak yang sama lewat SQLite. Empat prepared statement disiapkan sekali di awal lalu dipakai berulang — praktik yang juga membuat parameter terikat menjadi kebiasaan, bukan tambahan. Methodtutuphadir karena koneksi database adalah sumber daya yang memang perlu ditutup. - Baris 34 (
Number(hasil.lastInsertRowid)):simpanmengembalikan id darilastInsertRowid. Nilai ini dikonversi eksplisit denganNumber()supaya bentuk hasil selalu angka biasa, terlepas dari tipe yang dikembalikan driver. - Baris 36 (
semua): Baris hasilstmtSemua.all()disebar ke object baru lewatmap((baris) => ({ ...baris })). Baris hasilnode:sqlitebukan object JavaScript biasa — prototype-nyanull— sehingga konversi di batas repository menjaga konsumen menerima bentuk domain yang bersih dan seragam dengan repo memori. - Baris 43-54 (
jalankanSkenario): Konsumen menerimareposebagai parameter dan hanya memanggil method kontrak. Tidak adaifyang membedakan memori dari SQLite, tidak ada SQL, tidak adaMap. Inilah sebabnya fungsi yang sama bekerja untuk kedua repository. - Baris 56-61 (perakitan): Skenario berjalan berurutan — memori lebih dulu, lalu SQLite, ditutup dengan
tutup()agar koneksi:memory:tidak menggantung. Database memakai:memory:sehingga skenario selalu mulai dari keadaan kosong dan tidak meninggalkan berkas apa pun di disk.
Menjaga Kontrak Tetap Murni #
Kontrak hanya melindungi kita selama batasnya bersih. Tiga godaan paling umum justru datang dari kebiasaan yang terasa praktis saat itu:
Ada dua keputusan kecil yang menjaga kemurnian kontrak. Pertama, nama method mengikuti bahasa domain — cariNama, simpan, hapus — bukan bahasa teknologi seperti executeSelect. Kedua, kontrak sengaja dijaga kecil: empat method sudah mencakup kebutuhan konsumen demo. Method yang belum dibutuhkan tidak ditulis, karena setiap method adalah janji yang harus ditepati oleh semua implementasi repository.
Menuju Penyambungan yang Rapi #
Kita kini punya dua implementasi repository dengan satu kontrak, tetapi satu pertanyaan belum terjawab: bagaimana service mendapatkan repository yang tepat? Service bisa mengimpor satu repository global, tetapi pilihan itu menutup pintu pertukaran yang baru saja kita bangun. Jawaban yang benar adalah menyuntikkan repository dari luar — konsep dependency injection, chapter berikutnya.