The Repository That Refuses to Inherit
Extending the base repository would have kept an unscoped findMany callable, and TypeScript cannot hide an inherited public method. Making the tenant id the first argument of every method turns a data leak into a compile error.

Every other repository in this codebase extends BaseRepository. The one for tenant-scoped tables does not, and the comment explaining why is longer than some of its methods.
The short version: inheriting would have made a whole class of data-leak bug invisible to the compiler.
What inheritance would have carried in
BaseRepository is the usual generic wrapper over a Prisma delegate — findMany, findById, updateById, deleteSoftById, the lot. It knows nothing about tenants, and it should not: most tables in the app are not tenant-scoped.
The tempting move is to extend it and override the handful of methods that need a filter:
class OrderRepository extends BaseRepository<...> {
// the scoped version you meant to write
override findMany(tenantId: string, params?: FindManyParams) { ... }
}This does not work, and it fails in the quietest way available. TypeScript will not let a subclass narrow a public method out of existence — every inherited signature stays callable:
// Still inherited. Still public. Still compiles.
repo.findMany({ where: { status: "open" } });The compiler is satisfied. The query is valid Prisma. The bug only surfaces when somebody in one tenant sees another tenant's rows.
That is the worst shape a bug can take: legal at every layer that could have caught it, wrong only in production.
Making the mistake unwriteable
So the tenant-scoped repository is a separate abstract class that shares no ancestry with the base one. Its rule fits on one line: every method takes the tenant id as its first argument.
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>
}There is no unscoped findMany to reach for, because none was ever inherited. The version of the query that forgets to filter is not a mistake you can write — it does not compile.
All the stitching happens in one private method, the only place the tenant column is ever added to a where clause:
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;
}Where Prisma pushes back
Scoping reads is easy. Scoping writes is where the API resists, and the reason is the same in both places: Prisma's by-id operations demand a unique where, and will not accept an extra condition beside it.
findUnique cannot also require a tenant. Neither can update. Which makes both of the obvious implementations wrong:
// Neither of these can carry a tenant condition.
this.delegate.findUnique({ where: { id } });
this.delegate.update({ where: { id }, data });Fetching the row and then checking its tenant looks harmless, but it answers a question the caller was not entitled to ask: it confirms the row exists before refusing it. So the read path goes through findFirst, where the tenant travels with the 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 });
}The write path has the same problem and a less obvious answer — updateMany, which accepts an arbitrary where and reports how many rows it touched:
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);
}Zero rows means the id was not in this tenant, or was soft-deleted. Either way the caller gets null and learns nothing further.
The tenant comes from the argument, never the payload
One more hole worth closing. If create takes its row data straight from the caller, the caller can set the tenant column themselves:
async create(
tenantId: string,
data: Omit<TCreateInput, "tenant" | "tenant_id">,
) {
return this.delegate.create({
data: { ...data, tenant: { connect: { id: tenantId } } },
});
}The Omit is doing real work here. Passing a tenant in the payload becomes a type error, and the value that actually lands in the row comes from the argument the method already demanded.
The leak hiding in the page count
This is the subtle one. Scoping the rows is not the whole job, because pagination also counts — and a count is an answer.
Filter the rows but not the count and the list is right while the page numbers are wrong. A tenant with three orders sees three orders and eleven pages. That is a rough estimate of somebody else's volume, served by your own API.
The pagination helper builds its own where clause out of the search and sort parameters, so the scoping cannot simply be handed over up front. It has to be applied after the helper is done:
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) }),
};
}Both halves of the pair are scoped, and the helper cannot be handed the raw delegate by accident, because the repository never exposes it.
Scoping is not authorization
Worth being precise about, because a class like this is easy to over-trust. It guarantees that every query carries a tenant filter. It does not decide whether this user may act inside that tenant at all.
That question is settled before the repository is ever reached, by whatever resolves the tenant from the request and checks membership. The repository's job is narrower and absolute: given a tenant id, never return anything outside it.
Two guarantees, two layers. Collapsing them into one class would mean every query re-checks permissions, and a permission check that lives in fifty places is a permission check that drifts.
When this shape is worth it
Not for every multi-tenant table. If the boundary is enforced in the database — row-level security, a schema per tenant, a connection per tenant — then the application layer does not need to carry it as well, and duplicating it there just gives you two things to keep in sync.
This shape earns its keep when the filter lives in application code, which in a Prisma app is most of the time. Then the only real question is whether the filter is something each service has to remember, or something the type system insists on.
Inheriting made it the first. Refusing to inherit made it the second.


