External Publication
Visit Post

How we built multi-tenant isolation in NestJS that even a junior dev can't break

DEV Community [Unofficial] June 18, 2026
Source

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

Discussion in the ATmosphere

Loading comments...