Engineering ai-driven-dev 13 min read

bateson を single-tenant 実装で multi-tenant 化を妨げない Phase 設計

Phase A の single-tenant 実装が Phase B 以降の multi-tenant 化を阻まないための 3 規約。tenantId 全引数化・adapter DI・cancelToken hash で破壊的変更なしに SaaS 化を準備。

公開 2026-05-26 森本拓見

Phase A(single-tenant MVP)の実装が完了した後、「Phase B で multi-tenant にしようとしたら全書き換えになった」というパターンは珍しくない。予約エンジン bateson の設計では、この問題を Phase A の段階で 3 つの規約を守ることで構造的に回避している。tenantId を全 API 引数に持たせ、adapter に副作用を委譲し、cancelToken を hash 化する。この 3 規約を守れば、Phase B への移行は「スキーマ追加」だけで済み、API のフルリライトは不要になる。bateson は現在 Phase 3b フェーズにあり、これらの規約を実際に適用中だ。詳細なアーキテクチャは親 pillar「Booking Engine を自作する設計戦略」を参照してほしい。

bateson の Phase 構成は全 4 段階だ。Phase A が single-tenant MVP、Phase B が multi-tenant リファクタ、Phase C が repo extraction、Phase D が productize という構成になっている。


Phase A → B の境界で起きる破壊的変更の正体

tenantId を後付けすると全 API のシグネチャが変わる理由

Phase A で tenantId を設計に含めなかった場合、Phase B での追加は全 API のシグネチャ変更を意味する。

// Phase A(tenantId なし)
async function listAvailableSlots(dateRange: DateRange): Promise<Slot[]>
async function createBooking(input: BookingInput): Promise<Booking>
async function cancelBooking(bookingId: string): Promise<void>

// Phase B で tenantId を追加すると...
async function listAvailableSlots(tenantId: string, dateRange: DateRange): Promise<Slot[]>
async function createBooking(tenantId: string, input: BookingInput): Promise<Booking>
async function cancelBooking(tenantId: string, bookingId: string): Promise<void>

全 API のシグネチャが変わる。呼び出し側(フロントエンド / webhook 等)も全て修正が必要になる。これが「Phase B で全書き換え」の実態だ。

adapter なし実装が Phase B で外部依存の全 mock 化を要求する理由

テストなしで Phase A を実装した場合、Phase B でテストを書こうとすると問題が起きる。外部依存(カレンダー API / メール送信 / DB など)が実装コードに直接埋め込まれており、モックに差し替える手段がない。

// adapter なしの Phase A 実装(よくある例)
async function createBooking(input: BookingInput) {
  await googleCalendar.createEvent(...)  // 直接呼んでいる
  await sendgrid.sendEmail(...)          // 直接呼んでいる
  await db.save(...)                     // 直接呼んでいる
}

Phase B で multi-tenant テストを書こうとすると、googleCalendar / sendgrid / db の全てをテスト用に差し替える仕組みが必要になる。結果的に大規模なリファクタを強いられる。

token の単純 ID が Phase B で tenant 越境バグを生む理由

Phase A で cancelToken = bookingId(単純な ID)として実装した場合、Phase B で複数テナントが混在すると問題が起きる。

テナント A の bookingId: "12345" とテナント B の bookingId: "12345" が衝突するケース、またはテナント A のユーザーがテナント B のキャンセルトークンを入手して B の予約を不正キャンセルするケースが発生しうる。


規約 1: tenantId を全 API 引数に持たせる

Phase A では 1 固定値を渡すだけでよい(変更コストゼロ)

Phase A では tenantId を全 API 引数に持たせるが、実際には固定値を渡すだけでよい:

const PHASE_A_TENANT_ID = "yakumo" as const

// Phase A での使用
const slots = await listAvailableSlots(PHASE_A_TENANT_ID, dateRange)

Phase B では PHASE_A_TENANT_ID の固定値を、DB から取得した動的な tenantId に差し替えるだけだ。API のシグネチャは変わらない。

listAvailableSlots / createBooking / cancelBooking の引数例

interface BookingEngine {
  listAvailableSlots(tenantId: string, dateRange: DateRange): Promise<Slot[]>
  createBooking(tenantId: string, input: BookingInput): Promise<Booking>
  cancelBooking(tenantId: string, token: string): Promise<void>
  rescheduleBooking(tenantId: string, token: string, newSlot: Slot): Promise<Booking>
}

全 API に tenantId が第 1 引数として含まれる。この interface に従って実装すれば、Phase A の実装資産は Phase B でもそのまま使える。

context で持たせてはいけない理由(テスト困難 / 暗黙依存)

React.createContext / AsyncLocalStorage / グローバル変数で tenantId を保持するアプローチは避ける。

  • テストが困難: context を設定しないとテストが動かない。並行テストで context が混在する
  • 暗黙依存: 「tenantId は引数に渡さなくていい」という慣習が広まると、後から追跡が難しくなる
  • 型による保証がない: TypeScript の型チェックが機能しない

明示的な引数渡しは冗長に見えるが、型安全性とテスト容易性を担保する。


規約 2: adapter DI で外部依存を差し替え可能にする

Calendar / Email / Storage / Scheduler の 4 adapter interface

bateson では 4 種の adapter interface を定義している:

interface CalendarAdapter {
  createEvent(tenantId: string, event: CalendarEvent): Promise<string>
  cancelEvent(tenantId: string, eventId: string): Promise<void>
}

interface EmailAdapter {
  sendConfirmation(tenantId: string, booking: Booking): Promise<void>
  sendReminder(tenantId: string, booking: Booking): Promise<void>
}

interface StorageAdapter {
  saveBooking(tenantId: string, booking: Booking): Promise<void>
  findBooking(tenantId: string, bookingId: string): Promise<Booking | null>
}

interface SchedulerAdapter {
  scheduleReminder(tenantId: string, at: Date, bookingId: string): Promise<void>
  cancelReminder(tenantId: string, bookingId: string): Promise<void>
}

これらは interface 定義だけで、concrete implementation(Google Calendar 実装 / Sendgrid 実装 等)は別ファイルに分離する。

createBookingCore の DI 構造 — 副作用は全て adapter に委譲

async function createBookingCore(
  tenantId: string,
  input: BookingInput,
  adapters: {
    calendar: CalendarAdapter
    email: EmailAdapter
    storage: StorageAdapter
    scheduler: SchedulerAdapter
  }
): Promise<Booking> {
  // ビジネスロジックのみ。副作用は全て adapters 経由
  const booking = buildBooking(tenantId, input)
  const eventId = await adapters.calendar.createEvent(tenantId, toCalendarEvent(booking))
  await adapters.storage.saveBooking(tenantId, { ...booking, eventId })
  await adapters.email.sendConfirmation(tenantId, booking)
  await adapters.scheduler.scheduleReminder(tenantId, booking.reminderAt, booking.id)
  return booking
}

createBookingCore はビジネスロジックだけを持ち、副作用の全ては adapter 経由だ。テスト時は adapter をモックに差し替えれば、外部 API を呼ばずにビジネスロジックを検証できる。

Phase A では concrete impl を 1 種類だけ用意すれば十分

Phase A での実装は concrete impl を 1 種類だけ用意すればよい:

// Phase A での concrete impl
const adapters = {
  calendar: new GoogleCalendarAdapter(),
  email: new SendgridEmailAdapter(),
  storage: new PostgresStorageAdapter(),
  scheduler: new PgBossSchedulerAdapter(),
}

Phase B で multi-tenant が必要になったとき、PostgresStorageAdapter をテナント別スキーマ対応版に差し替えるだけだ。interface は変わらないため、createBookingCore 本体は修正不要だ。


規約 3: cancelToken を hash 化して tenant 越境を防ぐ

hash = HMAC(tenantId + bookingId) の設計

function generateCancelToken(tenantId: string, bookingId: string): string {
  return hmac(process.env.CANCEL_TOKEN_SECRET, `${tenantId}:${bookingId}`)
}

HMAC(Hash-based Message Authentication Code)で tenantId と bookingId を組み合わせて hash を生成する。CANCEL_TOKEN_SECRET は環境変数から取得する(ハードコード禁止)。

検証時に tenantId を含めることで越境チェックが自動化

function verifyCancelToken(tenantId: string, bookingId: string, token: string): boolean {
  const expected = generateCancelToken(tenantId, bookingId)
  return timingSafeEqual(token, expected)
}

検証時に tenantId を含めて再計算するため、テナント A の token をテナント B のコンテキストで使おうとすると hash が一致せず自動的に拒否される。「越境チェック」という意識的な実装なしに、hash の仕組みが自動的に保証する。

rescheduleToken も同じパターンで実装

cancelToken と同様に rescheduleToken も HMAC 設計を適用する:

function generateRescheduleToken(tenantId: string, bookingId: string): string {
  return hmac(process.env.RESCHEDULE_TOKEN_SECRET, `${tenantId}:${bookingId}`)
}

パターンが統一されているため、新しい token 種別が増えても設計の迷いが生じない。


まとめ — Phase A の 3 規約が Phase B-D の扉を保つ

規約を守れば Phase B は「tenantId を DB に持たせる」スキーマ変更だけ

3 規約を Phase A で守った場合、Phase B での変更は次の 2 点だけだ:

  1. DB スキーマに tenantId カラムを追加: bookings テーブルに tenant_id を追加
  2. StorageAdapter を差し替え: テナント別データ分離に対応した concrete impl に切り替え

API のシグネチャ変更は不要だ。呼び出し側(フロントエンド / webhook)の修正も最小限に留まる。Phase A の実装資産が Phase B にそのまま引き継がれる。

bateson の現状 Phase と次のマイルストーン

bateson は現在 Phase 3b フェーズで、Yakumo の corporate-site /contact で dogfooding 中だ。3 規約は実際の実装に適用されており、Phase B への準備は Phase A の実装時点から組み込まれている。

具体的なビジネス戦略(SaaS 化の判断基準 / ROI 試算)については sister pillar「受託から商用ライセンスへの経営判断」を参照してほしい。Phase A の段階で将来の選択肢を潰さない設計が、dogfooding と商用化準備を同時に進める実現可能な戦略を支えている。

関連記事

SHARE X でシェア B! はてブ