Logika bisnis tidak seharusnya tahu di mana datanya tidur: di map, di berkas JSON, atau di PostgreSQL. Setiap detail storage yang bocor ke layanan — nama berkas, format kolom, pesan error driver — menjadi beban saat penyimpanan diganti atau diuji. Repository pattern memotong kebocoran itu dengan satu interface: layanan hanya melihat method Save dan Find, implementasi yang memegang sisanya. Chapter ini membangun repository generik Repository[T] dengan dua implementasi nyata — map dalam memori dan berkas JSON — lalu menukar keduanya tanpa menyentuh kode layanan.
Kontrak: Repository[T] Generik #
Satu interface untuk semua entitas menjadi mungkin berkat type parameter. Entitas apa pun memakai kontrak yang sama dan hanya berbeda instansiasi:
type Repository[T any] interface {
Save(id string, entitas T) error
Find(id string) (T, error)
Delete(id string) error
}Sentinel error ErrNotFound dideklarasikan berdampingan dengan interface dan ikut menjadi bagian kontrak. Saat entitas tidak ditemukan, repository mengembalikan nilai nol T beserta error terbungkus %w, sehingga pemanggil cukup memeriksa errors.Is(err, ErrNotFound) tanpa mengenal storage di belakangnya.
Dua Implementasi, Satu Layanan #
Implementasi pertama, memoryRepo[T], menyimpan map map[string]T yang dijaga sync.Mutex — sederhana dan cocok sebagai pengganti saat pengujian. Implementasi kedua, fileRepo[T], membaca berkas JSON ke map, mengubahnya, lalu menulis kembali dengan json.MarshalIndent. Berkas yang belum ada dianggap map kosong, bukan error, karena repository baru memang sah belum punya data.
Kedua implementasi dibangun lewat constructor generik. Layanan LayananPengguna hanya menyimpan satu field bertipe Repository[Pengguna] — tidak ada import os maupun encoding/json. Detail seperti izin berkas 0o600 dan nama file sementara hidup sepenuhnya di dalam implementasi.
Demo: Memory dan File JSON #
Berkas repo-demo.go pada bagian bawah halaman menjalankan layanan yang sama dengan dua repository, lalu menampilkan isi berkas JSON yang dihasilkan. Kode lengkapnya:
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
// ErrNotFound adalah error domain: entitas dengan ID tersebut tidak ada.
// Pemanggil memeriksa error ini dengan errors.Is, bukan mengenali storage.
var ErrNotFound = errors.New("entitas tidak ditemukan")
// Repository adalah kontrak penyimpanan generik.
// Type parameter T membuat satu interface dipakai untuk semua entitas.
type Repository[T any] interface {
Save(id string, entitas T) error
Find(id string) (T, error)
Delete(id string) error
}
// memoryRepo menyimpan entitas di map dalam memori.
type memoryRepo[T any] struct {
mu sync.Mutex
data map[string]T
}
func NewMemoryRepo[T any]() *memoryRepo[T] {
return &memoryRepo[T]{data: map[string]T{}}
}
func (r *memoryRepo[T]) Save(id string, entitas T) error {
r.mu.Lock()
defer r.mu.Unlock()
r.data[id] = entitas
return nil
}
func (r *memoryRepo[T]) Find(id string) (T, error) {
r.mu.Lock()
defer r.mu.Unlock()
v, ok := r.data[id]
if !ok {
var zero T
return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
}
return v, nil
}
func (r *memoryRepo[T]) Delete(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.data[id]; !ok {
return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
}
delete(r.data, id)
return nil
}
// fileRepo menyimpan seluruh map sebagai satu berkas JSON.
type fileRepo[T any] struct {
nama string
mu sync.Mutex
}
func NewFileRepo[T any](nama string) *fileRepo[T] {
return &fileRepo[T]{nama: nama}
}
// muat membaca isi berkas; berkas yang belum ada dianggap kosong.
func (r *fileRepo[T]) muat() (map[string]T, error) {
isi, err := os.ReadFile(r.nama)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return map[string]T{}, nil
}
return nil, err
}
var data map[string]T
if err := json.Unmarshal(isi, &data); err != nil {
return nil, err
}
return data, nil
}
func (r *fileRepo[T]) simpan(data map[string]T) error {
isi, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(r.nama, isi, 0o600)
}
func (r *fileRepo[T]) Save(id string, entitas T) error {
r.mu.Lock()
defer r.mu.Unlock()
data, err := r.muat()
if err != nil {
return err
}
data[id] = entitas
return r.simpan(data)
}
func (r *fileRepo[T]) Find(id string) (T, error) {
r.mu.Lock()
defer r.mu.Unlock()
data, err := r.muat()
if err != nil {
var zero T
return zero, err
}
v, ok := data[id]
if !ok {
var zero T
return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
}
return v, nil
}
func (r *fileRepo[T]) Delete(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
data, err := r.muat()
if err != nil {
return err
}
if _, ok := data[id]; !ok {
return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
}
delete(data, id)
return r.simpan(data)
}
// Pengguna adalah struct domain murni, tidak mengimport storage.
type Pengguna struct {
Nama string
Email string
}
// LayananPengguna hanya mengenal interface Repository.
// Implementasi bisa ditukar tanpa menyentuh baris pun di sini.
type LayananPengguna struct {
repo Repository[Pengguna]
}
func NewLayananPengguna(repo Repository[Pengguna]) *LayananPengguna {
return &LayananPengguna{repo: repo}
}
func (l *LayananPengguna) Daftar(id, nama, email string) error {
return l.repo.Save(id, Pengguna{Nama: nama, Email: email})
}
func (l *LayananPengguna) Cari(id string) (Pengguna, error) {
return l.repo.Find(id)
}
func main() {
// Implementasi pertama: in-memory.
mem := NewLayananPengguna(NewMemoryRepo[Pengguna]())
_ = mem.Daftar("u1", "Budi", "[email protected]")
_ = mem.Daftar("u2", "Siti", "[email protected]")
p, err := mem.Cari("u1")
if err != nil {
fmt.Println("error:", err)
}
fmt.Printf("memory : u1 = %s <%s>\n", p.Nama, p.Email)
_, err = mem.Cari("u9")
fmt.Println("memory : cari u9 ->", err)
fmt.Println("memory : errors.Is(ErrNotFound) =", errors.Is(err, ErrNotFound))
// Implementasi kedua: file JSON, layanan yang sama tanpa perubahan.
berkas := filepath.Join(os.TempDir(), "repo-demo-pengguna.json")
os.Remove(berkas) // mulai dari kondisi bersih agar demo dapat diulang
file := NewLayananPengguna(NewFileRepo[Pengguna](berkas))
_ = file.Daftar("u1", "Budi", "[email protected]")
q, err := file.Cari("u1")
if err != nil {
fmt.Println("error:", err)
}
fmt.Printf("file : u1 = %s <%s>\n", q.Nama, q.Email)
isi, err := os.ReadFile(berkas)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("file : isi %s:\n%s\n", filepath.Base(berkas), string(isi))
}Output yang Dihasilkan:
memory : u1 = Budi <[email protected]>
memory : cari u9 -> cari "u9": entitas tidak ditemukan
memory : errors.Is(ErrNotFound) = true
file : u1 = Budi <[email protected]>
file : isi repo-demo-pengguna.json:
{
"u1": {
"Nama": "Budi",
"Email": "[email protected]"
}
}Mari kita telaah baris demi baris program di atas:
- Baris 14 (
ErrNotFound): sentinel error domain yang dideklarasikan berdampingan dengan kontrak. - Baris 18–22 (
Repository[T any]): kontrak generik;Tdiinstansiasi per entitas, tanpa interface terpisah untuk tiap jenis data. - Baris 25–35 (
memoryRepo): map plussync.Mutex; seluruh method mengunci sebelum menyentuh map. - Baris 41–49 (
Findmemory): entitas yang tidak ada mengembalikan nilai nolTdan error terbungkus%wberisi ID. - Baris 73–94 (
muat,simpan):os.ErrNotExistditerjemahkan menjadi map kosong; penulisan memakaiMarshalIndentdan izin0o600. - Baris 149–159 (
LayananPengguna): layanan hanya mengenalRepository[Pengguna]— titik yang membuat penukaran implementasi bebas efek samping. - Baris 163 dan 181 (dua perakitan): layanan pertama memakai
NewMemoryRepo, keduaNewFileRepo— kode layanan sama sekali tidak berubah. - Baris 178–179 (berkas demo): path sementara dihapus dulu agar tiap eksekusi mulai dari kondisi bersih.
Error Domain dan Pemetaan #
Repository adalah tempat terjemahan error. Dunia storage berbicara bahasa masing-masing: berkas hilang melontarkan os.ErrNotExist, database melontarkan sql.ErrNoRows, JSON rusak melontarkan error sintaks. Di dalam repository semua itu dipetakan ke kosakata domain: entitas ada atau tidak ada. Layanan cukup menangani ErrNotFound — misalnya menerjemahkannya menjadi respons 404 — tanpa mengimport os maupun database/sql.
Batasan Generics pada Method #
Type parameter T pada Repository[T] melekat pada tipe, dan di situlah batasnya: method tidak boleh membawa type parameter sendiri. Deklarasi func (r *memoryRepo[T]) AmbilSemua[U any](f func(T) U) []U ditolak kompiler dengan pesan method must have no type parameters. Jalan keluarnya: fungsi bebas di level package boleh membawa type parameter sendiri:
// Fungsi bebas boleh membawa type parameter — method tidak.
func AmbilSemua[T, U any](r Repository[T], konversi func(T) U) []UPola campuran ini — tipe generik menyimpan data, fungsi generik mengoperasikannya — adalah idiom lazim di standard library. Batasannya dibahas di chapter Generic Type dan Keterbatasan Method Generik .
Versi SQL dari pola ini — pemetaan sql.ErrNoRows, connection pooling, dan fake untuk test database — dibahas di chapter Repository Pattern pada Database
. Lalu muncul pertanyaan alami: jika semua komponen bergantung pada interface, siapa yang menyambungkannya? Dependency Injection Pattern
menjawab dengan satu tempat perakitan.
Referensi #
- Dokumentasi package encoding/json
—
MarshaldanUnmarshalpada implementasi berkas. - Dokumentasi package errors
— sentinel error dan
errors.Is. - Effective Go — pemisahan kontrak dan implementasi lewat interface.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
repo-demo.go
Unduh berkaspackage main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
// ErrNotFound adalah error domain: entitas dengan ID tersebut tidak ada.
// Pemanggil memeriksa error ini dengan errors.Is, bukan mengenali storage.
var ErrNotFound = errors.New("entitas tidak ditemukan")
// Repository adalah kontrak penyimpanan generik.
// Type parameter T membuat satu interface dipakai untuk semua entitas.
type Repository[T any] interface {
Save(id string, entitas T) error
Find(id string) (T, error)
Delete(id string) error
}
// memoryRepo menyimpan entitas di map dalam memori.
type memoryRepo[T any] struct {
mu sync.Mutex
data map[string]T
}
func NewMemoryRepo[T any]() *memoryRepo[T] {
return &memoryRepo[T]{data: map[string]T{}}
}
func (r *memoryRepo[T]) Save(id string, entitas T) error {
r.mu.Lock()
defer r.mu.Unlock()
r.data[id] = entitas
return nil
}
func (r *memoryRepo[T]) Find(id string) (T, error) {
r.mu.Lock()
defer r.mu.Unlock()
v, ok := r.data[id]
if !ok {
var zero T
return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
}
return v, nil
}
func (r *memoryRepo[T]) Delete(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.data[id]; !ok {
return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
}
delete(r.data, id)
return nil
}
// fileRepo menyimpan seluruh map sebagai satu berkas JSON.
type fileRepo[T any] struct {
nama string
mu sync.Mutex
}
func NewFileRepo[T any](nama string) *fileRepo[T] {
return &fileRepo[T]{nama: nama}
}
// muat membaca isi berkas; berkas yang belum ada dianggap kosong.
func (r *fileRepo[T]) muat() (map[string]T, error) {
isi, err := os.ReadFile(r.nama)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return map[string]T{}, nil
}
return nil, err
}
var data map[string]T
if err := json.Unmarshal(isi, &data); err != nil {
return nil, err
}
return data, nil
}
func (r *fileRepo[T]) simpan(data map[string]T) error {
isi, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(r.nama, isi, 0o600)
}
func (r *fileRepo[T]) Save(id string, entitas T) error {
r.mu.Lock()
defer r.mu.Unlock()
data, err := r.muat()
if err != nil {
return err
}
data[id] = entitas
return r.simpan(data)
}
func (r *fileRepo[T]) Find(id string) (T, error) {
r.mu.Lock()
defer r.mu.Unlock()
data, err := r.muat()
if err != nil {
var zero T
return zero, err
}
v, ok := data[id]
if !ok {
var zero T
return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
}
return v, nil
}
func (r *fileRepo[T]) Delete(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
data, err := r.muat()
if err != nil {
return err
}
if _, ok := data[id]; !ok {
return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
}
delete(data, id)
return r.simpan(data)
}
// Pengguna adalah struct domain murni, tidak mengimport storage.
type Pengguna struct {
Nama string
Email string
}
// LayananPengguna hanya mengenal interface Repository.
// Implementasi bisa ditukar tanpa menyentuh baris pun di sini.
type LayananPengguna struct {
repo Repository[Pengguna]
}
func NewLayananPengguna(repo Repository[Pengguna]) *LayananPengguna {
return &LayananPengguna{repo: repo}
}
func (l *LayananPengguna) Daftar(id, nama, email string) error {
return l.repo.Save(id, Pengguna{Nama: nama, Email: email})
}
func (l *LayananPengguna) Cari(id string) (Pengguna, error) {
return l.repo.Find(id)
}
func main() {
// Implementasi pertama: in-memory.
mem := NewLayananPengguna(NewMemoryRepo[Pengguna]())
_ = mem.Daftar("u1", "Budi", "[email protected]")
_ = mem.Daftar("u2", "Siti", "[email protected]")
p, err := mem.Cari("u1")
if err != nil {
fmt.Println("error:", err)
}
fmt.Printf("memory : u1 = %s <%s>\n", p.Nama, p.Email)
_, err = mem.Cari("u9")
fmt.Println("memory : cari u9 ->", err)
fmt.Println("memory : errors.Is(ErrNotFound) =", errors.Is(err, ErrNotFound))
// Implementasi kedua: file JSON, layanan yang sama tanpa perubahan.
berkas := filepath.Join(os.TempDir(), "repo-demo-pengguna.json")
os.Remove(berkas) // mulai dari kondisi bersih agar demo dapat diulang
file := NewLayananPengguna(NewFileRepo[Pengguna](berkas))
_ = file.Daftar("u1", "Budi", "[email protected]")
q, err := file.Cari("u1")
if err != nil {
fmt.Println("error:", err)
}
fmt.Printf("file : u1 = %s <%s>\n", q.Nama, q.Email)
isi, err := os.ReadFile(berkas)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("file : isi %s:\n%s\n", filepath.Base(berkas), string(isi))
}