saas
SaaSの最先端 — マルチテナンシー、従量課金、PLG、AI機能統合、最新の可観測性、コンプライアンス、セキュリティを本番SaaSプロダクトに適用します。
手順
マルチテナンシー
ユースケースに最適な分離モデルを選択してください:
| モデル | 説明 | 使用すべきケース |
|---|---|---|
| 共有スキーマ | すべてのテナントがテーブルを共有。行は tenant_id でタグ付け | 初期段階、コスト重視、シンプルなコンプライアンス |
| テナント別スキーマ | テナントごとに個別のDBスキーマ | 中程度の分離、テナントごとの移行が容易 |
| テナント別DB | テナントごとに個別のデータベース | エンタープライズ、厳格なデータ所在地要件、高コンプライアンス(SOC 2、HIPAA) |
| ハイブリッド | コアデータは共有、機密データは隔離 | 混合層サービスを提供する規制産業 |
共有スキーマテナンシーのルール:
- すべてのクエリは 必ず
tenant_idでフィルタリング — ベースリポジトリまたはORMミドルウェアで強制し、開発者の規律に依存しない - PostgreSQL 行レベルセキュリティ(RLS) を第二の防御層として使用:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
- テナントスコープのすべてのテーブルに
tenant_idのインデックスを作成 - テナント間のデータ分離を確認する統合テストを作成
- すべてのオブジェクトストレージパスをスコープ化:
s3://bucket/{tenantId}/uploads/{fileId} - 予測可能な連番IDを公開しない — UUID(ソート可能なv7)を使用
サブスクリプションと課金
定額サブスクリプション
- 課金はプロバイダー(Stripe、Paddle、LemonSqueezy)に委任 — 自社で決済処理を実装しない
- データベースには
customerIdとsubscriptionIdのみ保存。カードデータは絶対に保存しない - Webhookを介して状態を同期 — データベースは課金プロバイダーの情報を結果整合性で読み取るレプリカとして扱う:
customer.subscription.created → プランをアクティブ化 customer.subscription.updated → アップグレード / ダウングレード / シート変更 customer.subscription.deleted → キャンセル + 猶予期間開始 invoice.payment_failed → 督促: 通知 → 制限 → キャンセル - Webhookイベント
idを冪等性キーとして使用 — 処理前に保存し、既に処理済みの場合はスキップ - 監査とリプレイのために生のWebhookペイロードを保存
従量課金(最新デフォルト)
- 使用イベントを発生時に追跡(API呼び出し、シート数、消費トークン、ストレージGB):
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, { quantity: tokensUsed, timestamp: Math.floor(Date.now() / 1000), action: 'increment', }); - 顧客にリアルタイムの使用量ダッシュボードを公開 — 請求書発行日に驚かれると解約の原因になります
- 課金期間ごとにソフトリミット(80%で警告)とハードリミット(100%でブロック)を実装
- プロダクト主導成長(PLG)の入り口として、無料枠+従量課金アップセルを検討
プロダクト主導成長(PLG)
- 無料枠 / フリーミアム — 支払いを要求する前にユーザーが価値を体験できるようにする。有料プランが明らかに価値があると分かる制限を設定
- タイムトゥバリュー(TTV) — サインアップから最初の有意義なアクション(「ahaモーメント」)までの時間を測定し最小化する
- アプリ内アップグレードプロンプト — プラン制限に達した瞬間にコンテキストに応じたアップグレードCTAを表示。一般的な設定ページではない
- プロダクト認定リード(PQL) — 単なるデモリクエストではなく、プロダクトの使用状況シグナルでユーザーをスコアリングし、営業にルーティング
- バイラル / コラボ機能 — チームメイトの招待、レポートの共有、公開ページ — 各アクションが配信チャネルになる
AI機能統合
最新のSaaSプロダクトにはAIネイティブ機能が期待されます。以下のパターンを適用:
AIアシスタントとコパイロット:
- ストリーミングレスポンス(SSE /
ReadableStream)を使用して、ユーザーがトークンを順次確認できるように — 完全な応答を待たせない - AIの出力は常に編集可能にする — ユーザーが修正、洗練、出力を所有できるようにする
- 信頼を構築するために信頼度シグナルとソース引用(RAG)を表示
検索拡張生成(RAG):
- テナントごとのベクトル埋め込みを分離された名前空間(Pinecone、pgvector、Qdrant)に保存 — テナントデータを混在させない
- 書き込み時にコンテンツを埋め込み、読み取り時にコサイン類似度でクエリ
- ドキュメントをインテリジェントにチャンク分割(512~1024トークン、オーバーラップあり)。ソースコンテンツが変更されたら再埋め込み
使用量とコスト管理:
- テナントごとのLLMトークン消費を追跡 — それに応じて課金またはレート制限
- 決定論的なプロンプト(同じ入力→同じ出力)をキャッシュしてコスト削減
- 分類/トリアージには安価なモデルを使用。生成タスクには最先端モデルを確保
データプライバシー:
- テナントデータがモデルトレーニングに使用されるかどうかを明示するデータ処理追補(DPA)を提供
- オプトアウト手段を提供。エンタープライズ顧客はこれを要求する
フィーチャーフラグとプランゲーティング
- 機能ゲートは サービス層 で行う — クライアントサイドでの非表示はUIの見栄えであり、セキュリティではない
- エンタイトルメントロジックを単一の
can(tenant, feature)ヘルパーに集約:function can(tenant: Tenant, feature: Feature): boolean { return tenant.plan.features.includes(feature); } - 機能の デプロイ と機能の リリース をフラグシステム(LaunchDarkly、Unleash、GrowthBook、または単純なDBテーブル)で分離
- フラグの用途: 段階的ロールアウト(テナントの割合)、ベータプログラム、A/Bテスト、キルスイッチ
オンボーディング
- TTVを最小化: 5分以内にユーザーを最初の有意義なアクションに導く
- 明確な進捗インジケーターを持つチェックリスト駆動のオンボーディングフローを使用
- 初回使用時にプロダクトが空っぽに見えないように、サンプル/デモデータを事前に入力
- 最初に収集する情報は最小限に抑え、その他は後回しにする
- オンボーディングメールは プロダクトイベント(時間だけでなく)に基づいてトリガー: サインアップ時に歓迎、24時間後にステップ2未完了ならナッジ、最初のマイルストーンを祝福
- インタラクティブなプロダクトツアー(ビデオではない)を提供して、ユーザーが実際に操作しながら学べるようにする
可観測性と運用
構造化ログ — すべてのログ行は以下を含むこと:
{ "level": "info", "msg": "...", "tenantId": "...", "userId": "...", "requestId": "...", "timestamp": "..." }
OpenTelemetryによる分散トレーシング:
- フレームワークレベルでインストルメント化(Next.js
instrumentation.ts、Expressミドルウェア) - サービス境界を越えてトレースコンテキストを伝播(
traceparentヘッダー) - 任意のバックエンド(Jaeger、Honeycomb、Datadog、Grafana Tempo)にエクスポート
追跡すべきテナントごとのメトリクス:
- アクティブユーザー(DAU/MAU)、API呼び出し量、エラー率、p50/p95/p99レイテンシ、機能採用率、消費ストレージ
アラート:
- テナントレベルの異常にアラート — 突然のエラースパイク、使用量低下、課金失敗
- ティアごとにSLOを定義: 無料(ベストエフォート)、プロ(99.5%)、エンタープライズ(99.9%)
管理ツール(内部向け):
- サポートのためにテナントを偽装(すべての偽装を監査)
- 監査ログの表示、サブスクリプションの管理、フィーチャーフラグの上書き、再オンボーディングのトリガー
- ヘルスダッシュボードの構築: テナントごとのステータス、最近のエラー、使用量の傾向
データ、分析、コンプライアンス
イベント追跡:
- 構造化プロダクトイベント(
user.signed_up、feature.used、subscription.upgraded)をデータウェアハウス(Snowflake、BigQuery)またはCDP(Segment、PostHog、RudderStack)に出力 - これらのイベントを解約予測、コホート分析、PLGスコアリングに使用
GDPR / プライバシー:
- データエクスポートエンドポイントとアカウント削除エンドポイントを提供 — GDPRで必須、エンタープライズバイヤーにも期待される
- データ保持ポリシーを文書化し、自動パージを実装
- マーケティングCookieと分析には同意管理を使用
SOC 2 / コンプライアンス衛生(エンタープライズ対応):
- 状態を変更するすべてのユーザーアクションの監査ログを維持(誰が、何を、いつ、どのIPから)
- 最小権限をデフォルトとするロールベースアクセス制御(RBAC)を実装
- すべての管理アカウントに多要素認証(MFA)を強制。エンタープライズテナントにSSO(SAML/OIDC)を提供
- 定期的な依存関係の脆弱性スキャンと侵入テストを実施
- 脆弱性開示ポリシーを維持
データ分離とセキュリティ
- すべてのAPIコールで
tenant_idを 認証済みセッション に対して検証 — リクエストボディを決して信頼しない - テナントごとにレート制限をかけ、騒がしい隣人問題を防止
- DB内のAPIキーはハッシュ化(bcryptまたはArgon2)。平文で保存しない
- HTTPSを必須にし、長い
max-ageでStrict-Transport-Securityを設定 - セッションCookieには
HttpOnly; Secure; SameSite=Laxを使用
例
// RLSコンテキストを持つテナントスコープリポジトリ
class TenantRepository<T> {
constructor(
private readonly tenantId: string,
private readonly db: PrismaClient,
) {}
protected async withTenantContext<R>(fn: () => Promise<R>): Promise<R> {
return this.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${this.tenantId}, true)`;
return fn();
});
}
async findById(id: string): Promise<T | null> {
return this.withTenantContext(() =>
(this.db as any).findFirst({ where: { id, tenantId: this.tenantId } })
);
}
}
// テナントごとの使用量計測を伴うAIストリーミングエンドポイント
export async function POST(req: Request) {
const session = await getSession(req);
const { prompt } = await req.json();
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
let tokensUsed = 0;
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
controller.enqueue(new TextEncoder().encode(text));
tokensUsed += chunk.usage?.completion_tokens ?? 0;
}
controller.close();
// ストリーミング完了後に使用量を記録
await meteringService.record(session.tenantId, 'ai_tokens', tokensUsed);
},
});
return new Response(readable, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
// 冪等性を持つStripe Webhook
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!
);
const seen = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } });
if (seen) return res.sendStatus(200);
await db.webhookEvent.create({ data: { stripeEventId: event.id, payload: event } });
switch (event.type) {
case 'customer.subscription.updated':
await subscriptionService.sync(event.data.object);
break;
case 'customer.subscription.deleted':
await subscriptionService.cancel(event.data.object.metadata.tenantId);
break;
}
res.sendStatus(200);
});