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:
- Extract the tenant ID from the JWT
- Add a
WHERE organizationId = ?clause to every query - 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[];
}
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;
}
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);
}
}
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
);
}
}
Usage in controllers:
@Roles(Role.OWNER, Role.ADMIN)
@Post('members/invite')
async inviteMember(@Body() dto: InviteMemberDto) {
return this.orgService.inviteMember(dto);
}
Why This Pattern Matters
- No accidental data leaks — the repository enforces scoping, not the developer
- No performance overhead — it's just a WHERE clause, same as manual filtering
- Clean API — controllers and services don't deal with tenant logic
- 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.
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)