About a year ago, a junior dev on our team wrote a cleanup job that nuked records without a tenant filter. In staging,
thankfully — but it wiped out an entire test tenant's data and took half a day to restore. That was the wake-up call.
We run a multi-tenant NestJS + TypeORM SaaS (shared database, shared schema, tenant_id column on everything). The
classic approach is "just remember to add WHERE tenant_id = ? everywhere." Which works great right up until it
doesn't.
So we built a three-layer safety net. Sharing it because I haven't seen this exact combo written up anywhere, and it
took us a few iterations to get right.
## Layer 1: Every entity inherits tenant ownership
typescript
@Entity()
export abstract class TenantBaseEntity {
@Column()
tenantId: string;
}
Dead simple. If an entity doesn't extend this, it doesn't get created. We enforce this in code review — no exceptions.
It means the column physically exists on every table, which matters for Layer 3.
Layer 2: Tenant context lives on the request
@Injectable({ scope: Scope.REQUEST })
export class TenantService {
private tenantId: string;
constructor(@Inject(REQUEST) private request: Request) {
this.tenantId = this.request.user?.tenantId;
}
getTenantId(): string {
if (!this.tenantId) {
throw new Error('Tenant context not available — are you in a non-HTTP context?');
}
return this.tenantId;
}
}
The key addition we made after getting burned: that guard clause. If something tries to query without a tenant
context, it throws loudly instead of silently returning unscoped data. Fail closed, not open.
Layer 3: A custom repository that makes forgetting impossible
@Injectable()
export class TenantRepository<T extends TenantBaseEntity> {
constructor(
private repo: Repository<T>,
private tenantService: TenantService,
) {}
async find(options?: FindManyOptions<T>): Promise<T[]> {
return this.repo.find({
...options,
where: {
...options?.where,
tenantId: this.tenantService.getTenantId(),
} as any,
});
}
async findOne(options?: FindOneOptions<T>): Promise<T | null> {
return this.repo.findOne({
...options,
where: {
...options?.where,
tenantId: this.tenantService.getTenantId(),
} as any,
});
}
// same pattern for save, update, delete...
}
Devs inject TenantRepository<Whatever> instead of the raw TypeORM repo. The tenant filter is injected automatically on
every operation. You can't forget it because you never write it.
The edge case that bit us: background jobs
Cron tasks, BullMQ workers — anything outside an HTTP request has no request-scoped context, so TenantService blows
up. We solved this with an explicit TenantContext wrapper:
await this.tenantContext.runWithTenant(tenantId, async () => {
await this.tenantRepository.find();
});
Honest tradeoffs
Not gonna pretend this is perfect:
- Query performance — composite indexes on every table. Our DBA was not thrilled.
- Request-scoped injection — NestJS creates new instances per request. At scale, look into AsyncLocalStorage with
nestjs-cls.
- Raw queries — if someone writes raw SQL, none of this helps. We lint for query() and createQueryBuilder() in CI.
Running in production for about a year across ~40 tables. Zero cross-tenant incidents since.
What's next?
Genuinely curious — anyone gone the schema-per-tenant route with NestJS? We evaluated it early but connection pool
management seemed nightmarish at ~200 tenants. Also wondering about Postgres RLS as an alternative.
---
We packaged this pattern (along with auth, Stripe payments, RBAC, admin dashboard, and deployment configs) into a full
SaaS starter kit:
- 🔗 https://github.com/sayahweb2-png/saas-starter-lite (MIT licensed)
- 🔗 https://demo.cloudrix.io
- 🔗 https://demo.cloudrix.io/blog/nestjs-angular-authentication-jwt-oauth
Top comments (0)