Repository pattern adalah pola desain yang membungkus logika akses data di balik interface yang bersih, memisahkan detail implementasi penyimpanan data (seperti Entity Framework Core) dari logika bisnis aplikasi, sehingga keduanya bisa berubah secara independen tanpa saling memengaruhi.

Dengan repository pattern, controller atau service tidak lagi berinteraksi langsung dengan DbContext, melainkan melalui interface repository yang mendefinisikan operasi apa saja yang tersedia, seperti GetById, GetAll, Add, atau Delete, tanpa perlu tahu bagaimana operasi tersebut sebenarnya diimplementasikan di baliknya.

Artikel ini membahas cara menerapkan repository pattern dasar dengan EF Core, manfaatnya untuk testability, serta perdebatan umum tentang apakah pola ini masih relevan mengingat EF Core DbContext sendiri sudah mengimplementasikan pola repository dan unit of work secara internal.

Mendefinisikan Interface Repository

Interface mendefinisikan kontrak operasi data tanpa detail implementasi

public interface IProdukRepository { Task<Produk> GetByIdAsync(int id); Task<List<Produk>> GetAllAsync(); Task AddAsync(Produk produk); Task DeleteAsync(int id); }
Interface IProdukRepository mendefinisikan operasi apa saja yang tersedia untuk mengakses data Produk, tanpa menjelaskan sama sekali bagaimana operasi tersebut diimplementasikan, apakah lewat EF Core, raw SQL, atau bahkan API eksternal, memberikan abstraksi yang bersih bagi kode yang memakainya.

Implementasi Repository dengan EF Core

Implementasi konkret menyembunyikan detail EF Core dari pemanggil

public class ProdukRepository : IProdukRepository { private readonly TokoDbContext _context; public ProdukRepository(TokoDbContext context) => _context = context; public async Task<Produk> GetByIdAsync(int id) => await _context.Produk.FindAsync(id); public async Task<List<Produk>> GetAllAsync() => await _context.Produk.ToListAsync(); public async Task AddAsync(Produk produk) => await _context.Produk.AddAsync(produk); public async Task DeleteAsync(int id) { var produk = await GetByIdAsync(id); if (produk != null) _context.Produk.Remove(produk); } }
ProdukRepository mengimplementasikan interface dengan memanfaatkan EF Core di baliknya, namun kode yang memakai IProdukRepository tidak perlu tahu detail ini sama sekali, sehingga jika suatu saat implementasi berganti teknologi penyimpanan data, kode yang memakai interface tersebut tidak perlu diubah sama sekali.

Mendaftarkan Repository ke Dependency Injection

Controller bergantung pada abstraksi, bukan implementasi konkret

builder.Services.AddScoped<IProdukRepository, ProdukRepository>(); public class ProdukController : ControllerBase { private readonly IProdukRepository _repository; public ProdukController(IProdukRepository repository) => _repository = repository; [HttpGet("{id}")] public async Task<IActionResult> GetById(int id) => Ok(await _repository.GetByIdAsync(id)); }
Controller hanya bergantung pada interface IProdukRepository, bukan pada ProdukRepository secara langsung, mengikuti Dependency Inversion Principle yang membuat controller bisa diuji dengan mock repository tanpa perlu database sungguhan selama unit testing.

Perdebatan Relevansi Repository Pattern dengan EF Core

DbContext sendiri sudah berperilaku seperti repository

// DbContext EF Core SUDAH mengimplementasikan // Repository Pattern dan Unit of Work secara internal: db.Produk.Add(produk); // mirip repository.Add() db.SaveChanges(); // mirip unit of work.Commit()
Sebagian developer berpendapat bahwa membungkus EF Core DbContext dengan repository pattern tambahan adalah duplikasi abstraksi yang tidak perlu, karena DbSet sendiri sudah berperilaku seperti repository generic dan SaveChanges sudah mirip unit of work; perdebatan ini penting dipahami sebelum memutuskan menerapkan pola ini secara membabi buta di setiap project.

Generic Repository vs Repository Spesifik per Entity

Generic repository untuk operasi CRUD dasar, repository spesifik untuk query khusus

public interface IGenericRepository<T> where T : class { Task<T?> GetByIdAsync(int id); Task<List<T>> GetAllAsync(); Task AddAsync(T entity); } public class GenericRepository<T> : IGenericRepository<T> where T : class { private readonly AppDbContext _db; private readonly DbSet<T> _set; public GenericRepository(AppDbContext db) { _db = db; _set = db.Set<T>(); } public async Task<T?> GetByIdAsync(int id) => await _set.FindAsync(id); public async Task<List<T>> GetAllAsync() => await _set.ToListAsync(); public async Task AddAsync(T entity) => await _set.AddAsync(entity); } // Repository spesifik menambah method yang tidak masuk akal digeneralisasi public interface IOrderRepository : IGenericRepository<Order> { Task<List<Order>> GetPendingOrdersByCustomerAsync(int customerId); }

Generic repository bermanfaat untuk menghindari duplikasi kode CRUD dasar seperti GetByIdAsync, GetAllAsync, dan AddAsync yang polanya identik di hampir semua entity. Namun menjadikan generic repository sebagai satu-satunya lapisan akses data seringkali berujung pada masalah lain: query kompleks yang melibatkan filter bertingkat, join, atau agregasi dipaksa dibungkus ke dalam method generik yang tidak fleksibel, atau malah ditulis ulang di luar repository sehingga tujuan pattern ini menjadi sia-sia.

Pendekatan yang lebih realistis adalah menggabungkan generic repository untuk operasi dasar dengan repository spesifik per entity yang mewarisi interface generiknya, lalu menambahkan method seperti GetPendingOrdersByCustomerAsync yang memang hanya relevan untuk entity Order. Dengan begitu, kode tetap DRY untuk operasi umum, tetapi query bisnis yang spesifik tetap punya tempat yang jelas dan type-safe tanpa memaksakan generalisasi berlebihan.

Satu hal yang perlu diperhatikan ketika memakai Repository Pattern di atas EF Core adalah bahwa DbContext sendiri sebenarnya sudah merupakan implementasi dari Unit of Work dan Repository Pattern secara bawaan lewat DbSet. Repository tambahan di atasnya lebih berguna untuk menyembunyikan detail query LINQ dari layer business logic dan memudahkan unit testing lewat mocking interface, bukan untuk menggantikan fungsi DbContext itu sendiri.

Unit of Work: Menyatukan Beberapa Repository dalam Satu Transaksi

Satu pemanggilan SaveChangesAsync membungkus perubahan dari dua repository berbeda

public interface IUnitOfWork : IDisposable { IOrderRepository Orders { get; } IProductRepository Products { get; } Task<int> SaveChangesAsync(); } public class UnitOfWork : IUnitOfWork { private readonly AppDbContext _db; public IOrderRepository Orders { get; } public IProductRepository Products { get; } public UnitOfWork(AppDbContext db, IOrderRepository orders, IProductRepository products) { _db = db; Orders = orders; Products = products; } public Task<int> SaveChangesAsync() => _db.SaveChangesAsync(); public void Dispose() => _db.Dispose(); } // Dipakai di service: kurangi stok DAN buat order dalam satu SaveChanges await _unitOfWork.Products.ReduceStockAsync(productId, qty); await _unitOfWork.Orders.AddAsync(order); await _unitOfWork.SaveChangesAsync(); // satu transaksi, satu commit

Ketika sebuah operasi bisnis melibatkan perubahan pada lebih dari satu entity sekaligus, misalnya mengurangi stok produk dan membuat record order baru dalam satu aksi checkout, memanggil SaveChangesAsync terpisah pada tiap repository berisiko menimbulkan data tidak konsisten jika salah satu operasi gagal di tengah jalan. Pattern Unit of Work mengatasi hal ini dengan membungkus beberapa repository dalam satu instance yang berbagi DbContext yang sama. Kegagalan pada satu bagian proses, seperti stok yang ternyata tidak mencukupi, bisa dideteksi sebelum SaveChangesAsync dipanggil sama sekali.

Karena seluruh repository pada contoh di atas menerima AppDbContext yang sama persis lewat dependency injection, perubahan yang dilakukan lewat Orders dan Products sama-sama tertampung di change tracker DbContext tersebut, dan baru benar-benar dikirim ke database saat SaveChangesAsync pada UnitOfWork dipanggil satu kali. Jika terjadi error sebelum SaveChangesAsync sempat dieksekusi, seluruh perubahan di memori otomatis tidak pernah tersimpan ke database. Perilaku ini setara dengan transaksi implisit, karena seluruh perubahan dikirim dalam satu batch perintah SQL yang sama ke database.

Penting dipahami bahwa AppDbContext pada EF Core sebenarnya sudah berperilaku sebagai Unit of Work secara bawaan, sehingga membungkusnya lagi dengan interface IUnitOfWork pada project kecil terkadang dianggap lapisan abstraksi berlebih. Pola ini lebih terasa manfaatnya pada aplikasi besar dengan banyak repository yang perlu dikoordinasikan secara eksplisit, atau ketika tim ingin menyembunyikan detail DbContext sepenuhnya dari layer service demi kemudahan testing dan penggantian ORM di masa depan. Keputusan memakai pattern ini sebaiknya mempertimbangkan ukuran tim dan kompleksitas domain, bukan sekadar mengikuti contoh arsitektur dari project lain.

Menggabungkan Specification Pattern dengan Repository untuk Query Fleksibel

public class ProdukAktifSpec : ISpecification<Produk> { public Expression<Func<Produk, bool>> Criteria => p => p.IsActive && p.Stok > 0; } public async Task<List<Produk>> GetAsync(ISpecification<Produk> spec) { return await _dbSet.Where(spec.Criteria).ToListAsync(); } // Pemanggil cukup kirim spesifikasi, repository tetap satu method generik var produk = await _repo.GetAsync(new ProdukAktifSpec());

Repository generik murni sering berujung pada penambahan method baru setiap kali ada kebutuhan filter yang berbeda, misalnya GetActiveProduk, GetProdukByCategory, dan berbagai variasi lainnya yang terus bertambah. Specification pattern memindahkan logika filter tersebut ke dalam class terpisah yang bisa disusun ulang dan dipakai kembali di banyak tempat, sementara repository itu sendiri tetap hanya punya satu method generik untuk menerima dan mengeksekusinya.

Pendekatan ini memudahkan penulisan unit test karena kriteria filter bisa diuji secara terpisah tanpa perlu memanggil database sama sekali, lalu diverifikasi ulang lewat integration test yang sesungguhnya. Untuk aplikasi kecil dengan jumlah variasi query yang masih sedikit, menambahkan layer specification bisa terasa berlebihan dibanding langsung menulis method repository yang spesifik untuk setiap kebutuhan query tersebut.

Kesalahan Umum yang Sering Terjadi

Membuat Repository Generic yang Terlalu Rumit

Repository generic yang mencoba menangani semua jenis entity dengan satu implementasi seringkali berakhir dengan interface yang terlalu banyak parameter opsional atau method yang jarang dipakai, menambah kompleksitas tanpa manfaat nyata.

Menerapkan Repository Pattern Tanpa Alasan yang Jelas

Menambahkan layer repository hanya karena dianggap 'best practice' tanpa mempertimbangkan apakah project benar-benar membutuhkan abstraksi tersebut bisa menambah kompleksitas yang tidak sepadan untuk project kecil dengan kebutuhan sederhana.

Membocorkan Detail EF Core Lewat Interface Repository

Mengembalikan IQueryable langsung dari method repository membocorkan detail implementasi EF Core ke pemanggil, menghilangkan sebagian besar manfaat abstraksi yang seharusnya diberikan repository pattern itu sendiri.

Kapan Pendekatan Ini Tidak Cocok Dipakai

Repository pattern memberikan testability yang lebih baik karena logika bisnis bisa diuji dengan mock repository tanpa database sungguhan, serta memudahkan penggantian teknologi penyimpanan data di masa depan.

Namun untuk project kecil hingga menengah yang tidak berencana mengganti EF Core, layer abstraksi tambahan ini bisa dianggap overhead yang tidak perlu, mengingat DbContext EF Core sendiri sudah cukup testable lewat in-memory database provider untuk kebutuhan unit testing.

Ringkasan

Repository pattern memberikan pemisahan yang bersih antara logika bisnis dan detail akses data, meningkatkan testability aplikasi, namun penerapannya perlu dipertimbangkan matang mengingat EF Core sendiri sudah menyediakan abstraksi serupa secara internal. Pilih berdasarkan kebutuhan nyata project, bukan sekadar mengikuti pola secara membabi buta.