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

typescript dev.to

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

Enter fullscreen mode Exit fullscreen mode

Source: dev.to

arrow_back Back to Tutorials