Domain-Driven Design in TypeScriptPart 5
Connect the domain model to tenant-safe persistence
Learn where DDD repositories end and infrastructure safeguards begin when enforcing tenant isolation in a shared-schema database.
DDD does not remove infrastructure concerns. The model can express that a tenant owns its configuration, but that rule still has to survive ORM queries, background jobs, support tools, and the database itself. This article connects the domain model from the earlier parts of the series to tenant-safe persistence without pretending a repository alone is a security boundary.
In our running example, a developer adds an overdue-invoices dashboard and writes
WHERE status = 'overdue'. The query works, but it also returns invoices from every customer
because nothing required a tenant_id. In a shared-schema design, every tenant’s rows live in the
same tables and a tenant column separates them, so one omitted predicate can cross a customer
boundary without producing a database error.
“Remember the tenant filter” is not a security boundary. In shared-schema SaaS, tenant isolation must hold on every read, including the ordinary query someone writes during a busy release. The safe path should apply the boundary automatically, while rare cross-tenant operations use a separate, audited interface.
This article builds that path with execution context, tenant-scoped repositories, query filters, and database safeguards. By the end, a missing filter becomes difficult to express instead of easy to overlook.
Tenant isolation belongs in infrastructure, not every service method
Open in StudioThe example
This looks harmless:
const invoices = await prisma.invoice.findMany({
where: { status: "overdue" },
});
In a shared-schema SaaS database, that query may read every tenant’s overdue invoices.
“Remember the tenant filter” is not a security boundary. The platform must make unsafe access difficult.
Failure mode: support dashboard leak
The bug usually enters through useful code:
export async function findOverdueInvoicesForDashboard() {
return prisma.invoice.findMany({
where: { status: "overdue" },
orderBy: { due_at: "asc" },
});
}
Nothing about that function looks malicious. It still returns every customer’s overdue invoices, because nothing in the call requires a tenant.
Repository as domain collection
The application layer should ask for tenant-owned aggregates through a repository:
const configuration = await tenantConfigurationRepository.get(tenantId);
It should not know storage details:
// Not in application code.
await prisma.tenantConfiguration.findFirst({
where: {
tenant_id: tenantId,
deleted_at: null,
},
});
The repository earns its abstraction here because it presents a domain-oriented operation, such as “load this tenant configuration,” while hiding how the row is selected. More importantly, it gives the application one controlled path into storage. Tenant-aware infrastructure can apply the current tenant to that path instead of asking every caller to remember the same security predicate.
That is the original intent of the repository pattern: present domain objects through a collection-like interface while the mapping layer handles storage. Martin Fowler’s Repository description is worth reading because it also makes the limit clear. A repository is most useful around a substantial domain model or complicated query logic. Wrapping every simple ORM call in a one-line method does not automatically create a better boundary.
Tenant context
The current tenant should come from authenticated execution context, not a hand-passed optional
parameter. The application creates this context only after it authenticates the request and verifies
the actor’s tenant membership. AsyncLocalStorage then carries the verified identity through the
asynchronous call chain, much like request-scoped state, without adding an optional tenantId to
every repository method:
type TenantExecutionContext = {
tenantId: TenantId;
actorId: UserId;
correlationId: CorrelationId;
};
const tenantContext = new AsyncLocalStorage<TenantExecutionContext>();
export function currentTenantId(): TenantId {
const context = tenantContext.getStore();
if (!context) throw new Error("tenant context missing");
return context.tenantId;
}
HTTP requests, jobs, and saga handlers all enter through this context, but each entry point must build it from trusted data. A job should use the tenant stored on its durable row, for example, not a tenant id copied from an unvalidated payload. If no trusted context exists, repository access fails instead of quietly becoming cross-tenant.
Global query filtering
With Prisma, a client extension can inject tenant filters:
const tenantPrisma = prisma.$extends({
query: {
$allModels: {
async findMany({ model, args, query }) {
if (tenantScopedModels.has(model)) {
args.where = {
...args.where,
tenant_id: currentTenantId(),
};
}
return query(args);
},
},
},
});
The same idea applies to TypeORM subscribers, query builders, or a custom repository layer.
This shortened example demonstrates one read operation. It is not a complete Prisma isolation layer. A production extension must cover every tenant-scoped read, update, delete, aggregate, and relation operation your application permits. Raw SQL, alternate clients, migrations, and database administration still bypass it, which is why the database safeguards below are part of the design.
The rule is simple:
Standard tenant-scoped reads and writes should be isolated automatically.
Test the guardrail
Test the infrastructure behavior directly:
it("adds the current tenant filter to tenant-scoped reads", async () => {
const parsedTenantId = tenantId("ten_ops_us");
await withTenantContext(tenantExecutionContext({ tenantId: parsedTenantId }), async () => {
await invoiceRepository.findOverdue();
});
expect(prisma.invoice.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
tenant_id: parsedTenantId,
}),
}),
);
});
Also test the failure path:
it("rejects tenant-scoped reads without tenant context", async () => {
await expect(invoiceRepository.findOverdue()).rejects.toThrow("tenant context missing");
});
The first test prevents accidental leakage. The second prevents background jobs from silently running outside a tenant boundary.
Zero-trust exceptions
Some queries are legitimately cross-tenant: usage rollups, platform operations, migration checks. Those should be explicit and rare:
await platformRepository.withCrossTenantAccess("monthly usage rollup", async () => {
return usageReportRepository.calculateGlobalUsage();
});
Make cross-tenant access noisy in code, logs, and review.
Database safeguards
Application filters are necessary but not sufficient because not every access path uses the same client. A migration, raw SQL query, administration tool, or future service can bypass the Prisma extension. Database constraints provide an independent layer that still applies when application code takes one of those paths.
Use database-level constraints where possible:
- composite indexes starting with
tenant_id - foreign keys that include
tenant_id - row-level security for supported databases
- views or stored policies for sensitive tables
- migration tests that scan for tenant-scoped tables without tenant indexes.
Example composite foreign key:
alter table tenant_users
add constraint tenant_users_tenant_fk
foreign key (tenant_id, tenant_configuration_id)
references tenant_configurations (tenant_id, id);
The schema should make a cross-tenant join hard to express accidentally.
Layered isolation
No single guardrail is enough for high-risk tenant data.
| Layer | What it prevents |
|---|---|
| Execution context | Running tenant-scoped work without a tenant |
| Repository API | Bypassing the domain collection boundary |
| Global query filtering | Forgetting tenant_id on ordinary ORM operations |
| Composite foreign keys | Joining records across tenants by accident |
| Row-level security | Database access that bypasses application code |
| Audit logs | Invisible cross-tenant reads and administrative exceptions |
For lower-risk internal tables, repository plus global filters may be enough. For invoices, identities, protected health information (PHI), financial data, or customer content, add database-level enforcement too.
No-downtime migrations
Shared-schema migrations affect every tenant. Treat them like production events:
- Add nullable column or new table.
- Backfill in tenant-sized batches.
- Dual-write from application code.
- Verify per-tenant completeness.
- Flip reads.
- Remove old column later.
Do not ship a migration that locks a hot table across thousands of tenants because the domain model wanted a new invariant.
The takeaway
Never require developers to remember tenant filters on standard queries.
Repositories express domain access, while the authenticated execution context identifies which tenant the current operation may use. Infrastructure combines the two by injecting the tenant predicate into ordinary queries, and database constraints catch invalid relationships even when a different access path bypasses that code. Each layer covers a gap left by the one above it.
Treat tenant isolation the way you treat uptime: if ordinary application code can skip it, the design is unfinished.
Principles to apply in your own work
- Express repositories in domain terms and keep storage queries behind their contracts.
- Make the scope of an operation explicit and required; do not depend on callers to remember an implicit filter.
- Put rules that must survive every code path into the persistence model with keys, constraints, or database policies.
- Layer application checks and database enforcement when a broken invariant would expose data or violate a critical business rule.
- Model exceptional or privileged access as a separate, auditable capability rather than a flag on an ordinary repository method.
- Use integration tests to prove repository scoping and persistence constraints, including attempted access across boundaries.
Repositories do more than hide an ORM. They preserve the domain’s access and consistency assumptions as objects move between memory and storage.