NestJS Multi-Tenancy with TypeORM: Architecture & Implementation
Multi-Tenancy Is Not Optional for SaaS
I once shipped a SaaS MVP without proper tenant isolation. We used a simple userId column and thought it was fine. Two months after launch, a customer reported seeing another company's data in their dashboard. It turned out one query in our reporting module was missing the tenant filter. We fixed it in 20 minutes, but the trust damage took months to repair. That bug cost us three enterprise contracts.
If you are building a SaaS product, multi-tenancy is your first architectural decision. Get it wrong and you are refactoring every query, every migration, every deployment pipeline. Get it right and tenants scale invisibly.
This guide covers the three multi-tenancy strategies available with TypeORM and PostgreSQL, then walks through a complete row-level isolation implementation in NestJS — the pattern that works for 90% of SaaS products. If you are new to NestJS, start with our NestJS REST API tutorial first.
The Three Strategies
1. Row-Level Isolation (Shared Database, Shared Schema)
Every tenant's data lives in the same tables. A tenantId column on every entity separates the data. Queries always filter by tenant.
Pros: Simple migrations, lowest infrastructure cost, easiest to implement. Cons: Risk of data leaks if a query misses the tenant filter. Noisy-neighbor performance.
Best for: Most SaaS products, especially early-stage.
2. Schema-Level Isolation (Shared Database, Separate Schemas)
Each tenant gets their own PostgreSQL schema (tenant_abc.users, tenant_xyz.users). The application switches schemas per request.
Pros: Stronger isolation, per-tenant migrations possible. Cons: Schema sprawl, complex connection management, harder to query across tenants.
Best for: Regulated industries where data isolation audits matter.
3. Database-Level Isolation (Separate Databases)
Each tenant gets their own database instance. Complete physical isolation.
Pros: Maximum isolation, independent scaling, per-tenant backups. Cons: Expensive, operationally complex, connection pool management is painful.
Best for: Enterprise customers who contractually require dedicated infrastructure.
Cloudrix SaaS Starter ships with this pre-configured and tested.
Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.
Try the live demo →Implementing Row-Level Isolation in NestJS
Row-level isolation is the right default. Here is how to implement it properly so you never accidentally leak tenant data.
Step 1: The Tenant Entity Base Class
Every tenant-scoped entity extends a base class:
// base-tenant.entity.ts
@Entity()
export abstract class BaseTenantEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
@Index()
tenantId: string;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'tenantId' })
tenant: Organization;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
Every entity that belongs to a tenant extends this:
@Entity()
export class Project extends BaseTenantEntity {
@Column()
name: string;
@Column({ type: 'text', nullable: true })
description: string;
}
Step 2: Extracting the Tenant from the Request
Use NestJS middleware to resolve the tenant on every request. The tenant comes from the authenticated user's JWT:
// tenant.middleware.ts
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const user = req.user as AuthenticatedUser;
if (user?.organizationId) {
req['tenantId'] = user.organizationId;
}
next();
}
}
Make the tenant ID available via a request-scoped provider:
// tenant.provider.ts
export const TENANT_ID = 'TENANT_ID';
@Injectable({ scope: Scope.REQUEST })
export class TenantProvider {
constructor(@Inject(REQUEST) private request: Request) {}
get tenantId(): string {
const id = this.request['tenantId'];
if (!id) throw new UnauthorizedException('Tenant context required');
return id;
}
}
Step 3: Tenant-Aware Repository Pattern
This is the critical piece. Wrap TypeORM repositories so every query is automatically scoped to the current tenant. This pattern pairs directly with the authentication system that provides the tenant context from the JWT token:
// tenant-aware.repository.ts
@Injectable({ scope: Scope.REQUEST })
export class TenantAwareRepository<T extends BaseTenantEntity> {
private repo: Repository<T>;
constructor(
private dataSource: DataSource,
private tenantProvider: TenantProvider,
private entityClass: EntityTarget<T>,
) {
this.repo = this.dataSource.getRepository(entityClass);
}
private get tenantId(): string {
return this.tenantProvider.tenantId;
}
async find(options?: FindManyOptions<T>): Promise<T[]> {
return this.repo.find({
...options,
where: { ...options?.where, tenantId: this.tenantId } as any,
});
}
async findOne(options: FindOneOptions<T>): Promise<T | null> {
return this.repo.findOne({
...options,
where: { ...options.where, tenantId: this.tenantId } as any,
});
}
async save(entity: DeepPartial<T>): Promise<T> {
const withTenant = { ...entity, tenantId: this.tenantId } as DeepPartial<T>;
return this.repo.save(withTenant);
}
async delete(id: string): Promise<void> {
// Always scope deletes to prevent cross-tenant data removal
const entity = await this.findOne({ where: { id } as any });
if (!entity) throw new NotFoundException();
await this.repo.remove(entity);
}
}
Step 4: Using Tenant-Aware Repositories in Services
Your service code never thinks about tenant IDs — the repository handles it:
@Injectable()
export class ProjectService {
constructor(
@Inject(PROJECT_REPOSITORY)
private projectRepo: TenantAwareRepository<Project>,
) {}
async getAll(): Promise<Project[]> {
// Automatically filtered to current tenant
return this.projectRepo.find({ order: { createdAt: 'DESC' } });
}
async create(dto: CreateProjectDto): Promise<Project> {
// tenantId automatically injected
return this.projectRepo.save({
name: dto.name,
description: dto.description,
});
}
}
Want multi-tenancy pre-built and tested? SaaS Starter includes a complete row-level isolation implementation with organization switching, tenant-aware repositories, and the Angular UI -- plus Stripe billing and authentication. 55+ tests included. Try the free lite version →
Step 5: Organization Switching
Users can belong to multiple organizations. Switching tenants means issuing a new JWT with a different organizationId:
@Injectable()
export class OrganizationSwitchService {
constructor(
private jwtService: JwtService,
private membershipRepo: Repository<OrganizationMember>,
) {}
async switchOrganization(userId: string, targetOrgId: string): Promise<string> {
// Verify membership
const membership = await this.membershipRepo.findOne({
where: { userId, organizationId: targetOrgId },
});
if (!membership) throw new ForbiddenException('Not a member of this organization');
// Issue new token with updated org context
return this.jwtService.sign({
sub: userId,
organizationId: targetOrgId,
role: membership.role,
});
}
}
Step 6: TypeORM Subscribers for Automatic Tenant Assignment
Add a safety net — a TypeORM subscriber that ensures tenantId is always set before insert:
@EventSubscriber()
export class TenantSubscriber implements EntitySubscriberInterface<BaseTenantEntity> {
listenTo() {
return BaseTenantEntity;
}
beforeInsert(event: InsertEvent<BaseTenantEntity>) {
if (!event.entity.tenantId) {
throw new Error('Attempted to insert entity without tenantId — this is a bug');
}
}
}
Step 7: PostgreSQL Row-Level Security (Defense in Depth)
For maximum safety, add database-level enforcement. Even if your application code has a bug, the database will block cross-tenant access:
-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see rows matching their tenant
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Set the tenant context on each database connection:
async onModuleInit() {
this.dataSource.driver.afterConnect.push(async (connection) => {
const tenantId = this.tenantProvider.tenantId;
await connection.query(`SET app.tenant_id = '${tenantId}'`);
});
}
Testing Multi-Tenant Isolation
Write explicit tests that verify tenant boundaries:
describe('Tenant Isolation', () => {
it('should not return data from another tenant', async () => {
// Create project in tenant A
await projectService.create({ name: 'Secret Project' }, tenantA);
// Query as tenant B
const results = await projectService.getAll(tenantB);
expect(results).toHaveLength(0);
});
it('should not allow cross-tenant deletion', async () => {
const project = await projectService.create({ name: 'Protected' }, tenantA);
await expect(
projectService.delete(project.id, tenantB),
).rejects.toThrow(NotFoundException);
});
});
Common Multi-Tenancy Pitfalls
- Forgetting the filter on raw queries: If you use
queryBuilderor raw SQL, you must manually add the tenant filter. The repository wrapper does not help here. - Background jobs losing tenant context: Queued jobs execute outside the request scope. Always pass
tenantIdin the job payload and restore context explicitly. - Admin endpoints leaking data: Your internal admin panel may need cross-tenant access. Use a separate guard that bypasses tenant scoping for admin routes only.
- Migrations breaking isolation: Schema changes apply to all tenants in row-level isolation. Test migrations with production-like multi-tenant data.
SaaS Starter Ships Multi-Tenancy Out of the Box
Building multi-tenancy from scratch takes weeks of careful implementation and testing. SaaS Starter includes a complete, battle-tested multi-tenancy implementation with row-level isolation, organization switching, tenant-aware repositories, and the Angular admin dashboard for managing organizations.
The free Lite version includes the core tenant architecture so you can evaluate the pattern in a working application.
Explore the live demo — create multiple organizations, switch between them, and see how data isolation works in practice. Then clone the repo and ship your multi-tenant SaaS in days instead of months.
Pricing:
- Lite (Free) — Core tenant architecture included
- Pro ($149) — Auth + billing + dashboard
- Business ($249) — Full multi-tenancy + RBAC + advanced features
- Enterprise ($399) — Everything + priority support
View pricing | Try the live demo | Get the free lite version
Related posts: Stripe Subscriptions guide | NestJS + Angular vs Next.js | Deploy to AWS with Terraform