Skip to main content
backend2025年9月14日3分で読めます

マルチテナントSaaSバックエンドの構築方法

データベースの分離戦略から認証、課金、機能フラグまで、マルチテナントSaaSアーキテクチャの実践的なガイド。

saasarchitecturenodejs
マルチテナントSaaSバックエンドの構築方法

アプリケーションが複数の組織にサービスを提供すると決めた瞬間から、すべてのアーキテクチャ上の決定が分岐します。データベース設計、認証、認可、課金、さらにはロギングに至るまで、すべてが「どのテナント向けか?」という問いを伴うようになります。

私は非常に異なるドメインでマルチテナントバックエンドをリリースしてきました。本番環境で生き残るパターンは、ホワイトボード上で最もきれいに見えるものとは限りません。この記事では、実際に重要な決定事項、誰も教えてくれないトレードオフ、そしてすぐに使える具体的な実装パターンについて説明します。

データベース分離戦略

これは最初にして最も重要な決定です。これを誤ると、コードベース内のすべてのクエリに影響する移行作業が必要になります。

主なアプローチは3つあります。

戦略 分離度 複雑さ コスト 最適な用途
共有DB、共有スキーマ 最低 初期段階のSaaS、テナント数100未満
共有DB、テナントごとのスキーマ 中規模、規制産業
テナントごとのデータベース 最高 エンタープライズクライアント、厳格なコンプライアンス

共有データベース、共有スキーマ

すべてのテナントのデータは同じテーブルに格納されます。行はtenant_idカラムで区別されます。ほとんどのSaaS製品はここから始めるべきです。

利点はシンプルさです。単一のマイグレーションパス、単一のコネクションプール、簡単なクエリ。欠点は、WHERE tenant_id = ?句が欠落しているとデータ漏洩につながる可能性があることです。インフラレベルでのセーフティネットはなく、分離は完全にアプリケーションコードに依存します。

このアプローチは、大規模なテナントのクエリが他のすべてのテナントのパフォーマンスを低下させ始めるか、エンタープライズ顧客のコンプライアンスチームが競合他社からデータが物理的にどのように分離されているかを尋ねるまで機能します。その時点で、あなたは次の段階に進みます。

テナントごとのスキーマ

各テナントは、共有データベースインスタンス内に独自のPostgreSQLスキーマ(または同等のもの)を持ちます。テーブルはスキーマ間で同一ですが、物理的に分離されています。接続時に検索パスを切り替えます。

これにより、多数のデータベースインスタンスを管理することなく、真の分離を実現できます。マイグレーションはより複雑になります(N回実行することになります)が、node-pg-migrateやPrismaのマルチスキーマサポートのようなツールはこれを適切に処理します。

注意点:コネクションプーリングが複雑になります。PgBouncerを使用している場合、スキーマ切り替えがプールされた接続とどのように相互作用するかを慎重に考慮する必要があります。私は、トランザクションの途中で接続がプールに戻されたために、テナントAが一時的にテナントBのスキーマを読み取ってしまうという微妙なバグを見たことがあります。

テナントごとのデータベース

最大の分離。各テナントは専用のデータベースインスタンス(または少なくとも専用の論理データベース)を持ちます。テナントのコンテキストに基づいて接続を動的にルーティングします。

これは高価で運用負荷が高いですが、一部の顧客はこれに費用を払います。医療や金融サービスではしばしば要求されます。分離以外の主な利点は、テナントを個別にスケール、バックアップ、復元できることです。騒がしい隣人が共有インスタンスをダウンさせることはありません。

実際には、ほとんどのチームはハイブリッド方式を採用しています。大部分には共有DBを使用し、費用を支払うエンタープライズアカウントには専用インスタンスを提供します。

認証とテナント解決

すべてのインバウンドリクエストは、2つの質問に答える必要があります。「このユーザーは誰か?」そして「どのテナントに属しているか?」

テナントコンテキストを解決するための一般的な戦略は3つあります。

サブドメインベース: acme.yourapp.comがAcmeテナントにマッピングされます。クリーンで直感的であり、各顧客が独自のワークスペースを持つB2B製品に最適です。Hostヘッダーからテナントスラッグを抽出します。

JWTクレームベース: テナントIDはログイン時にアクセストークンに埋め込まれます。インフラレベルのルーティングは不要です。サブドメインルーティングが非現実的なモバイルアプリやSPAに適しています。

ヘッダーベース: クライアントがX-Tenant-IDヘッダーを送信します。シンプルですが、認証されたユーザーの権限に対して検証されない場合、危険です。内部のサービス間呼び出しにのみ使用してください。

ほとんどのシステムでは、初期ログインフローにはサブドメイン解決を、その後のAPI呼び出しにはJWTクレームを組み合わせています。ミドルウェアは次のようになります。

import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";

export async function tenantMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // 1. まずJWTクレームを試す(認証済みリクエスト)
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (token) {
    const payload = verifyToken(token);
    if (payload?.tenantId) {
      req.tenantId = payload.tenantId;
      return next();
    }
  }

  // 2. サブドメイン解決にフォールバックする
  const host = req.hostname;
  const slug = host.split(".")[0];

  if (slug === "www" || slug === "app") {
    return res.status(400).json({ error: "Tenant not identified" });
  }

  const tenant = await getTenantBySlug(slug);
  if (!tenant || !tenant.isActive) {
    return res.status(404).json({ error: "Tenant not found" });
  }

  req.tenantId = tenant.id;
  next();
}

これをルートハンドラーの前にマウントすると、すべてのダウンストリーム関数がreq.tenantIdにアクセスできるようになります。アプリケーションの残りの部分は、テナント解決がどのように機能するかを考える必要がなく、リクエストコンテキストからIDを読み取るだけです。

すべてのデータベースクエリのスコープ設定

すべてのリクエストにテナントコンテキストが設定されたら、それがすべてのクエリに適用されることを保証する必要があります。スコープの欠落はデータ漏洩につながります。これは開発者の規律に任せるべきことではありません。

私が見つけた最良のパターンは、スコープを自動的に強制するリポジトリ層です。

import { db } from "../lib/database";

export function scopedQuery(tenantId: string) {
  return {
    async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .select("*");
    },

    async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .first();
    },

    async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
      return db(table)
        .insert({ ...data, tenant_id: tenantId })
        .returning("*")
        .then((rows) => rows[0]);
    },

    async update(
      table: string,
      where: Record<string, unknown>,
      data: Record<string, unknown>
    ): Promise<number> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .update(data);
    },

    async remove(table: string, where: Record<string, unknown>): Promise<number> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .del();
    },
  };
}

ルートハンドラーでは、生のクエリを構築することはありません。常にスコープ付きインターフェースを介して行います。

app.get("/api/projects", async (req, res) => {
  const query = scopedQuery(req.tenantId);
  const projects = await query.findMany("projects", { archived: false });
  res.json(projects);
});

PrismaのようなORMを使用している場合、findManyfindFirstupdatedeleteのすべての呼び出しにtenant_idフィルターを注入するミドルウェアで同じことを実現できます。要点は、安全でないパスを安全なパスよりも難しくすることです。

テナント内のロールベースアクセス制御

マルチテナンシーは2層の認可モデルを導入します。第一に、このユーザーはこのテナントに属しているか?第二に、そのテナント内で何ができるか?

私はtenant_membershipsテーブルを使ってこれをシンプルに保っています。

CREATE TABLE tenant_memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, tenant_id)
);

ロールは通常、owneradminmemberであり、時にはviewerやドメイン固有のロールもあります。製品が本当に必要としない限り、完全な権限マトリックスを構築することは避けてください。ほとんどのB2B SaaSは3〜4つのロールで十分です。

認可チェックはテナント解決後に行われます。

export function requireRole(...roles: string[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const membership = await db("tenant_memberships")
      .where({ user_id: req.userId, tenant_id: req.tenantId })
      .first();

    if (!membership || !roles.includes(membership.role)) {
      return res.status(403).json({ error: "Insufficient permissions" });
    }

    req.userRole = membership.role;
    next();
  };
}

// 使用例
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
  // オーナーと管理者のみがこのハンドラーに到達する
});

後々の悩みを減らすための設計上の選択肢が1つあります。ユーザーが常に複数のテナントに属することを許可することです。たとえ今日の製品が単一のワークスペースであっても、代理店アカウント、ホワイトラベル再販業者、またはコンサルタントモードを追加した瞬間に、それが必要になります。

テナントごとの機能フラグ

すべてのテナントがすべての機能を見るべきではありません。プランティアによって機能を制限する場合もあれば、段階的なロールアウトを実行する場合もあります。また、エンタープライズクライアントがカスタム機能に費用を支払っている場合もあります。

私はサードパーティの機能フラグサービスを導入するのではなく、シンプルなtenant_featuresテーブルを使用しています。ほとんどのSaaS製品にとって、これで十分です。

// キャッシュバックされた機能フラグチェック
const featureCache = new Map<string, Set<string>>();

export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
  if (!featureCache.has(tenantId)) {
    const rows = await db("tenant_features")
      .where({ tenant_id: tenantId, enabled: true })
      .select("feature_key");

    featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
  }

  return featureCache.get(tenantId)!.has(feature);
}

// プラン変更または手動切り替え時に無効化
export function invalidateFeatureCache(tenantId: string) {
  featureCache.delete(tenantId);
}

ルートハンドラーでは:

app.post("/api/analytics/export", async (req, res) => {
  if (!(await hasFeature(req.tenantId, "analytics_export"))) {
    return res.status(403).json({
      error: "This feature is not available on your current plan",
      upgrade: true,
    });
  }
  // エクスポートを続行
});

テナントがプランをアップグレードすると、関連する機能行を切り替え、キャッシュを無効にします。これはシンプルで監査可能であり、外部依存関係もありません。

課金連携

Stripeがここで標準であるのには理由があります。マッピングは自然です。テナントごとに1つのStripe Customer、テナントごとに1つのSubscription、プランティアはProductsとPricesにマッピングされます。

重要な設計ルール:Stripe Customer IDはユーザーレコードではなく、テナントレコードに保存してください。 課金は組織に属し、クレジットカードを入力した個人に属するものではありません。

Webhookのフローは次のようになります。

  1. checkout.session.completed — Stripe Customer IDをテナントに紐付け、サブスクリプションを有効化します。
  2. invoice.paid — 請求期間を延長し、tenant.paidUntilを更新します。
  3. invoice.payment_failed — テナントを延滞としてマークし、督促メールを送信します。
  4. customer.subscription.updated — プランティアの変更を同期し、それに応じて機能フラグを更新します。
  5. customer.subscription.deleted — フリーティアにダウングレードするか、停止します。

鍵となるのは冪等性です。Stripeは同じWebhookを複数回送信することがあります。すべてのハンドラーは、同じイベントペイロードで2回実行しても安全であるべきです。

データ分離とセキュリティ

クエリのスコープ設定以外にも、早期に正しく行うべきいくつかの目立たない点があります。

行レベルセキュリティ (RLS): PostgreSQLを使用している場合、RLSを第二の防衛線として有効にしてください。アプリケーションコードにバグがあったとしても、データベース自体が現在のテナントコンテキストに一致しない行を返すことを拒否します。接続時にapp.current_tenantを設定し、それに対してポリシーを記述します。

ロギングコンテキスト: すべてのログ行にtenantIdを含めるべきです。午前3時に何かが壊れたとき、リクエストトレースを掘り下げることなく、どのテナントが影響を受けているかを知る必要があります。ロガーコンテキストにテナントフィールドが組み込まれた構造化ロギングは、これを自動的に行います。

バックアップとデータ削除: テナントは解約し、GDPRやデータ削除の権利を行使する場合があります。すべてのデータが共有テーブルにある場合、単一のテナントをパージするには、すべてのテーブルに対して慎重に作成されたDELETEステートメントが必要です。スキーマごとまたはデータベースごとのテナントの場合、それはDROP SCHEMAまたはDROP DATABASEです。初日からこれを計画してください。

テナントごとのレート制限: グローバルなレートリミッターでは不十分です。あるテナントの一括インポートが、別のテナントのレート予算を食い潰すべきではありません。レートリミッターをIPやユーザーだけでなく、tenantIdでスコープ設定してください。

保存時の暗号化: 機密性の高い業界では、テナントデータをテナントごとのキーで保存時に暗号化します。これによりキー管理の複雑さが増しますが、テナントのアクセスを取り消すことがキーを破棄するのと同じくらい簡単になります。オフボーディング後もディスク上に読み取り可能なデータが残ることはありません。

最後に

マルチテナンシーはインストールするライブラリではありません。データベースからCDNまで、スタックのあらゆる層に影響を与える一連のアーキテクチャ上の決定です。幸いなことに、そのパターンはよく理解されています。難しいのは、特定の市場と段階に適したレベルの分離でそれらを適用することです。

すべてを共有する状態と、規律あるスコープ層から始めましょう。顧客が要求するにつれて分離境界を追加します。まだ存在しないコンプライアンス要件のために過剰な設計をしないようにしつつ、書き換えなしでより厳格な分離へとアーキテクチャを進化させられるようにしてください。

私はレストランプラットフォームから医療SaaSまで、それぞれ独自の分離とスケーリング要件を持つマルチテナントシステムを構築してきました。この記事で紹介したパターンは、それらすべてで通用したものです。

DU

Danil Ulmashev

Full Stack Developer

一緒にお仕事しませんか?