SaaSアプリケーション例
この例では、MBC CQRS Serverlessを使用したサブスクリプション管理、使用量追跡、課金連携を備えたマルチテナントSaaSアプリケーションを示します。
概要
SaaSの例では以下をカバーします:
- テナント階層によるマルチテナント分離
- サブスクリプションとプラン管理
- 使用量計測とクォータ強制
- 課金イベント生成
データモデル
キー構造
パーティションキー (pk) ソートキー (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
エンティティ定義
// 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-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 (ステップ1: テナントを作成)
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 (ステップ2: トライアルサブスクリプションを作成)
await this.subscriptionService.createTrialSubscription(
dto.companySlug,
dto.planCode,
context,
);
// Step 3: Initialize usage tracking (ステップ3: 使用量追跡を初期化)
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.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)`
);
}
}
}
使用量計測
使用量サービス
// usage.service.ts
import { Injectable, Logger } 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 (APIコールを追跡)
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')}`;
}
}