Repository sebagai koleksi domain (abstraction over persistence), beda DAO vs repository, testability dan swap ORM — dengan interface + TypeORM/ Prisma di NestJS, interface + GORM/sqlc di Fiber, dan repository Laravel plus catatan kapan ia redundan di Active Record — praktik ganti ORM tanpa sentuh service

Episode 4 kalian membuat OrdersRepository sebagai latihan DIP. Episode ini melegitimasi pattern itu sepenuhnya: Repository adalah abstraction over persistence yang memperlakukan kumpulan objek domain seakan koleksi in-memory — dan menjadi lantai dasar hampir semua arsitektur berlapis.
Ide Martin Fowler-nya sederhana namun radikal: service bertanya "berikan saya semua order milik user X" — bukan "jalankan query JOIN dengan WHERE...". Detail SQL/ORM/ tabel hilang di balik antarmuka yang bermakna domain:
interface OrdersRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
}
// Tidak ada: QueryBuilder, raw SQL, nama kolom, include/join di sini.| Aspek | DAO | Repository |
|---|---|---|
| Perspektif | Tabel/baris database | Objek/agregat domain |
| Method | insertRow, updateRow, CRUD per-tabel | save(order), findByCustomer() |
| Return | Record mentah | Entity domain utuh |
DAO adalah lapisan teknis; repository adalah lapisan domain. Pada sistem kecil keduanya bisa menyatu; pada sistem besar repository bahkan boleh men-combine beberapa DAO untuk merekonstruksi satu agregat.
Manfaat ganda yang sudah kita raba: (1) unit test service pakai in-memory fake tanpa DB — milidetik per test; (2) keputusan ORM bisa ditunda atau diganti (Prisma → Drizzle, GORM → sqlc) tanpa menyentuh logika bisnis.
@Injectable()
export class PrismaOrdersRepository implements OrdersRepository {
constructor(private prisma: PrismaService) {}
async findById(id: string) {
const row = await this.prisma.order.findUnique({
where: { id }, include: { items: true },
});
return row ? OrderMapper.toDomain(row) : null;
}
async save(order: Order) {
await this.prisma.order.upsert(OrderMapper.toPersistence(order));
}
}
// Mapping row<->domain terjadi DI SINI - service tidak melihat bentuk tabel.Laravel memakai Active Record ($order->save() langsung di model) yang sudah menjadi abstraksi sendiri. Menambah repository di atasnya bisa jadi boilerplate murni pada aplikasi kecil. Aturan keputusannya:
BERNILAI saat:
- Domain kompleks, entity punya rekonstruksi/mapping khusus
- Butuh swap storage / unit test service tanpa DB
- Tim memakai Data Mapper (Doctrine, Prisma) - pasangan alami repository
REDUNDAN saat:
- CRUD polos, domain tipis, Active Record cukup ekspresif
- Abstraksi tidak akan pernah punya implementasi kedua (YAGNI)Target outline: ganti ORM tanpa menyentuh service — buktikan lewat mock repository test.
class InMemoryOrders implements OrdersRepository { // fake #1
private map = new Map<string, Order>();
async findById(id: string) { return this.map.get(id) ?? null; }
async findByCustomer(cid: string) { /* filter */ }
async save(o: Order) { this.map.set(o.id, o); }
}
describe('PlaceOrderService', () => {
it('membuat order dan menghitung total', async () => {
const svc = new PlaceOrderService(new InMemoryOrders(), new FixedPricing());
const order = await svc.execute({ customerId: 'c1', items: [...] });
expect(await svc.repo.findById(order.id)).not.toBeNull();
});
});Note
Kunci latihan: interface dideklarasikan di sisi service/domain, bukan di folder "interfaces" global. Repository adalah bagian dari bahasa domain, bukan utilitas infrastruktur.
Rangkuman episode ini:
Episode 10 naik satu lapis: Service Layer & pola Service-Repository — orchestrator use case, transaction boundary, dan anatomi request sampai database yang menjadi default arsitektur series ini. Sampai jumpa!