{
  "$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"
}