マルチテナントパターン
このガイドでは、適切なデータ分離、共有リソース、テナント間操作を備えたマルチテナントアプリケーションの実装パターンについて説明します。
このガイドを使用するタイミング
以下の場合にこのガイドを使用してください:
- テナント(顧客/組織)間でデータを分離する
- すべてのテナントで共通データを共有する
- ユーザーが複数のテナントに所属できるようにする
- テナント固有の設定を実装する
- テナント間でデータを同期する
マルチテナントアーキテクチャ
┌─────────────────────────────────────────────────────────────────┐
│ Application │
├───────────────────────────────────────────────────────────────── ┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Tenant A │ │ Tenant B │ │ Common │ │
│ │ PK: X#A │ │ PK: X#B │ │ PK: X#common│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DynamoDB Table │ │
│ │ PK: ENTITY#tenantCode | SK: identifier │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
テナントコンテキスト
テナントコンテキストの抽出
呼び出しコンテキストからテナント情報を抽出するヘルパーを作成します:
// helpers/context.ts
import { IInvoke, getUserContext } from '@mbc-cqrs-serverless/core';
export interface CustomUserContext {
tenantCode: string;
userCode: string;
userId: string;
email?: string;
role?: string;
}
/**
* Get custom user context from invoke context (呼び出しコンテキストからカスタムユーザーコンテキストを取得)
*/
export function getCustomUserContext(invokeContext: IInvoke): CustomUserContext {
const userContext = getUserContext(invokeContext);
return {
tenantCode: userContext.tenantCode || DEFAULT_TENANT_CODE,
userCode: userContext.userCode || '',
userId: userContext.userId || '',
email: userContext.email,
role: userContext['custom:role'],
};
}
/**
* Tenant code for shared/common data across all tenants (全テナント共通/共有データ用のテナントコード)
* Use this for master data, settings, and resources shared by all tenants (マスターデータ、設定、全テナント共有リソースに使用)
*/
export const TENANT_COMMON = 'common';
/**
* Default tenant code when no tenant is specified (テナントが指定されていない場合のデフォルトテナントコード)
* Used in single-tenant mode or when tenant context is not available (シングルテナントモードまたはテナントコンテキストが利用できない場合に使用)
*/
export const DEFAULT_TENANT_CODE = 'single';
/**
* Check if user has access to tenant (ユーザーがテナントへのアクセス権を持っているか確認)
*/
export function hasTenantAccess(
userContext: CustomUserContext,
targetTenantCode: string,
): boolean {
// System admin can access all tenants (システム管理者は全テナントにアクセス可能)
if (userContext.role === 'SYSTEM_ADMIN') {
return true;
}
// User can only access their own tenant (ユーザーは自分のテナントのみアクセス可能)
return userContext.tenantCode === targetTenantCode;
}
テナントガード
テナントアクセスを強制するガードを実装します:
// guards/tenant.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { getCustomUserContext, hasTenantAccess } from '../helpers/context';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const invokeContext = request.invokeContext;
const userContext = getCustomUserContext(invokeContext);
// Get target tenant from path or body
const targetTenant = request.params.tenantCode ||
request.body?.tenantCode ||
this.extractTenantFromPk(request.body?.pk);
if (!targetTenant) {
return true; // No tenant specified, will use user's tenant
}
if (!hasTenantAccess(userContext, targetTenant)) {
throw new ForbiddenException(
`Access denied to tenant: ${targetTenant}`,
);
}
return true;
}
private extractTenantFromPk(pk: string | undefined): string | undefined {
if (!pk) return undefined;
const parts = pk.split('#');
return parts.length >= 2 ? parts[1] : undefined;
}
}
データ分離パターン
パターン1: パーティションキーにテナントを含める
完全な分離のためにパーティションキーにテナントコードを含めます:
// Standard tenant isolation pattern (標準的なテナント分離パターン)
const PRODUCT_PK_PREFIX = 'PRODUCT';
function generateProductPk(tenantCode: string): string {
return `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`;
}
// Example keys:
// PK: PRODUCT#tenant-a
// SK: 01HX7MBJK3V9WQBZ7XNDK5ZT2M
// Query all products for a tenant
async function listProductsByTenant(tenantCode: string) {
const pk = generateProductPk(tenantCode);
return dataService.listItemsByPk(pk);
}
パターン2: 共有データ用の共通テナント
すべてのテナントで共有されるデータには共通のテナントコードを使用します:
// Shared data pattern for master data and configurations (共有データパターン:マスターデータ、設定)
const COMMON_TENANT = 'common';
// System-wide settings
const settingsPk = `SETTINGS${KEY_SEPARATOR}${COMMON_TENANT}`;
// User data (users can belong to multiple tenants)
const userPk = `USER${KEY_SEPARATOR}${COMMON_TENANT}`;
// Example: Get system-wide email templates
async function getEmailTemplates() {
return dataService.listItemsByPk(`TEMPLATE${KEY_SEPARATOR}${COMMON_TENANT}`);
}
パターン3: ユーザー・テナント関連付け
複数のテナントに所属するユーザーを処理します:
// user/dto/user-tenant.dto.ts
export interface UserTenantAssociation {
pk: string; // USER_TENANT#common
sk: string; // {tenantCode}#{userCode}
tenantCode: string;
userCode: string;
role: string; // Role within this tenant
isDefault: boolean; // Default tenant for user
}
// user/user.service.ts
@Injectable()
export class UserService {
/**
* Get all tenants a user belongs to (ユーザーが所属する全テナントを取得)
*/
async getUserTenants(userCode: string): Promise<UserTenantAssociation[]> {
const pk = `USER_TENANT${KEY_SEPARATOR}${COMMON_TENANT}`;
// Query with SK prefix to find all tenant associations
const result = await this.dataService.listItemsByPk(pk, {
skPrefix: '', // Get all, then filter
});
return result.items.filter(item =>
item.sk.endsWith(`${KEY_SEPARATOR}${userCode}`),
);
}
/**
* Add user to tenant (ユーザーをテナントに追加)
*/
async addUserToTenant(
userCode: string,
tenantCode: string,
role: string,
invokeContext: IInvoke,
): Promise<void> {
const pk = `USER_TENANT${KEY_SEPARATOR}${COMMON_TENANT}`;
const sk = `${tenantCode}${KEY_SEPARATOR}${userCode}`;
await this.commandService.publishSync({
pk,
sk,
id: generateId(pk, sk),
tenantCode: COMMON_TENANT,
code: `${tenantCode}-${userCode}`,
name: `User ${userCode} in ${tenantCode}`,
type: 'USER_TENANT',
attributes: {
userCode,
tenantCode,
role,
isDefault: false,
},
}, { invokeContext });
}
/**
* Switch user's active tenant (ユーザーのアクティブテナントを切り替え)
*/
async switchTenant(
userCode: string,
newTenantCode: string,
invokeContext: IInvoke,
): Promise<{ token: string }> {
// Verify user belongs to tenant
const associations = await this.getUserTenants(userCode);
const association = associations.find(a =>
a.attributes.tenantCode === newTenantCode,
);
if (!association) {
throw new ForbiddenException(
`User does not belong to tenant: ${newTenantCode}`,
);
}
// Generate new token with updated tenant context
return this.authService.generateToken({
userCode,
tenantCode: newTenantCode,
role: association.attributes.role,
});
}
}
テナント間操作
パターン1: テナント間のデータ同期
あるテナントから別のテナントへデータを同期します(例:マスターデータ配信):
// sync/tenant-sync.service.ts
@Injectable()
export class TenantSyncService {
private readonly logger = new Logger(TenantSyncService.name);
constructor(
private readonly commandService: CommandService,
private readonly dataService: DataService,
) {}
/**
* Sync master data from source to target tenants (マスターデータをソーステナントからターゲットテナントに同期)
*/
async syncMasterData(
sourceTenantCode: string,
targetTenantCodes: string[],
entityType: string,
invokeContext: IInvoke,
): Promise<{ synced: number; errors: string[] }> {
const sourcePk = `${entityType}${KEY_SEPARATOR}${sourceTenantCode}`;
const sourceData = await this.dataService.listItemsByPk(sourcePk);
let synced = 0;
const errors: string[] = [];
for (const targetTenant of targetTenantCodes) {
for (const item of sourceData.items) {
try {
await this.syncItem(item, targetTenant, invokeContext);
synced++;
} catch (error) {
errors.push(`Failed to sync ${item.id} to ${targetTenant}: ${error.message}`);
}
}
}
this.logger.log(`Synced ${synced} items to ${targetTenantCodes.length} tenants`);
return { synced, errors };
}
private async syncItem(
sourceItem: any,
targetTenantCode: string,
invokeContext: IInvoke,
): Promise<void> {
// Create new keys for target tenant
const pkParts = sourceItem.pk.split(KEY_SEPARATOR);
const entityType = pkParts[0];
const targetPk = `${entityType}${KEY_SEPARATOR}${targetTenantCode}`;
const targetId = generateId(targetPk, sourceItem.sk);
await this.commandService.publishSync({
pk: targetPk,
sk: sourceItem.sk,
id: targetId,
tenantCode: targetTenantCode,
code: sourceItem.code,
name: sourceItem.name,
type: sourceItem.type,
attributes: sourceItem.attributes,
// Mark as synced from source
metadata: {
syncedFrom: sourceItem.id,
syncedAt: new Date().toISOString(),
},
}, { invokeContext });
}
}
パターン2: テナント横断レポート
レポート用にテナント横断でデータを集計します:
// reporting/cross-tenant-report.service.ts
@Injectable()
export class CrossTenantReportService {
constructor(private readonly prismaService: PrismaService) {}
/**
* Get aggregated metrics across all tenants (全テナントの集計メトリクスを取得)
*/
async getSystemMetrics(): Promise<SystemMetrics> {
const [totalProducts, productsByTenant, recentOrders] = await Promise.all([
// Total count across all tenants
this.prismaService.product.count({
where: { isDeleted: false },
}),
// Count by tenant
this.prismaService.product.groupBy({
by: ['tenantCode'],
_count: { id: true },
where: { isDeleted: false },
}),
// Recent orders across all tenants (admin only)
this.prismaService.order.findMany({
where: { isDeleted: false },
orderBy: { createdAt: 'desc' },
take: 100,
}),
]);
return {
totalProducts,
productsByTenant: productsByTenant.map(t => ({
tenantCode: t.tenantCode,
count: t._count.id,
})),
recentOrdersCount: recentOrders.length,
};
}
/**
* Get tenant-specific metrics (テナント固有のメトリクスを取得)
*/
async getTenantMetrics(tenantCode: string): Promise<TenantMetrics> {
const [products, orders, users] = await Promise.all([
this.prismaService.product.count({
where: { tenantCode, isDeleted: false },
}),
this.prismaService.order.count({
where: { tenantCode, isDeleted: false },
}),
this.prismaService.user.count({
where: { tenantCode, isDeleted: false },
}),
]);
return { tenantCode, products, orders, users };
}
}