Skip to content

Multitenant_saas

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”

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 | +-------------------+
+-------------------+
================================================================================

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
================================================================================

src/tenants/tenant.entity.ts
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;
}
src/users/user.entity.ts
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;
}
src/plans/plan.entity.ts
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;
}
src/subscriptions/subscription.entity.ts
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;
}

src/tenants/tenant-context.service.ts
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;
}
}

src/tenants/tenant.middleware.ts
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;
}
}

src/common/tenant-aware.repository.ts
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,
},
});
}
}

src/tenants/tenant.service.ts
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;
}
}

src/auth/rbac.service.ts
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);
}
}
src/auth/guards/permission.guard.ts
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;
}

src/billing/billing.service.ts
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));
}
}

// Example: Tenant-aware query with automatic filtering
async 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 query
async 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;
}

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

Chapter 50: Final Project Review


Last Updated: February 2026