Repository yang Menolak Mewarisi
Mewarisi repository dasar akan menyisakan findMany tanpa scope yang tetap bisa dipanggil, dan TypeScript tidak bisa menyembunyikan method publik warisan. Menjadikan tenant id sebagai argumen pertama tiap method mengubah kebocoran data jadi compile error.

Semua repository lain di codebase ini mewarisi BaseRepository. Yang untuk tabel terikat tenant tidak, dan komentar yang menjelaskan alasannya lebih panjang daripada sebagian method-nya.
Singkatnya: mewarisi akan membuat satu kelas bug kebocoran data jadi tidak terlihat oleh compiler.
Apa yang akan ikut terbawa lewat pewarisan
BaseRepository itu pembungkus generic biasa di atas delegate Prisma — findMany, findById, updateById, deleteSoftById, lengkap. Dia tidak tahu apa-apa soal tenant, dan memang tidak perlu: sebagian besar tabel di aplikasinya tidak terikat tenant.
Langkah yang menggoda adalah mewarisinya lalu meng-override segelintir method yang butuh filter:
class OrderRepository extends BaseRepository<...> {
// the scoped version you meant to write
override findMany(tenantId: string, params?: FindManyParams) { ... }
}Ini tidak berhasil, dan gagalnya dengan cara yang paling senyap. TypeScript tidak mengizinkan subclass mempersempit sebuah method publik sampai hilang — tiap signature warisan tetap bisa dipanggil:
// Still inherited. Still public. Still compiles.
repo.findMany({ where: { status: "open" } });Compiler-nya puas. Query-nya Prisma yang sah. Bug-nya baru muncul ketika seseorang di satu tenant melihat baris milik tenant lain.
Dan itu bentuk bug yang paling buruk: sah di setiap lapisan yang seharusnya bisa menangkapnya, salah hanya di produksi.
Membuat kesalahannya tidak bisa ditulis
Jadi repository yang terikat tenant dibuat sebagai abstract class terpisah yang tidak berbagi garis keturunan sama sekali dengan yang dasar. Aturannya muat satu baris: tiap method menerima tenant id sebagai argumen pertamanya.
abstract class TenantScopedRepository<...> {
findMany(tenantId: string, params?: FindManyParams): Promise<TModel[]>
findById(tenantId: string, id: string): Promise<TModel | null>
count(tenantId: string, where?: TWhereInput): Promise<number>
create(tenantId: string, data: Omit<TCreateInput, "tenant" | "tenant_id">): Promise<TModel>
updateById(tenantId: string, id: string, data: TUpdateInput): Promise<TModel | null>
deleteSoftById(tenantId: string, id: string): Promise<boolean>
}Tidak ada findMany tanpa scope yang bisa diraih, karena memang tidak pernah ada yang diwariskan. Versi query yang lupa memfilter bukan lagi kesalahan yang bisa Anda tulis — dia tidak bisa dikompilasi.
Semua penjahitannya terjadi di satu method privat, satu-satunya tempat kolom tenant pernah ditambahkan ke klausa where:
private scopedWhere(tenantId: string, where?: TWhereInput) {
const next: any = { ...(where ?? {}), tenant_id: tenantId };
// Same soft-delete rule as the base class: an explicit choice wins.
if (this.hasSoftDelete && next.deleted_at === undefined) {
next.deleted_at = null;
}
return next;
}Di mana Prisma melawan
Men-scope pembacaan itu gampang. Men-scope penulisan adalah tempat API-nya menolak, dan alasannya sama di kedua sisi: operasi by-id Prisma menuntut where yang unik, dan tidak menerima kondisi tambahan di sebelahnya.
findUnique tidak bisa sekaligus mensyaratkan tenant. update juga tidak. Yang membuat dua implementasi paling jelas justru dua-duanya salah:
// Neither of these can carry a tenant condition.
this.delegate.findUnique({ where: { id } });
this.delegate.update({ where: { id }, data });Mengambil barisnya dulu lalu memeriksa tenant-nya kelihatan tidak berbahaya, tapi itu menjawab pertanyaan yang sebenarnya tidak berhak ditanyakan pemanggil: dia mengonfirmasi barisnya ada sebelum menolaknya. Jadi jalur bacanya lewat findFirst, tempat tenant-nya ikut bepergian bersama id:
async findById(tenantId: string, id: string) {
// findFirst takes an arbitrary where, so the tenant travels with the id.
return this.findFirst(tenantId, { where: { id } as TWhereInput });
}Jalur tulisnya punya masalah yang sama dan jawaban yang kurang kentara — updateMany, yang menerima where sembarang dan melaporkan berapa baris yang tersentuh:
async updateById(tenantId: string, id: string, data: TUpdateInput) {
const { count } = await this.delegate.updateMany({
where: this.scopedWhere(tenantId, { id } as TWhereInput),
data,
});
if (!count) return null;
return this.findById(tenantId, id);
}Nol baris berarti id-nya bukan milik tenant ini, atau sudah di-soft-delete. Apa pun itu, pemanggilnya dapat null dan tidak belajar apa-apa lagi.
Tenant-nya datang dari argumen, tidak pernah dari payload
Satu lubang lagi yang layak ditutup. Kalau create mengambil data barisnya mentah-mentah dari pemanggil, pemanggilnya bisa menetapkan sendiri kolom tenant-nya:
async create(
tenantId: string,
data: Omit<TCreateInput, "tenant" | "tenant_id">,
) {
return this.delegate.create({
data: { ...data, tenant: { connect: { id: tenantId } } },
});
}Bagian Omit itu benar-benar bekerja. Mengirim tenant lewat payload jadi type error, dan nilai yang benar-benar mendarat di barisnya datang dari argumen yang memang sudah dituntut method-nya.
Kebocoran yang bersembunyi di jumlah halaman
Ini yang paling halus. Men-scope barisnya bukan seluruh pekerjaan, karena paginasi juga menghitung — dan sebuah hitungan adalah sebuah jawaban.
Filter barisnya tapi tidak hitungannya, maka daftarnya benar sementara nomor halamannya salah. Tenant dengan tiga order melihat tiga order dan sebelas halaman. Itu perkiraan kasar volume milik orang lain, disajikan oleh API Anda sendiri.
Helper paginasinya menyusun klausa where-nya sendiri dari parameter pencarian dan pengurutan, jadi scope-nya tidak bisa begitu saja diserahkan di awal. Dia harus dipasang setelah helper-nya selesai:
paginationModel(tenantId: string) {
return {
findMany: (args) =>
this.delegate.findMany({ ...args, where: this.scopedWhere(tenantId, args?.where) }),
count: (where?) =>
this.delegate.count({ where: this.scopedWhere(tenantId, where) }),
};
}Kedua sisi pasangannya ter-scope, dan helper-nya tidak mungkin diberi delegate mentah karena kelalaian, sebab repository-nya memang tidak pernah membukanya.
Scoping bukan otorisasi
Ini perlu ditegaskan, karena kelas seperti ini gampang terlalu dipercaya. Dia menjamin tiap query membawa filter tenant. Dia tidak memutuskan apakah user ini boleh bertindak di dalam tenant tersebut.
Pertanyaan itu sudah selesai jauh sebelum repository-nya tersentuh, oleh apa pun yang menentukan tenant dari request dan memeriksa keanggotaannya. Tugas repository-nya lebih sempit dan mutlak: diberi sebuah tenant id, jangan pernah mengembalikan apa pun di luarnya.
Dua jaminan, dua lapisan. Menggabungkannya jadi satu kelas berarti tiap query memeriksa ulang izin, dan pemeriksaan izin yang tinggal di lima puluh tempat adalah pemeriksaan izin yang akan menyimpang.
Kapan bentuk ini sepadan
Tidak untuk setiap tabel multi-tenant. Kalau batasnya ditegakkan di database — row-level security, satu schema per tenant, satu koneksi per tenant — lapisan aplikasinya tidak perlu ikut memikulnya, dan menduplikasinya di sana cuma memberi Anda dua hal yang harus dijaga tetap sinkron.
Bentuk ini baru sepadan ketika filternya memang tinggal di kode aplikasi, yang di aplikasi Prisma adalah keadaan yang paling sering terjadi. Setelah itu pertanyaannya tinggal satu: apakah filternya sesuatu yang harus diingat tiap service, atau sesuatu yang dipaksakan oleh type system.
Mewarisi membuatnya jadi yang pertama. Menolak mewarisi membuatnya jadi yang kedua.


