SaaS Platform Development
Overview
Building Software-as-a-Service applications with multi-tenancy, subscription billing, and user management.
Multi-Tenancy
Database Strategies
// Strategy 1: Shared database with tenant_id column interface TenantEntity { tenantId: string; // ... other fields }
// Middleware to inject tenant context function tenantMiddleware(req: Request, res: Response, next: NextFunction) { const tenantId = req.headers['x-tenant-id'] || req.user?.tenantId;
if (!tenantId) { return res.status(400).json({ error: 'Tenant ID required' }); }
req.tenantId = tenantId; next(); }
// Prisma middleware for automatic tenant filtering prisma.$use(async (params, next) => { const tenantId = getCurrentTenantId();
if (params.model && hasTenantId(params.model)) { // Add tenant filter to queries if (params.action === 'findMany' || params.action === 'findFirst') { params.args.where = { ...params.args.where, tenantId, }; }
// Add tenant ID to creates
if (params.action === 'create') {
params.args.data.tenantId = tenantId;
}
}
return next(params); });
// Strategy 2: Schema per tenant (PostgreSQL)
async function createTenantSchema(tenantId: string) {
await prisma.$executeRawCREATE SCHEMA IF NOT EXISTS ${tenantId};
// Run migrations for new schema await runMigrations(tenantId); }
function getTenantConnection(tenantId: string) {
return new PrismaClient({
datasources: {
db: {
url: ${process.env.DATABASE_URL}?schema=${tenantId},
},
},
});
}
// Strategy 3: Database per tenant
async function createTenantDatabase(tenantId: string) {
const dbName = tenant_${tenantId};
await adminDb.$executeRawCREATE DATABASE ${dbName};
return new PrismaClient({
datasources: {
db: {
url: postgresql://user:pass@host:5432/${dbName},
},
},
});
}
Tenant Isolation
// Row-level security with Prisma const prisma = new PrismaClient().$extends({ query: { $allModels: { async findMany({ model, operation, args, query }) { const tenantId = getCurrentTenantId(); args.where = { ...args.where, tenantId }; return query(args); }, async create({ model, operation, args, query }) { const tenantId = getCurrentTenantId(); args.data = { ...args.data, tenantId }; return query(args); }, }, }, });
// PostgreSQL Row Level Security /* CREATE POLICY tenant_isolation ON projects USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY; */
// Set tenant context for RLS
async function withTenantContext<T>(
tenantId: string,
fn: () => Promise<T>
): Promise<T> {
await prisma.$executeRawSET app.tenant_id = ${tenantId};
try {
return await fn();
} finally {
await prisma.$executeRawRESET app.tenant_id;
}
}
Subscription Management
Stripe Subscriptions
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Create subscription async function createSubscription( customerId: string, priceId: string, trialDays?: number ) { const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }], trial_period_days: trialDays, payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription' }, expand: ['latest_invoice.payment_intent'], });
return subscription; }
// Update subscription async function updateSubscription(subscriptionId: string, newPriceId: string) { const subscription = await stripe.subscriptions.retrieve(subscriptionId);
return stripe.subscriptions.update(subscriptionId, { items: [ { id: subscription.items.data[0].id, price: newPriceId, }, ], proration_behavior: 'create_prorations', }); }
// Cancel subscription async function cancelSubscription(subscriptionId: string, immediate = false) { if (immediate) { return stripe.subscriptions.cancel(subscriptionId); }
return stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true, }); }
// Handle subscription webhooks async function handleSubscriptionWebhook(event: Stripe.Event) { switch (event.type) { case 'customer.subscription.created': case 'customer.subscription.updated': { const subscription = event.data.object as Stripe.Subscription; await syncSubscription(subscription); break; }
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await deactivateSubscription(subscription.id);
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
await recordPayment(invoice);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handleFailedPayment(invoice);
break;
}
} }
// Sync subscription to database async function syncSubscription(subscription: Stripe.Subscription) { const planMapping: Record<string, string> = { price_starter: 'starter', price_pro: 'pro', price_enterprise: 'enterprise', };
await prisma.organization.update({ where: { stripeCustomerId: subscription.customer as string }, data: { subscriptionId: subscription.id, subscriptionStatus: subscription.status, plan: planMapping[subscription.items.data[0].price.id] || 'free', currentPeriodEnd: new Date(subscription.current_period_end * 1000), }, }); }
Usage-Based Billing
// Track usage async function recordUsage( subscriptionItemId: string, quantity: number, timestamp?: number ) { await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, { quantity, timestamp: timestamp || Math.floor(Date.now() / 1000), action: 'increment', }); }
// Usage tracking service class UsageTracker { private buffer: Map<string, number> = new Map(); private flushInterval: NodeJS.Timeout;
constructor(private flushIntervalMs = 60000) { this.flushInterval = setInterval(() => this.flush(), flushIntervalMs); }
track(orgId: string, metric: string, amount = 1) {
const key = ${orgId}:${metric};
this.buffer.set(key, (this.buffer.get(key) || 0) + amount);
}
async flush() { const entries = Array.from(this.buffer.entries()); this.buffer.clear();
for (const [key, amount] of entries) {
const [orgId, metric] = key.split(':');
// Record to database
await prisma.usageRecord.create({
data: {
organizationId: orgId,
metric,
amount,
timestamp: new Date(),
},
});
// Report to Stripe (for metered billing)
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { subscriptionItemId: true },
});
if (org?.subscriptionItemId) {
await recordUsage(org.subscriptionItemId, amount);
}
}
} }
Feature Flags & Entitlements
interface Plan { id: string; name: string; features: { [key: string]: boolean | number; }; limits: { [key: string]: number; }; }
const plans: Record<string, Plan> = { free: { id: 'free', name: 'Free', features: { basicAnalytics: true, advancedAnalytics: false, apiAccess: false, customBranding: false, }, limits: { projects: 3, teamMembers: 1, storage: 100, // MB apiCalls: 1000, }, }, pro: { id: 'pro', name: 'Pro', features: { basicAnalytics: true, advancedAnalytics: true, apiAccess: true, customBranding: false, }, limits: { projects: 20, teamMembers: 10, storage: 10000, // MB apiCalls: 100000, }, }, enterprise: { id: 'enterprise', name: 'Enterprise', features: { basicAnalytics: true, advancedAnalytics: true, apiAccess: true, customBranding: true, }, limits: { projects: -1, // Unlimited teamMembers: -1, storage: -1, apiCalls: -1, }, }, };
// Check feature access function hasFeature(org: Organization, feature: string): boolean { const plan = plans[org.plan]; return plan?.features[feature] ?? false; }
// Check limit function checkLimit(org: Organization, resource: string, current: number): boolean { const plan = plans[org.plan]; const limit = plan?.limits[resource] ?? 0; return limit === -1 || current < limit; }
// Middleware for feature gating function requireFeature(feature: string) { return async (req: Request, res: Response, next: NextFunction) => { const org = await getOrganization(req.tenantId);
if (!hasFeature(org, feature)) {
return res.status(403).json({
error: 'Feature not available',
upgrade: true,
requiredPlan: getMinimumPlanForFeature(feature),
});
}
next();
}; }
User Onboarding
interface OnboardingStep { id: string; title: string; completed: boolean; skippable: boolean; }
async function getOnboardingProgress(userId: string) { const user = await prisma.user.findUnique({ where: { id: userId }, include: { organization: true }, });
const steps: OnboardingStep[] = [ { id: 'profile', title: 'Complete your profile', completed: !!user.name && !!user.avatar, skippable: true, }, { id: 'invite_team', title: 'Invite team members', completed: user.organization.memberCount > 1, skippable: true, }, { id: 'create_project', title: 'Create your first project', completed: user.organization.projectCount > 0, skippable: false, }, { id: 'connect_integration', title: 'Connect an integration', completed: user.organization.integrationCount > 0, skippable: true, }, ];
const completedCount = steps.filter((s) => s.completed).length;
return { steps, progress: Math.round((completedCount / steps.length) * 100), isComplete: steps.every((s) => s.completed || s.skippable), }; }
Related Skills
-
[[system-design]] - SaaS architecture
-
[[security-practices]] - Multi-tenant security
-
[[database]] - Tenant data isolation