Multitenant_saas
Chapter 49: Multi-Tenant SaaS Project
Section titled “Chapter 49: Multi-Tenant SaaS Project”Building a Multi-Tenant SaaS Application with Tenant Isolation, Billing, and Roles
Section titled “Building a Multi-Tenant SaaS Application with Tenant Isolation, Billing, and Roles”49.1 Project Overview
Section titled “49.1 Project Overview”This project demonstrates a multi-tenant SaaS application with complete tenant isolation, subscription billing, and role-based access control.
Multi-Tenant SaaS Architecture ================================================================================
+-------------------+ +-------------------+ +-------------------+ | Tenants | | Users | | Subscriptions | +-------------------+ +-------------------+ +-------------------+ | id | | id | | id | | name |<--->| tenantId |<--->| tenantId | | slug | | email | | planId | | domain | | password | | status | | planId | | role | | currentPeriodEnd | | isActive | | isActive | +-------------------+ +-------------------+ +-------------------+ | | | | | v v | +-------------------+ +-------------------+ | | UserProfiles | | Plans | | +-------------------+ +-------------------+ | | id | | id | | | userId | | name | | | firstName | | price | | | lastName | | features | | +-------------------+ +-------------------+ | v +-------------------+ +-------------------+ +-------------------+ | TenantSettings | | Invoices | | Roles | +-------------------+ +-------------------+ +-------------------+ | id | | id | | id | | tenantId | | tenantId | | tenantId | | key | | amount | | name | | value | | status | | permissions | +-------------------+ | paidAt | +-------------------+ +-------------------+
================================================================================49.2 Multi-Tenancy Strategies
Section titled “49.2 Multi-Tenancy Strategies” Multi-Tenancy Strategies ================================================================================
Strategy 1: Shared Database, Shared Schema (Row-Level Isolation) +------------------------------------------------------------------+ | Database | | +------------------------------------------------------------+ | | | Shared Schema | | | | +--------+ +--------+ +--------+ +--------+ | | | | | Tenant | | Tenant | | Tenant | | Tenant | | | | | | A | | B | | C | | D | | | | | +--------+ +--------+ +--------+ +--------+ | | | +------------------------------------------------------------+ | +------------------------------------------------------------------+ Pros: Cost-effective, easy maintenance Cons: Requires careful isolation, shared resources
Strategy 2: Shared Database, Separate Schemas +------------------------------------------------------------------+ | Database | | +------------+ +------------+ +------------+ +------------+ | | | Schema A | | Schema B | | Schema C | | Schema D | | | | Tenant A | | Tenant B | | Tenant C | | Tenant D | | | +------------+ +------------+ +------------+ +------------+ | +------------------------------------------------------------------+ Pros: Better isolation, flexible per-tenant customization Cons: More complex migrations, higher overhead
Strategy 3: Separate Databases +------------------------------------------------------------------+ | +------------+ +------------+ +------------+ +------------+ | | | Database A | | Database B | | Database C | | Database D | | | | Tenant A | | Tenant B | | Tenant C | | Tenant D | | | +------------+ +------------+ +------------+ +------------+ | +------------------------------------------------------------------+ Pros: Maximum isolation, independent scaling Cons: Higher cost, complex management
================================================================================49.3 Entity Definitions
Section titled “49.3 Entity Definitions”Tenant Entity
Section titled “Tenant Entity”import { Entity, PrimaryGeneratedColumn, Column, OneToMany, OneToOne, ManyToOne, CreateDateColumn, UpdateDateColumn, Index,} from 'typeorm';import { User } from '../users/user.entity';import { Subscription } from '../subscriptions/subscription.entity';import { TenantSetting } from './tenant-setting.entity';import { Plan } from '../plans/plan.entity';
@Entity('tenants')@Index(['slug'], { unique: true })@Index(['domain'], { unique: true })export class Tenant { @PrimaryGeneratedColumn() id: number;
@Column({ length: 100 }) name: string;
@Column({ unique: true, length: 50 }) slug: string;
@Column({ unique: true, length: 100, nullable: true }) domain: string;
@Column({ nullable: true }) logo: string;
@Column({ type: 'text', nullable: true }) address: string;
@Column({ length: 50, nullable: true }) timezone: string;
@Column({ length: 10, default: 'en' }) locale: string;
@Column({ default: true }) isActive: boolean;
@Column({ default: false }) isTrial: boolean;
@Column({ type: 'timestamp', nullable: true }) trialEndsAt: Date;
@Column({ nullable: true }) planId: number;
@ManyToOne(() => Plan) plan: Plan;
@OneToMany(() => User, (user) => user.tenant) users: User[];
@OneToOne(() => Subscription, (subscription) => subscription.tenant) subscription: Subscription;
@OneToMany(() => TenantSetting, (setting) => setting.tenant) settings: TenantSetting[];
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;}User Entity with Tenant
Section titled “User Entity with Tenant”import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToOne, CreateDateColumn, UpdateDateColumn, Index,} from 'typeorm';import { Tenant } from '../tenants/tenant.entity';import { UserProfile } from './user-profile.entity';
export enum UserRole { OWNER = 'owner', ADMIN = 'admin', MANAGER = 'manager', MEMBER = 'member', GUEST = 'guest',}
@Entity('users')@Index(['tenantId', 'email'], { unique: true })@Index(['tenantId', 'isActive'])export class User { @PrimaryGeneratedColumn() id: number;
@Column() tenantId: number;
@Column() email: string;
@Column({ select: false }) password: string;
@Column({ type: 'enum', enum: UserRole, default: UserRole.MEMBER }) role: UserRole;
@Column({ default: true }) isActive: boolean;
@Column({ default: false }) emailVerified: boolean;
@Column({ type: 'timestamp', nullable: true }) lastLoginAt: Date;
@ManyToOne(() => Tenant, (tenant) => tenant.users) tenant: Tenant;
@OneToOne(() => UserProfile, (profile) => profile.user) profile: UserProfile;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;}Plan Entity
Section titled “Plan Entity”import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn,} from 'typeorm';import { Subscription } from '../subscriptions/subscription.entity';
export enum PlanInterval { MONTHLY = 'monthly', YEARLY = 'yearly',}
@Entity('plans')export class Plan { @PrimaryGeneratedColumn() id: number;
@Column({ length: 50 }) name: string;
@Column({ unique: true, length: 50 }) slug: string;
@Column({ type: 'text', nullable: true }) description: string;
@Column({ type: 'decimal', precision: 10, scale: 2 }) price: number;
@Column({ type: 'enum', enum: PlanInterval }) interval: PlanInterval;
@Column({ type: 'int' }) maxUsers: number;
@Column({ type: 'int' }) maxStorage: number; // in MB
@Column({ type: 'jsonb', default: {} }) features: Record<string, boolean | number | string>;
@Column({ default: true }) isActive: boolean;
@Column({ default: false }) isPopular: boolean;
@OneToMany(() => Subscription, (subscription) => subscription.plan) subscriptions: Subscription[];
@CreateDateColumn() createdAt: Date;}Subscription Entity
Section titled “Subscription Entity”import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn, Index,} from 'typeorm';import { Tenant } from '../tenants/tenant.entity';import { Plan } from '../plans/plan.entity';import { Invoice } from '../invoices/invoice.entity';
export enum SubscriptionStatus { TRIALING = 'trialing', ACTIVE = 'active', PAST_DUE = 'past_due', CANCELLED = 'cancelled', EXPIRED = 'expired',}
@Entity('subscriptions')@Index(['tenantId'])@Index(['status'])export class Subscription { @PrimaryGeneratedColumn() id: number;
@Column() tenantId: number;
@Column() planId: number;
@Column({ type: 'enum', enum: SubscriptionStatus, default: SubscriptionStatus.ACTIVE }) status: SubscriptionStatus;
@Column({ type: 'timestamp' }) currentPeriodStart: Date;
@Column({ type: 'timestamp' }) currentPeriodEnd: Date;
@Column({ type: 'timestamp', nullable: true }) cancelledAt: Date;
@Column({ type: 'timestamp', nullable: true }) endsAt: Date;
@Column({ nullable: true }) stripeSubscriptionId: string;
@Column({ nullable: true }) stripeCustomerId: string;
@ManyToOne(() => Tenant, (tenant) => tenant.subscription) tenant: Tenant;
@ManyToOne(() => Plan, (plan) => plan.subscriptions) plan: Plan;
@OneToMany(() => Invoice, (invoice) => invoice.subscription) invoices: Invoice[];
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;}49.4 Tenant Context Service
Section titled “49.4 Tenant Context Service”import { Injectable, Scope } from '@nestjs/common';import { Tenant } from './tenant.entity';
@Injectable({ scope: Scope.REQUEST })export class TenantContextService { private tenant: Tenant | null = null;
setTenant(tenant: Tenant): void { this.tenant = tenant; }
getTenant(): Tenant | null { return this.tenant; }
getTenantId(): number | null { return this.tenant?.id || null; }
clear(): void { this.tenant = null; }}49.5 Tenant Middleware
Section titled “49.5 Tenant Middleware”import { Injectable, NestMiddleware, NotFoundException,} from '@nestjs/common';import { Request, Response, NextFunction } from 'express';import { InjectRepository } from '@nestjs/typeorm';import { Repository } from 'typeorm';import { Tenant } from './tenant.entity';import { TenantContextService } from './tenant-context.service';
@Injectable()export class TenantMiddleware implements NestMiddleware { constructor( @InjectRepository(Tenant) private tenantRepository: Repository<Tenant>, private tenantContextService: TenantContextService, ) {}
async use(req: Request, res: Response, next: NextFunction) { // Extract tenant from subdomain or header const tenantSlug = this.extractTenant(req);
if (tenantSlug) { const tenant = await this.tenantRepository.findOne({ where: { slug: tenantSlug, isActive: true }, });
if (!tenant) { throw new NotFoundException('Tenant not found'); }
this.tenantContextService.setTenant(tenant); }
next(); }
private extractTenant(req: Request): string | null { // Method 1: From subdomain (tenant.example.com) const host = req.headers.host; if (host) { const subdomain = host.split('.')[0]; if (subdomain && subdomain !== 'www' && subdomain !== 'api') { return subdomain; } }
// Method 2: From custom header const tenantHeader = req.headers['x-tenant-id']; if (tenantHeader) { return tenantHeader as string; }
// Method 3: From query parameter if (req.query.tenant) { return req.query.tenant as string; }
return null; }}49.6 Tenant-Aware Repository
Section titled “49.6 Tenant-Aware Repository”import { Repository, SelectQueryBuilder, ObjectLiteral } from 'typeorm';import { TenantContextService } from '../tenants/tenant-context.service';
export abstract class TenantAwareRepository<T extends ObjectLiteral> { constructor( protected repository: Repository<T>, protected tenantContextService: TenantContextService, ) {}
protected get tenantId(): number { const id = this.tenantContextService.getTenantId(); if (!id) { throw new Error('Tenant context not set'); } return id; }
createQueryBuilder(alias: string): SelectQueryBuilder<T> { const qb = this.repository.createQueryBuilder(alias); qb.where(`${alias}.tenantId = :tenantId`, { tenantId: this.tenantId }); return qb; }
async find(options?: any): Promise<T[]> { return this.repository.find({ ...options, where: { ...options?.where, tenantId: this.tenantId, }, }); }
async findOne(options: any): Promise<T | null> { return this.repository.findOne({ ...options, where: { ...options?.where, tenantId: this.tenantId, }, }); }
async save(entity: Partial<T>): Promise<T> { const entityWithTenant = { ...entity, tenantId: this.tenantId, }; return this.repository.save(entityWithTenant as any); }
async remove(entity: T): Promise<T> { return this.repository.remove(entity); }
async count(options?: any): Promise<number> { return this.repository.count({ ...options, where: { ...options?.where, tenantId: this.tenantId, }, }); }}49.7 Tenant Service
Section titled “49.7 Tenant Service”import { Injectable, NotFoundException, ConflictException, BadRequestException,} from '@nestjs/common';import { InjectRepository } from '@nestjs/typeorm';import { Repository, DataSource } from 'typeorm';import { Tenant } from './tenant.entity';import { User, UserRole } from '../users/user.entity';import { Subscription, SubscriptionStatus } from '../subscriptions/subscription.entity';import { Plan } from '../plans/plan.entity';import { CreateTenantDto } from './dto/create-tenant.dto';
@Injectable()export class TenantService { constructor( @InjectRepository(Tenant) private tenantRepository: Repository<Tenant>, @InjectRepository(User) private userRepository: Repository<User>, @InjectRepository(Plan) private planRepository: Repository<Plan>, private dataSource: DataSource, ) {}
async create(createTenantDto: CreateTenantDto): Promise<Tenant> { // Check if slug is available const existingTenant = await this.tenantRepository.findOne({ where: { slug: createTenantDto.slug }, });
if (existingTenant) { throw new ConflictException('Tenant slug already exists'); }
return this.dataSource.transaction(async (manager) => { // Get default plan const defaultPlan = await manager.findOne(Plan, { where: { slug: 'free' }, });
// Create tenant const tenant = manager.create(Tenant, { name: createTenantDto.name, slug: createTenantDto.slug, domain: createTenantDto.domain, planId: defaultPlan?.id, isTrial: true, trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days });
const savedTenant = await manager.save(tenant);
// Create owner user const user = manager.create(User, { tenantId: savedTenant.id, email: createTenantDto.ownerEmail, password: createTenantDto.ownerPassword, // Should be hashed role: UserRole.OWNER, emailVerified: false, });
await manager.save(user);
// Create trial subscription if (defaultPlan) { const subscription = manager.create(Subscription, { tenantId: savedTenant.id, planId: defaultPlan.id, status: SubscriptionStatus.TRIALING, currentPeriodStart: new Date(), currentPeriodEnd: savedTenant.trialEndsAt!, });
await manager.save(subscription); }
return savedTenant; }); }
async findOne(id: number): Promise<Tenant> { const tenant = await this.tenantRepository.findOne({ where: { id }, relations: ['plan', 'subscription'], });
if (!tenant) { throw new NotFoundException('Tenant not found'); }
return tenant; }
async findBySlug(slug: string): Promise<Tenant> { const tenant = await this.tenantRepository.findOne({ where: { slug, isActive: true }, relations: ['plan', 'subscription'], });
if (!tenant) { throw new NotFoundException('Tenant not found'); }
return tenant; }
async update(id: number, updateData: Partial<Tenant>): Promise<Tenant> { const tenant = await this.findOne(id);
// Prevent updating sensitive fields delete updateData.id; delete updateData.slug; delete updateData.planId;
Object.assign(tenant, updateData); return this.tenantRepository.save(tenant); }
async checkLimits(tenantId: number): Promise<{ users: { current: number; max: number }; storage: { current: number; max: number }; }> { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId }, relations: ['plan'], });
if (!tenant) { throw new NotFoundException('Tenant not found'); }
const userCount = await this.userRepository.count({ where: { tenantId, isActive: true }, });
// Calculate storage usage (simplified) const storageUsed = 0; // Would calculate from actual storage
return { users: { current: userCount, max: tenant.plan?.maxUsers || 0, }, storage: { current: storageUsed, max: tenant.plan?.maxStorage || 0, }, }; }
async validateUserLimit(tenantId: number): Promise<boolean> { const limits = await this.checkLimits(tenantId); return limits.users.current < limits.users.max; }}49.8 Role-Based Access Control
Section titled “49.8 Role-Based Access Control”import { Injectable } from '@nestjs/common';import { UserRole } from '../users/user.entity';
interface Permission { action: string; resource: string;}
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = { [UserRole.OWNER]: [ { action: 'manage', resource: 'all' }, ], [UserRole.ADMIN]: [ { action: 'read', resource: 'all' }, { action: 'create', resource: 'users' }, { action: 'update', resource: 'users' }, { action: 'delete', resource: 'users' }, { action: 'create', resource: 'settings' }, { action: 'update', resource: 'settings' }, { action: 'manage', resource: 'billing' }, ], [UserRole.MANAGER]: [ { action: 'read', resource: 'users' }, { action: 'create', resource: 'users' }, { action: 'update', resource: 'users' }, { action: 'read', resource: 'settings' }, { action: 'read', resource: 'billing' }, ], [UserRole.MEMBER]: [ { action: 'read', resource: 'users' }, { action: 'read', resource: 'settings' }, ], [UserRole.GUEST]: [ { action: 'read', resource: 'users' }, ],};
@Injectable()export class RbacService { hasPermission(role: UserRole, action: string, resource: string): boolean { const permissions = ROLE_PERMISSIONS[role];
return permissions.some( (p) => (p.action === 'manage' || p.action === action) && (p.resource === 'all' || p.resource === resource), ); }
getPermissions(role: UserRole): Permission[] { return ROLE_PERMISSIONS[role] || []; }
getHigherRoles(role: UserRole): UserRole[] { const hierarchy = [ UserRole.GUEST, UserRole.MEMBER, UserRole.MANAGER, UserRole.ADMIN, UserRole.OWNER, ];
const currentIndex = hierarchy.indexOf(role); return hierarchy.slice(currentIndex + 1); }
canAssignRole(assignerRole: UserRole, targetRole: UserRole): boolean { const higherRoles = this.getHigherRoles(assignerRole); return !higherRoles.includes(targetRole); }}Permission Guard
Section titled “Permission Guard”import { Injectable, CanActivate, ExecutionContext, ForbiddenException,} from '@nestjs/common';import { Reflector } from '@nestjs/core';import { RbacService } from '../rbac.service';import { UserRole } from '../../users/user.entity';
export const PERMISSION_KEY = 'permission';
@Injectable()export class PermissionGuard implements CanActivate { constructor( private reflector: Reflector, private rbacService: RbacService, ) {}
canActivate(context: ExecutionContext): boolean { const requiredPermission = this.reflector.getAllAndOverride<Permission>( PERMISSION_KEY, [context.getHandler(), context.getClass()], );
if (!requiredPermission) { return true; }
const request = context.switchToHttp().getRequest(); const user = request.user;
if (!user) { throw new ForbiddenException('User not authenticated'); }
const hasPermission = this.rbacService.hasPermission( user.role, requiredPermission.action, requiredPermission.resource, );
if (!hasPermission) { throw new ForbiddenException('Insufficient permissions'); }
return true; }}
interface Permission { action: string; resource: string;}49.9 Billing Service
Section titled “49.9 Billing Service”import { Injectable, BadRequestException, NotFoundException,} from '@nestjs/common';import { InjectRepository } from '@nestjs/typeorm';import { Repository, DataSource } from 'typeorm';import { Subscription, SubscriptionStatus } from '../subscriptions/subscription.entity';import { Plan } from '../plans/plan.entity';import { Invoice, InvoiceStatus } from '../invoices/invoice.entity';import { Tenant } from '../tenants/tenant.entity';
@Injectable()export class BillingService { constructor( @InjectRepository(Subscription) private subscriptionRepository: Repository<Subscription>, @InjectRepository(Plan) private planRepository: Repository<Plan>, @InjectRepository(Invoice) private invoiceRepository: Repository<Invoice>, @InjectRepository(Tenant) private tenantRepository: Repository<Tenant>, private dataSource: DataSource, ) {}
async changePlan(tenantId: number, newPlanId: number): Promise<Subscription> { return this.dataSource.transaction(async (manager) => { const tenant = await manager.findOne(Tenant, { where: { id: tenantId }, relations: ['subscription'], });
if (!tenant) { throw new NotFoundException('Tenant not found'); }
const newPlan = await manager.findOne(Plan, { where: { id: newPlanId, isActive: true }, });
if (!newPlan) { throw new NotFoundException('Plan not found'); }
// Check if downgrading const currentPlan = tenant.plan; const isDowngrade = newPlan.price < (currentPlan?.price || 0);
// Update subscription const subscription = tenant.subscription; subscription.planId = newPlanId;
if (isDowngrade) { // Schedule downgrade at period end subscription.endsAt = subscription.currentPeriodEnd; } else { // Immediate upgrade subscription.currentPeriodStart = new Date(); subscription.currentPeriodEnd = this.calculatePeriodEnd(newPlan); }
await manager.save(subscription);
// Update tenant tenant.planId = newPlanId; await manager.save(tenant);
// Create invoice for upgrade if (!isDowngrade) { await this.createInvoice(manager, tenantId, subscription.id, newPlan); }
return subscription; }); }
async cancelSubscription(tenantId: number): Promise<Subscription> { const subscription = await this.subscriptionRepository.findOne({ where: { tenantId }, });
if (!subscription) { throw new NotFoundException('Subscription not found'); }
if (subscription.status === SubscriptionStatus.CANCELLED) { throw new BadRequestException('Subscription already cancelled'); }
subscription.status = SubscriptionStatus.CANCELLED; subscription.cancelledAt = new Date(); subscription.endsAt = subscription.currentPeriodEnd;
return this.subscriptionRepository.save(subscription); }
async processPayment(invoiceId: number): Promise<Invoice> { const invoice = await this.invoiceRepository.findOne({ where: { id: invoiceId }, relations: ['subscription'], });
if (!invoice) { throw new NotFoundException('Invoice not found'); }
// Process payment with Stripe (simplified) const paymentSuccessful = true;
if (paymentSuccessful) { invoice.status = InvoiceStatus.PAID; invoice.paidAt = new Date();
// Update subscription const subscription = invoice.subscription; subscription.currentPeriodStart = new Date(); subscription.currentPeriodEnd = this.calculatePeriodEnd( await this.planRepository.findOne({ where: { id: subscription.planId } }), ); subscription.status = SubscriptionStatus.ACTIVE;
await this.subscriptionRepository.save(subscription); } else { invoice.status = InvoiceStatus.FAILED; }
return this.invoiceRepository.save(invoice); }
private async createInvoice( manager: any, tenantId: number, subscriptionId: number, plan: Plan, ): Promise<Invoice> { const invoice = manager.create(Invoice, { tenantId, subscriptionId, amount: plan.price, status: InvoiceStatus.PENDING, dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days });
return manager.save(invoice); }
private calculatePeriodEnd(plan: Plan): Date { const now = new Date(); if (plan.interval === 'yearly') { return new Date(now.setFullYear(now.getFullYear() + 1)); } return new Date(now.setMonth(now.getMonth() + 1)); }}49.10 Data Isolation Query Examples
Section titled “49.10 Data Isolation Query Examples”// Example: Tenant-aware query with automatic filteringasync getTenantUsers(tenantId: number) { return this.userRepository .createQueryBuilder('user') .where('user.tenantId = :tenantId', { tenantId }) .andWhere('user.isActive = :isActive', { isActive: true }) .orderBy('user.createdAt', 'DESC') .getMany();}
// Example: Cross-tenant query (admin only)async getAllTenantsStats() { return this.tenantRepository .createQueryBuilder('tenant') .leftJoin('tenant.users', 'user') .select([ 'tenant.id', 'tenant.name', 'COUNT(user.id) as userCount', ]) .groupBy('tenant.id') .getRawMany();}
// Example: Tenant-specific aggregate queryasync getTenantStats(tenantId: number) { const userStats = await this.userRepository .createQueryBuilder('user') .select([ 'COUNT(*) as totalUsers', 'COUNT(CASE WHEN user.isActive = true THEN 1 END) as activeUsers', 'COUNT(CASE WHEN user.role = :adminRole THEN 1 END) as adminCount', ]) .where('user.tenantId = :tenantId', { tenantId }) .setParameter('adminRole', UserRole.ADMIN) .getRawOne();
return userStats;}49.11 Summary
Section titled “49.11 Summary”This Multi-Tenant SaaS project demonstrates:
- Multi-Tenancy Strategies: Row-level, schema-level, and database-level isolation
- Tenant Context Management: Request-scoped tenant context
- Automatic Data Isolation: Tenant-aware repositories and queries
- Subscription Management: Plans, billing cycles, and payment processing
- Role-Based Access Control: Hierarchical roles with permissions
- Tenant Limits: User and storage limits enforcement
- Billing Integration: Plan changes, cancellations, and invoices
Next Chapter
Section titled “Next Chapter”Chapter 50: Final Project Review
Last Updated: February 2026