DEV Community

joah levien
joah levien

Posted on

How I Built Multi-Tenancy with Automatic Data Isolation in NestJS + TypeORM

Multi-tenancy is the feature that separates a SaaS side project from a real SaaS product. But most tutorials teach it wrong — they rely on middleware filters or manual WHERE clauses that developers can forget.

I built a system where tenant isolation happens at the repository level. Developers cannot accidentally query another tenant's data. Here's how.

The Problem with "Manual" Multi-Tenancy

Most multi-tenancy tutorials tell you to:

  1. Extract the tenant ID from the JWT
  2. Add a WHERE organizationId = ? clause to every query
  3. Hope nobody forgets

This is a data leak waiting to happen. One missed filter, one raw query, one eager-loaded relation without the scope — and you're serving Customer A's data to Customer B.

The Architecture: Repository-Level Isolation

Instead of filtering at the query level, I scope at the repository level. Every repository automatically filters by the current tenant. Developers use the repository normally — the isolation is invisible and mandatory.

Step 1: The Organization Entity

@Entity()
export class Organization {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column({ type: 'enum', enum: ['active', 'suspended', 'deleted'] })
  status: string;

  @OneToMany(() => OrganizationMember, member => member.organization)
  members: OrganizationMember[];
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Tenant-Aware Base Entity

Every entity that belongs to a tenant extends this:

@Entity()
export abstract class TenantAwareEntity {
  @ManyToOne(() => Organization, { nullable: false })
  @JoinColumn({ name: 'organizationId' })
  organization: Organization;

  @Column()
  organizationId: string;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Scoped Repository

The magic happens here. Instead of using TypeORM's default repository, every tenant-aware entity uses a scoped repository:

@Injectable({ scope: Scope.REQUEST })
export class TenantRepository<T extends TenantAwareEntity> {
  private organizationId: string;

  constructor(
    @Inject(REQUEST) private request: Request,
    private dataSource: DataSource,
  ) {
    // Extract org from authenticated request
    this.organizationId = request.user?.organizationId;
  }

  private get repo(): Repository<T> {
    return this.dataSource.getRepository(this.entityClass);
  }

  async find(options?: FindManyOptions<T>): Promise<T[]> {
    return this.repo.find({
      ...options,
      where: {
        ...options?.where,
        organizationId: this.organizationId, // Always scoped
      } as any,
    });
  }

  async findOne(options: FindOneOptions<T>): Promise<T | null> {
    return this.repo.findOne({
      ...options,
      where: {
        ...options?.where,
        organizationId: this.organizationId, // Always scoped
      } as any,
    });
  }

  async save(entity: DeepPartial<T>): Promise<T> {
    return this.repo.save({
      ...entity,
      organizationId: this.organizationId, // Auto-assign tenant
    } as any);
  }

  async delete(id: string): Promise<void> {
    // Only delete within the tenant scope
    await this.repo.delete({
      id,
      organizationId: this.organizationId,
    } as any);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: RBAC Integration

Four roles with clear permission boundaries:

Role Can Manage Members Can Edit Settings Can Delete Org Can View Data
Owner Yes Yes Yes Yes
Admin Yes Yes No Yes
Member No No No Yes
Viewer No No No Read-only
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (!requiredRoles) return true;

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some(role =>
      user.organizationRole === role
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in controllers:

@Roles(Role.OWNER, Role.ADMIN)
@Post('members/invite')
async inviteMember(@Body() dto: InviteMemberDto) {
  return this.orgService.inviteMember(dto);
}
Enter fullscreen mode Exit fullscreen mode

Why This Pattern Matters

  1. No accidental data leaks — the repository enforces scoping, not the developer
  2. No performance overhead — it's just a WHERE clause, same as manual filtering
  3. Clean API — controllers and services don't deal with tenant logic
  4. Audit-friendly — every action is automatically scoped and logged

This Is Part of Cloudrix

I built this multi-tenancy system as part of Cloudrix — a production-ready NestJS 11 + Angular 21 SaaS starter kit.

Cloudrix Features

Along with multi-tenancy, it includes:

  • Complete auth (OAuth, magic links, 2FA, JWT rotation)
  • Stripe payments (subscriptions, usage billing, webhooks)
  • Admin dashboard with MRR/churn analytics
  • 7 email templates via Resend
  • Docker + AWS Terraform deployment
  • 50+ API endpoints with Swagger docs
  • Audit logging with 20+ action types
  • GDPR compliance

Free MIT lite version on GitHub. Paid: $149-$399, one-time purchase.

Live demo: demo.cloudrix.io


How do you handle multi-tenancy in your NestJS apps? I'd love to hear different approaches in the comments.

Top comments (0)