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:

MethodTugasHasil
simpan(produk)Menyimpan produk baruObject produk lengkap dengan id
semua()Mengambil seluruh produkArray of object produk
cariNama(nama)Mencari satu produk berdasarkan namaObject produk atau null
hapus(id)Menghapus produk berdasarkan idtrue 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.

js
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:

text
--- 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:

  1. Baris 3-18 (buatRepoMemori): Repository pertama menyimpan produk dalam Map privat dengan penghitung id sendiri. Seluruh detail ini tidak bocor ke luar; yang tampil hanya empat method kontrak. cariNama memakai ?? null agar konsumen selalu menerima null — bukan undefined — saat produk tidak ditemukan.
  2. 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. Method tutup hadir karena koneksi database adalah sumber daya yang memang perlu ditutup.
  3. Baris 34 (Number(hasil.lastInsertRowid)): simpan mengembalikan id dari lastInsertRowid. Nilai ini dikonversi eksplisit dengan Number() supaya bentuk hasil selalu angka biasa, terlepas dari tipe yang dikembalikan driver.
  4. Baris 36 (semua): Baris hasil stmtSemua.all() disebar ke object baru lewat map((baris) => ({ ...baris })). Baris hasil node:sqlite bukan object JavaScript biasa — prototype-nya null — sehingga konversi di batas repository menjaga konsumen menerima bentuk domain yang bersih dan seragam dengan repo memori.
  5. Baris 43-54 (jalankanSkenario): Konsumen menerima repo sebagai parameter dan hanya memanggil method kontrak. Tidak ada if yang membedakan memori dari SQLite, tidak ada SQL, tidak ada Map. Inilah sebabnya fungsi yang sama bekerja untuk kedua repository.
  6. 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.

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.