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 点だけだ:
- DB スキーマに tenantId カラムを追加:
bookingsテーブルにtenant_idを追加 - 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 と商用化準備を同時に進める実現可能な戦略を支えている。