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