SaaS Application Example
This example demonstrates a multi-tenant SaaS application with subscription management, usage tracking, and billing integration using MBC CQRS Serverless.
Overview
The SaaS example covers:
- Multi-tenant isolation with tenant hierarchy
- Subscription and plan management
- Usage metering and quota enforcement
- Billing event generation
Data Model
Key Structure
Partition Key (pk) Sort Key (sk)
────────────────── ────────────────────────────────
TENANT#acme-corp SUBSCRIPTION#SUB-001
TENANT#acme-corp USAGE#2024-01
TENANT#acme-corp USER#usr-001
TENANT#acme-corp APIKEY#key-001
MASTER#common PLAN#starter
MASTER#common PLAN#professional
MASTER#common PLAN#enterprise
Entity Definitions
// Plan Entity (Master Data)
export interface PlanAttributes {
displayName: string;
monthlyPrice: number;
yearlyPrice: number;
currency: string;
features: PlanFeature[];
limits: PlanLimits;
isActive: boolean;
}
export interface PlanLimits {
maxUsers: number;
maxApiCalls: number;
maxStorageGb: number;
maxProjects: number;
}
// Subscription Entity
export interface SubscriptionAttributes {
planCode: string;
billingCycle: 'monthly' | 'yearly';
status: SubscriptionStatus;
startDate: string;
endDate: string;
autoRenew: boolean;
paymentMethodId?: string;
}
export type SubscriptionStatus =
| 'active'
| 'trial'
| 'past_due'
| 'cancelled'
| 'expired';
// Usage Entity
export interface UsageAttributes {
period: string; // YYYY-MM
apiCalls: number;
storageUsedGb: number;
activeUsers: number;
projectCount: number;
lastUpdatedAt: string;
}
Tenant Management
Tenant Service Extension
// tenant-setup.service.ts
import { Injectable } from '@nestjs/common';
import { CommandService, IInvoke, KEY_SEPARATOR } from '@mbc-cqrs-serverless/core';
import { TenantService } from '@mbc-cqrs-serverless/tenant';
import { SubscriptionService } from './subscription.service';
// Example helper: per-tenant partition key
const generatePk = (tenantCode: string): string =>
`TENANT${KEY_SEPARATOR}${tenantCode}`;
@Injectable()
export class TenantSetupService {
constructor(
private readonly tenantService: TenantService,
private readonly subscriptionService: SubscriptionService,
private readonly commandService: CommandService,
) {}
// Create tenant with initial subscription
async provisionTenant(dto: ProvisionTenantDto, context: IInvoke) {
// Step 1: Create tenant
const tenant = await this.tenantService.createTenant(
{
code: dto.companySlug,
name: dto.companyName,
attributes: {
industry: dto.industry,
country: dto.country,
timezone: dto.timezone,
},
},
{ invokeContext: context },
);
// Step 2: Create trial subscription
await this.subscriptionService.createTrialSubscription(
dto.companySlug,
dto.planCode,
context,
);
// Step 3: Initialize usage tracking
await this.initializeUsageTracking(dto.companySlug, context);
return tenant;
}
private async initializeUsageTracking(
tenantCode: string,
context: IInvoke,
) {
const currentPeriod = this.getCurrentPeriod();
await this.commandService.publishAsync(
{
pk: generatePk(tenantCode),
sk: `USAGE#${currentPeriod}`,
code: currentPeriod,
name: `Usage for ${currentPeriod}`,
tenantCode,
attributes: {
period: currentPeriod,
apiCalls: 0,
storageUsedGb: 0,
activeUsers: 0,
projectCount: 0,
lastUpdatedAt: new Date().toISOString(),
},
},
{ invokeContext: context },
);
}
private getCurrentPeriod(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
}
Subscription Management
Subscription Service
// subscription.service.ts
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import {
CommandService,
DataService,
IInvoke,
KEY_SEPARATOR,
getUserContext,
} from '@mbc-cqrs-serverless/core';
import { MasterDataService } from '@mbc-cqrs-serverless/master';
// Example helper: per-tenant partition key
const generatePk = (tenantCode: string): string =>
`TENANT${KEY_SEPARATOR}${tenantCode}`;
@Injectable()
export class SubscriptionService {
constructor(
private readonly commandService: CommandService,
private readonly dataService: DataService,
private readonly masterDataService: MasterDataService,
) {}
// Create trial subscription
async createTrialSubscription(
tenantCode: string,
planCode: string,
context: IInvoke,
) {
const plan = await this.masterDataService.get({
pk: `MASTER${KEY_SEPARATOR}common`,
sk: `PLAN${KEY_SEPARATOR}${planCode}`,
});
if (!plan) {
throw new BadRequestException(`Plan ${planCode} not found`);
}
const trialDays = 14;
const startDate = new Date();
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + trialDays);
const command = {
pk: generatePk(tenantCode),
sk: `SUBSCRIPTION#SUB-${Date.now()}`,
code: `SUB-${Date.now()}`,
name: `${plan.name} Subscription`,
tenantCode,
attributes: {
planCode,
billingCycle: 'monthly',
status: 'trial' as SubscriptionStatus,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
autoRenew: true,
},
};
return this.commandService.publishAsync(command, { invokeContext: context });
}
// Upgrade/Downgrade subscription
async changePlan(
subscriptionCode: string,
newPlanCode: string,
context: IInvoke,
) {
const { tenantCode } = getUserContext(context);
const pk = generatePk(tenantCode);
const sk = `SUBSCRIPTION#${subscriptionCode}`;
const current = await this.dataService.getItem({ pk, sk });
if (!current) {
throw new NotFoundException(`Subscription ${subscriptionCode} not found`);
}
// Validate plan exists
const newPlan = await this.masterDataService.get({
pk: `MASTER${KEY_SEPARATOR}common`,
sk: `PLAN${KEY_SEPARATOR}${newPlanCode}`,
});
if (!newPlan) {
throw new BadRequestException(`Plan ${newPlanCode} not found`);
}
// Validate upgrade/downgrade is allowed
await this.validatePlanChange(tenantCode, current.attributes, newPlan);
const command = {
...current,
version: current.version,
attributes: {
...current.attributes,
planCode: newPlanCode,
planChangedAt: new Date().toISOString(),
},
};
return this.commandService.publishAsync(command, { invokeContext: context });
}
// Cancel subscription
async cancelSubscription(
subscriptionCode: string,
reason: string,
context: IInvoke,
) {
const { tenantCode } = getUserContext(context);
const pk = generatePk(tenantCode);
const sk = `SUBSCRIPTION#${subscriptionCode}`;
const current = await this.dataService.getItem({ pk, sk });
if (!current) {
throw new NotFoundException(`Subscription ${subscriptionCode} not found`);
}
const command = {
...current,
version: current.version,
attributes: {
...current.attributes,
status: 'cancelled' as SubscriptionStatus,
autoRenew: false,
cancellationReason: reason,
cancelledAt: new Date().toISOString(),
},
};
return this.commandService.publishAsync(command, { invokeContext: context });
}
private async validatePlanChange(
tenantCode: string,
currentSub: SubscriptionAttributes,
newPlan: any,
) {
// Check current usage against new plan limits
const usage = await this.getCurrentUsage(tenantCode);
if (usage.activeUsers > newPlan.attributes.limits.maxUsers) {
throw new BadRequestException(
`Cannot downgrade: current users (${usage.activeUsers}) ` +
`exceeds new plan limit (${newPlan.attributes.limits.maxUsers})`
);
}
if (usage.storageUsedGb > newPlan.attributes.limits.maxStorageGb) {
throw new BadRequestException(
`Cannot downgrade: current storage (${usage.storageUsedGb}GB) ` +
`exceeds new plan limit (${newPlan.attributes.limits.maxStorageGb}GB)`
);
}
}
private async getCurrentUsage(tenantCode: string): Promise<UsageAttributes> {
const now = new Date();
const period = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const record = await this.dataService.getItem({
pk: generatePk(tenantCode),
sk: `USAGE#${period}`,
});
return (record?.attributes ?? {
apiCalls: 0,
storageUsedGb: 0,
activeUsers: 0,
projectCount: 0,
}) as UsageAttributes;
}
}
Usage Metering
Usage Service
// usage.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
CommandService,
DataService,
IInvoke,
KEY_SEPARATOR,
getUserContext,
} from '@mbc-cqrs-serverless/core';
import { MasterDataService } from '@mbc-cqrs-serverless/master';
// Example helper: per-tenant partition key
const generatePk = (tenantCode: string): string =>
`TENANT${KEY_SEPARATOR}${tenantCode}`;
@Injectable()
export class UsageService {
private readonly logger = new Logger(UsageService.name);
constructor(
private readonly commandService: CommandService,
private readonly dataService: DataService,
private readonly masterDataService: MasterDataService,
) {}
// Track API call
async trackApiCall(context: IInvoke): Promise<void> {
await this.incrementUsage(context, 'apiCalls', 1);
}
// Track storage usage
async trackStorageChange(
deltaGb: number,
context: IInvoke,
): Promise<void> {
await this.incrementUsage(context, 'storageUsedGb', deltaGb);
}
// Check if quota is exceeded
async checkQuota(
metric: keyof UsageAttributes,
context: IInvoke,
): Promise<QuotaCheckResult> {
const { tenantCode } = getUserContext(context);
const [usage, subscription] = await Promise.all([
this.getCurrentUsage(tenantCode),
this.getActiveSubscription(tenantCode),
]);
const plan = await this.masterDataService.get({
pk: `MASTER${KEY_SEPARATOR}common`,
sk: `PLAN${KEY_SEPARATOR}${subscription.attributes.planCode}`,
});
const limit = this.getLimit(plan, metric);
const current = usage.attributes[metric] as number;
const percentage = (current / limit) * 100;
return {
metric,
current,
limit,
percentage,
isExceeded: current >= limit,
isNearLimit: percentage >= 80,
};
}
// Get usage summary for billing
async getUsageSummary(
tenantCode: string,
period: string,
): Promise<UsageSummary> {
const pk = generatePk(tenantCode);
const sk = `USAGE#${period}`;
const usage = await this.dataService.getItem({ pk, sk });
if (!usage) {
return {
period,
apiCalls: 0,
storageUsedGb: 0,
activeUsers: 0,
projectCount: 0,
};
}
return usage.attributes;
}
private async incrementUsage(
context: IInvoke,
metric: string,
delta: number,
): Promise<void> {
const { tenantCode } = getUserContext(context);
const period = this.getCurrentPeriod();
const pk = generatePk(tenantCode);
const sk = `USAGE#${period}`;
const current = await this.dataService.getItem({ pk, sk });
if (!current) {
// Create new usage record
const command = {
pk,
sk,
code: period,
name: `Usage for ${period}`,
tenantCode,
attributes: {
period,
apiCalls: 0,
storageUsedGb: 0,
activeUsers: 0,
projectCount: 0,
[metric]: delta,
lastUpdatedAt: new Date().toISOString(),
},
};
await this.commandService.publishAsync(command, { invokeContext: context });
return;
}
// Update existing record
const command = {
...current,
version: current.version,
attributes: {
...current.attributes,
[metric]: (current.attributes[metric] || 0) + delta,
lastUpdatedAt: new Date().toISOString(),
},
};
await this.commandService.publishAsync(command, { invokeContext: context });
}
private getLimit(plan: any, metric: string): number {
const limitMapping: Record<string, string> = {
apiCalls: 'maxApiCalls',
storageUsedGb: 'maxStorageGb',
activeUsers: 'maxUsers',
projectCount: 'maxProjects',
};
return plan.attributes.limits[limitMapping[metric]] || Infinity;
}
private getCurrentPeriod(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
private async getCurrentUsage(tenantCode: string): Promise<any> {
const pk = generatePk(tenantCode);
const sk = `USAGE#${this.getCurrentPeriod()}`;
return (
(await this.dataService.getItem({ pk, sk })) ?? {
attributes: { apiCalls: 0, storageUsedGb: 0, activeUsers: 0, projectCount: 0 },
}
);
}
private async getActiveSubscription(tenantCode: string): Promise<any> {
const pk = generatePk(tenantCode);
const items = await this.dataService.listItemsByPk(pk, {
skExpression: 'begins_with(sk, :prefix)',
skAttributeValues: { ':prefix': 'SUBSCRIPTION#' },
});
const active = items.items.find(
(item) =>
item.attributes.status === 'active' ||
item.attributes.status === 'trial',
);
if (!active) {
throw new NotFoundException(
`No active subscription found for tenant ${tenantCode}`,
);
}
return active;
}
}
Quota Guard
// quota.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UsageService } from './usage.service';
import { extractInvokeContext } from '@mbc-cqrs-serverless/core';
@Injectable()
export class QuotaGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly usageService: UsageService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const metric = this.reflector.get<string>('quota_metric', context.getHandler());
if (!metric) {
return true;
}
const ctx = extractInvokeContext(context);
const quota = await this.usageService.checkQuota(metric, ctx);
if (quota.isExceeded) {
throw new ForbiddenException(
`Quota exceeded for ${metric}: ${quota.current}/${quota.limit}. ` +
'Please upgrade your plan.'
);
}
return true;
}
}
// Usage in controller
@Controller('projects')
export class ProjectController {
@Post()
@UseGuards(QuotaGuard)
@SetMetadata('quota_metric', 'projectCount')
async create(
@Body() dto: CreateProjectDto,
@INVOKE_CONTEXT() invokeContext: IInvoke,
) {
// ...
}
}
Billing Integration
Billing Event Handler
// billing-event.handler.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
CommandModel,
DataService,
DataSyncHandler,
IDataSyncHandler,
KEY_SEPARATOR,
VERSION_FIRST,
} from '@mbc-cqrs-serverless/core';
import { MasterDataService } from '@mbc-cqrs-serverless/master';
import { BillingService } from './billing.service';
@DataSyncHandler('subscription')
@Injectable()
export class BillingEventHandler implements IDataSyncHandler {
private readonly logger = new Logger(BillingEventHandler.name);
constructor(
private readonly billingService: BillingService,
private readonly dataService: DataService,
private readonly masterDataService: MasterDataService,
) {}
async up(cmd: CommandModel): Promise<any> {
// Handle subscription changes
if (cmd.sk.startsWith('SUBSCRIPTION#')) {
await this.handleSubscriptionChange(cmd);
return;
}
// Handle usage updates
if (cmd.sk.startsWith('USAGE#')) {
await this.handleUsageChange(cmd);
return;
}
}
async down(cmd: CommandModel): Promise<any> {
// Optional rollback logic when a sync step fails
}
private async handleSubscriptionChange(cmd: CommandModel) {
const attrs = cmd.attributes;
// New subscription: stored version is VERSION_FIRST + 1
if (cmd.version === VERSION_FIRST + 1) {
this.logger.log(`New subscription: ${cmd.code}`);
if (attrs.status === 'active') {
await this.billingService.createBillingCycle({
tenantCode: cmd.tenantCode,
subscriptionCode: cmd.code,
planCode: attrs.planCode,
billingCycle: attrs.billingCycle,
startDate: attrs.startDate,
});
}
return;
}
// Subscription cancelled
if (attrs.status === 'cancelled') {
this.logger.log(`Subscription cancelled: ${cmd.code}`);
await this.billingService.cancelBillingCycle(cmd.code);
return;
}
// Plan changed
if (attrs.planChangedAt) {
this.logger.log(`Plan changed for ${cmd.code}: now ${attrs.planCode}`);
await this.billingService.prorate({
subscriptionCode: cmd.code,
newPlanCode: attrs.planCode,
changeDate: attrs.planChangedAt,
});
}
}
private async handleUsageChange(usage: CommandModel) {
// Check for overage
const subscription = await this.getActiveSubscription(usage.tenantCode);
const plan = await this.masterDataService.get({
pk: `MASTER${KEY_SEPARATOR}common`,
sk: `PLAN${KEY_SEPARATOR}${subscription.attributes.planCode}`,
});
const overages = this.calculateOverages(usage.attributes, plan.attributes.limits);
if (overages.length > 0) {
this.logger.log(`Overage detected for ${usage.tenantCode}`, overages);
await this.billingService.recordOverage({
tenantCode: usage.tenantCode,
period: usage.attributes.period,
overages,
});
}
}
private calculateOverages(
usage: UsageAttributes,
limits: PlanLimits,
): Overage[] {
const overages: Overage[] = [];
if (usage.apiCalls > limits.maxApiCalls) {
overages.push({
metric: 'apiCalls',
limit: limits.maxApiCalls,
actual: usage.apiCalls,
overage: usage.apiCalls - limits.maxApiCalls,
});
}
if (usage.storageUsedGb > limits.maxStorageGb) {
overages.push({
metric: 'storageUsedGb',
limit: limits.maxStorageGb,
actual: usage.storageUsedGb,
overage: usage.storageUsedGb - limits.maxStorageGb,
});
}
return overages;
}
private async getActiveSubscription(tenantCode: string): Promise<any> {
const pk = `TENANT${KEY_SEPARATOR}${tenantCode}`;
const items = await this.dataService.listItemsByPk(pk, {
skExpression: 'begins_with(sk, :prefix)',
skAttributeValues: { ':prefix': 'SUBSCRIPTION#' },
});
const active = items.items.find(
(item) =>
item.attributes.status === 'active' ||
item.attributes.status === 'trial',
);
if (!active) {
throw new NotFoundException(
`No active subscription found for tenant ${tenantCode}`,
);
}
return active;
}
}
API Key Management
API Key Service
// api-key.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes, createHash } from 'crypto';
import {
CommandService,
DataService,
IInvoke,
KEY_SEPARATOR,
getUserContext,
} from '@mbc-cqrs-serverless/core';
// Example helper: per-tenant partition key
const generatePk = (tenantCode: string): string =>
`TENANT${KEY_SEPARATOR}${tenantCode}`;
@Injectable()
export class ApiKeyService {
constructor(
private readonly commandService: CommandService,
private readonly dataService: DataService,
) {}
// Generate new API key
async createApiKey(dto: CreateApiKeyDto, context: IInvoke) {
const { tenantCode, userId } = getUserContext(context);
// Generate secure API key
const rawKey = `mbc_${randomBytes(32).toString('hex')}`;
const keyHash = this.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, 11); // Show first 11 chars
const command = {
pk: generatePk(tenantCode),
sk: `APIKEY#${keyHash}`,
code: keyHash,
name: dto.name,
tenantCode,
attributes: {
keyPrefix,
keyHash,
createdBy: userId,
scopes: dto.scopes || ['read'],
expiresAt: dto.expiresAt,
lastUsedAt: null,
isActive: true,
},
};
await this.commandService.publishAsync(command, { invokeContext: context });
// Return raw key only once
return {
key: rawKey,
keyPrefix,
name: dto.name,
scopes: dto.scopes,
};
}
// Validate API key
async validateApiKey(rawKey: string): Promise<ApiKeyValidation> {
const keyHash = this.hashKey(rawKey);
// Search across all tenants via GSI using DynamoDB client directly
// DataService.listItemsByPk() queries by pk; for GSI queries inject DynamoDbService.client
const result = await this.dataService.listItemsByPk(keyHash);
if (!result.items.length) {
return { valid: false, reason: 'Key not found' };
}
const apiKey = result.items[0];
if (!apiKey.attributes.isActive) {
return { valid: false, reason: 'Key is inactive' };
}
if (
apiKey.attributes.expiresAt &&
new Date(apiKey.attributes.expiresAt) < new Date()
) {
return { valid: false, reason: 'Key expired' };
}
// Update last used timestamp asynchronously
this.updateLastUsed(apiKey).catch(() => {});
return {
valid: true,
tenantCode: apiKey.tenantCode,
scopes: apiKey.attributes.scopes,
};
}
// Revoke API key
async revokeApiKey(keyPrefix: string, context: IInvoke) {
const { tenantCode } = getUserContext(context);
const pk = generatePk(tenantCode);
// Find key by prefix
const keys = await this.dataService.listItemsByPk(pk, {
sk: {
skExpression: 'begins_with(sk, :prefix)',
skAttributeValues: { ':prefix': 'APIKEY#' },
},
});
const apiKey = keys.items.find(
(k) => k.attributes.keyPrefix === keyPrefix
);
if (!apiKey) {
throw new NotFoundException('API key not found');
}
const command = {
...apiKey,
version: apiKey.version,
attributes: {
...apiKey.attributes,
isActive: false,
revokedAt: new Date().toISOString(),
},
};
return this.commandService.publishAsync(command, { invokeContext: context });
}
private hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}
}
API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /tenants/provision | Provision new tenant |
| GET | /subscription | Get current subscription |
| PATCH | /subscription/plan | Change plan |
| POST | /subscription/cancel | Cancel subscription |
| GET | /usage | Get current usage |
| GET | /usage/:period | Get usage for period |
| POST | /api-keys | Create API key |
| DELETE | /api-keys/:prefix | Revoke API key |
Best Practices
1. Tenant Isolation
Always scope data access by tenant:
const { tenantCode } = getUserContext(context);
const pk = generatePk(tenantCode);
2. Usage Tracking
Track usage incrementally, not on every request:
// Batch updates using a buffer
private usageBuffer = new Map<string, number>();
async trackApiCall(tenantCode: string) {
const current = this.usageBuffer.get(tenantCode) || 0;
this.usageBuffer.set(tenantCode, current + 1);
// Flush every 100 calls or 60 seconds
if (current + 1 >= 100) {
await this.flushUsage(tenantCode);
}
}
3. Billing Events
Use event handlers for billing, not synchronous calls:
// Good: Event-driven billing
@DataSyncHandler('subscription')
export class BillingEventHandler {
// Async, decoupled, reliable
}
// Avoid: Synchronous billing calls
async createSubscription() {
await this.createInStripe(); // Can fail, blocks user
}
Related Documentation
- E-commerce Example - Commerce application example
- Backend Development - Core backend implementation patterns
- Multi-Tenant Patterns - Tenant isolation
- Authentication - Subscription authentication
- Service Patterns - CRUD service patterns