Engineering ai-driven-dev 18 min read

Phase Design to Keep bateson Single-Tenant from Blocking Multi-Tenancy

Three Phase A rules prevent breaking changes at Phase B: tenantId in all API args, adapter DI, and cancelToken HMAC hashing.

Published 2026-05-26 森本拓見

After Phase A (single-tenant MVP) implementation is complete, it is not uncommon for “trying to go multi-tenant in Phase B” to end in a complete rewrite. In the bateson booking engine design, this problem is structurally avoided at the Phase A stage by adhering to three conventions: pass tenantId as an argument to every API, delegate side effects to adapters, and hash the cancelToken. Follow these three conventions and migrating to Phase B requires only “schema additions” — no full API rewrite. bateson is currently in Phase 3b, where these conventions are actively applied. For detailed architecture, refer to the parent pillar “Building Your Own Booking Engine: A Design Strategy.”

bateson’s Phase structure has four stages: Phase A is the single-tenant MVP, Phase B is the multi-tenant refactor, Phase C is repo extraction, and Phase D is productization.


What Actually Causes Breaking Changes at the Phase A → B Boundary

Why Adding tenantId After the Fact Changes Every API Signature

If tenantId is not included in the design during Phase A, adding it in Phase B means changing every API signature.

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

// When tenantId is added in Phase B...
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>

Every API signature changes. All callers — frontends, webhooks, and so on — must be updated as well. This is what a “full rewrite in Phase B” actually looks like.

Why an Adapter-Free Implementation Forces Full Mocking in Phase B

If Phase A is implemented without tests, writing them in Phase B becomes problematic. External dependencies — calendar APIs, email delivery, databases — are embedded directly in the implementation code with no way to swap them out for mocks.

// Phase A implementation without adapters (a common example)
async function createBooking(input: BookingInput) {
  await googleCalendar.createEvent(...)  // called directly
  await sendgrid.sendEmail(...)          // called directly
  await db.save(...)                     // called directly
}

When you try to write multi-tenant tests in Phase B, you need a mechanism to replace googleCalendar, sendgrid, and db with test doubles. The result is a large-scale refactor.

Why a Plain Token ID Creates Cross-Tenant Bugs in Phase B

If Phase A implements cancelToken = bookingId (a plain ID), mixing multiple tenants in Phase B causes problems.

Tenant A’s bookingId: "12345" can collide with Tenant B’s bookingId: "12345", or a user on Tenant A could obtain Tenant B’s cancel token and fraudulently cancel Tenant B’s booking.


Convention 1: Pass tenantId as an Argument to Every API

In Phase A, Just Pass a Single Fixed Value (Zero Migration Cost)

In Phase A, tenantId appears in every API signature, but in practice you simply pass a fixed value:

const PHASE_A_TENANT_ID = "yakumo" as const

// Usage in Phase A
const slots = await listAvailableSlots(PHASE_A_TENANT_ID, dateRange)

In Phase B, replace the fixed PHASE_A_TENANT_ID with a dynamic tenantId fetched from the database. The API signature does not change.

Argument Examples for listAvailableSlots, createBooking, and 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>
}

Every API includes tenantId as the first argument. Implement against this interface in Phase A and the implementation carries forward unchanged into Phase B.

Why You Should Not Carry tenantId in Context (Hard to Test / Implicit Dependency)

Avoid holding tenantId in React.createContext, AsyncLocalStorage, or global variables.

  • Hard to test: Tests won’t run unless context is set up correctly. Parallel tests risk context cross-contamination.
  • Implicit dependency: Once “you don’t need to pass tenantId as an argument” becomes a convention, tracking it down later becomes difficult.
  • No type-level guarantee: TypeScript’s type checking cannot enforce tenantId presence.

Explicit argument passing looks verbose but guarantees type safety and testability.


Convention 2: Make External Dependencies Swappable via Adapter DI

Four Adapter Interfaces: Calendar, Email, Storage, and Scheduler

bateson defines four adapter interfaces:

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>
}

These are interface definitions only. Concrete implementations — Google Calendar, Sendgrid, and so on — live in separate files.

DI Structure of createBookingCore — All Side Effects Delegated to Adapters

async function createBookingCore(
  tenantId: string,
  input: BookingInput,
  adapters: {
    calendar: CalendarAdapter
    email: EmailAdapter
    storage: StorageAdapter
    scheduler: SchedulerAdapter
  }
): Promise<Booking> {
  // Business logic only. All side effects go through 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 contains only business logic; all side effects go through adapters. In tests, swap the adapters for mocks to verify business logic without calling any external APIs.

In Phase A, One Concrete Implementation Is Enough

For Phase A, a single concrete implementation per adapter is sufficient:

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

When multi-tenancy is needed in Phase B, simply swap PostgresStorageAdapter for a version that supports per-tenant data isolation. Because the interface is unchanged, createBookingCore itself requires no modification.


Convention 3: Hash the cancelToken to Prevent Cross-Tenant Access

Design: 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) combines tenantId and bookingId to produce the hash. CANCEL_TOKEN_SECRET is read from an environment variable (no hardcoding).

Including tenantId in Verification Automates Cross-Tenant Checking

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

Because verification recomputes the hash with tenantId included, using Tenant A’s token in a Tenant B context produces a mismatched hash and is automatically rejected. Cross-tenant protection is guaranteed by the hash mechanism itself, with no explicit “cross-tenant check” logic required.

rescheduleToken Follows the Same Pattern

The HMAC design applies to rescheduleToken as well:

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

With a unified pattern, there is no ambiguity when new token types are introduced.


Summary — Three Phase A Conventions Keep the Door Open for Phases B–D

Follow the Conventions and Phase B Is Just a Schema Change

With the three Phase A conventions in place, Phase B requires only two changes:

  1. Add a tenantId column to the DB schema: add tenant_id to the bookings table.
  2. Swap the StorageAdapter: switch to a concrete implementation that supports per-tenant data isolation.

No API signature changes. Caller-side updates — frontend, webhooks — are minimal. The Phase A implementation carries forward intact into Phase B.

bateson’s Current Phase and Next Milestone

bateson is currently in Phase 3b, dogfooding on Yakumo’s corporate-site /contact. The three conventions are applied in the actual implementation, with Phase B readiness built in from the Phase A stage.

For the concrete business strategy — SaaS criteria and ROI estimation — refer to the sister pillar “The Business Decision: From Client Work to Commercial License.” Designing Phase A so it never forecloses future options is the viable strategy that lets dogfooding and commercial preparation proceed in parallel.