Engineering ai-driven-dev 41 min read

Designing a Booking Engine from Scratch — From Contract Work to Commercial License

Full disclosure of the 4 abstraction rules that promote a contract-built codebase to a commercial license. Phase A→D stage design, adapter pattern, and tenantId allow SaaS prep to advance without breaking changes, while dogfooding continues.

Published 2026-05-21 Takumi Morimoto

This article is written for engineers and architects responsible for design decisions on the bateson Booking Engine. Business perspective—management decisions, ROI, and sales funnel design for the contract-to-commercial-license transition—is in the sister pillar 「From Contract Work to Commercial License — bateson’s Management Decisions and Phase Strategy」. This article does not discuss business matters; it covers the adapter pattern, tenantId, DI, and headless UI design.

bateson is structured around 4 abstraction rules to keep single-tenant implementation from blocking multi-tenant migration, with the adapter / DI / headless UI structure that implements those rules. This article walks through the rules and the real code in src/lib/bateson/.

3 key takeaways:

  1. At Phase A, there are only 4 rules to protect: no reverse dependencies / tenantId in all API arguments / adapters swappable / templates injected. These are the commitments that keep breaking changes from Phase B onward locked out from Phase A.
  2. Include tenantId in the signature from the start. It’s fine to hard-code 'yakumo' at Phase A. What matters is that the signature exists—so the breaking change of rewriting all APIs at Phase B is avoided.
  3. Abstracting all 4 adapter types (Calendar / Email / Storage / Scheduler) behind interfaces means core logic only depends on those interfaces, and swapping implementations is just adding a new adapter.

bateson is currently dogfooding in Yakumo’s corporate site /contact flow. The future plan is to extract it as @yakumo/bateson (stating as fact context).

Technical detail is consolidated at the end of this article in → #tech-detail. Readers who want to check the actual type definitions of adapter interfaces, the DI structure of createBookingCore, and hook signatures are encouraged to start there.


Phase A → D Implementation Roadmap — Single-Tenant That Doesn’t Block Multi-Tenancy

Phase Definitions and Implementation Targets

bateson’s implementation is divided into 4 Phases. For each, we define: what to implement / what not to implement / where the abstraction boundary is / what dependencies remain.

PhaseImplementation TargetAbstraction LevelRemaining Dependencies
A. MVPSingle-tenant implementation in src/lib/bateson/, applying 4 abstraction rulesadapter interface + tenantId signature establishedtenantId: 'yakumo' fixed, contained within corporate-site
B. Multi-tenant refactorTenant-scoped storage adapter, per-tenant config branchingOnly adapter internal implementation changes (signature unchanged from A)Within corporate-site, Astro etc. dependencies remain
C. Repository extractionExtract bateson/ to a separate repository via git filter-branch, as npm package or git submodulesrc/lib/bateson/ imports nothing from corporate-siteZero dependencies (extractable)
D. ProductizationFinalize brand naming, distribution form (SaaS / OSS), sign-up flowAdapters and templates injectable from external sourcesStandalone as separate repository

Why Phase A Is a Prerequisite for Phase B-D

“Small compromises” at Phase A break the implementation for Phase B-D. Concretely:

❌ Hard-coding tenantId = 'yakumo'
  → Phase B requires a breaking change to every function signature

❌ Writing import { something } from '@/lib/i18n' even once
  → Phase C repository extraction (git filter-branch) fails

❌ Embedding Yakumo brand text directly in email templates
  → Phase D requires rewriting the entire template for every other tenant

The 4 abstraction rules are the commitments that lock these breaking changes out from Phase A. The cost of honoring the rules at Phase A corresponds to design decisions spanning a few hours to a few days; the cost of not honoring them at Phase B-D is a full-scale refactor spanning weeks to months.

Design Decisions for “What NOT to Implement” at Phase A

Explicitly stating what we intentionally do not implement at Phase A:

  • Tenant separation logic: tenantId exists as an argument, but storage doesn’t yet split data by tenant. At Phase A, all data can be in the same space
  • Per-tenant config: BookingConfig can be Yakumo-fixed values. Include extension points in the interface for Phase B branching
  • External-facing API authentication: Since calls are internal from Yakumo’s corporate site only, general API key auth is not needed. Add at Phase D

Explicitly stating “what we don’t implement” finalizes Phase A scope and makes the 4 abstraction rules function as the minimal set of “what to honor so Phase B+ won’t break.”

Current Progress (Phase A Complete)

As of May 2026, bateson has progressed to Phase 3b (equivalent to Phase A complete).

  • Adapter layer (4 types: Calendar / Email / Storage / Scheduler)
  • Core logic (createBookingCore factory + DI structure)
  • UI hooks (useAvailability / useBooking)
  • Headless components (Calendar / TimeSlots / BookingFlow)

API endpoints (/api/booking/availability, /api/booking/create, /api/booking/{token}/cancel, /api/booking/{token}/reschedule) are also implemented and integrated into corporate-site’s /contact Step 4.


The 4 Abstraction Rules — Core of Booking Engine Design

This is the core of this article. We define the 4 rules to honor at Phase A.

Rule 1: No Dependency on corporate-site (core / extension boundary design)

Files under src/lib/bateson/ do not import any corporate-site code. Only two types of dependency are allowed:

  • Within the same package: internal references like ../core/types
  • Pure npm dependencies: Node.js standard modules like node:crypto, or external npm packages like Google Calendar API client

Examples of disallowed dependencies:

❌ import { t } from '@/lib/i18n'
❌ import tailwindConfig from '../../../tailwind.config'
❌ import { useAstroRoute } from 'astro'

Only reverse dependency (corporate-site → bateson) is permitted. bateson maintains a state of “not knowing” corporate-site. This boundary is the design-level separation line between core and extension. At Phase C repository extraction, if this directional constraint is satisfied, git filter-branch can cut out src/lib/bateson/ as-is.

Rule 2: Include tenantId in Public API Signatures from the Start

At Phase A, tenantId = 'yakumo' fixed is fine. What matters is that tenantId exists in the signature.

// ✅ Even at Phase A, signatures carry tenantId
listAvailableSlots({ tenantId, eventTypeId, rangeStart, rangeEnd })
createBooking({ tenantId, eventTypeId, startAt, endAt, guest })
cancelBooking({ tenantId, cancelToken, reason? })

When multi-tenanting at Phase B, the signatures can be used as-is. It’s just a matter of splitting data areas per tenant inside the storage adapter.

The concrete cost of omitting tenantId for Phase B: breaking changes to public APIs require simultaneous changes to all callers (UI / API endpoints / E2E tests). Type errors propagate across all files, and tsc --noEmit won’t pass until the fix is complete. Rule 2 avoids that state with a single line of signature design at Phase A.

Rule 3: Abstract External System Dependencies via Adapter Interfaces

bateson depends on 4 types of external systems—calendar, email, storage, and reminder scheduler. For each, we define an interface and make implementations swappable as adapters.

bateson/adapters/
├── calendar/
│   ├── interface.ts   ← CalendarProvider type definition
│   └── google.ts      ← Google Calendar implementation
├── email/
│   ├── interface.ts   ← EmailProvider type definition
│   └── gmail.ts       ← Gmail API implementation
├── storage/
│   ├── interface.ts   ← BookingStorage type definition
│   └── google-sheets.ts ← Google Sheets implementation
└── scheduler/
    ├── interface.ts   ← ReminderScheduler type definition
    └── vercel-cron.ts ← Vercel Cron polling implementation

Yakumo’s Phase A implementation uses Google Calendar / Gmail / Sheets / Vercel Cron, but if another tenant wants Outlook / SendGrid / Postgres / AWS Lambda, adding a single adapter handles it. Core logic doesn’t change.

This design working correctly was proven by the change in reminder scheduler implementation. Early in design, Upstash QStash was being considered, but we switched to Vercel Cron + Sheets polling (zero external dependency, same stack, 1-minute precision is practically sufficient). At that point, not a single line of core logic changed—only writing a new adapter that satisfied scheduler/interface.ts’s contract.

Rule 4: Template DI (Dependency Injection)

createBookingCore is designed to receive templates as arguments.

createBookingCore({
  config,
  calendar,
  storage,
  email,
  scheduler,
  templates: {
    confirmation(input): EmailTemplateOutput { ... },
    cancellation(input): EmailTemplateOutput { ... },
    reschedule(input): EmailTemplateOutput { ... },
    reminder(input): EmailTemplateOutput { ... },
  },
  tokenSecret,
})

The confirmation / cancellation / reschedule / reminder templates are injected as functions. bateson’s core knows “when to call the templates.” It doesn’t know “what content to send.”

Yakumo’s brand text, logo, colors, and attachments are injected in src/lib/booking-yakumo/templates/. When brand replacement is needed for a future tenant, only the templates need to be swapped.

The difference between code honoring the 4 rules and code not honoring them is invisible at Phase A. When Phase B arrives, the honoring side finishes tenant separation in a few days; the non-honoring side can’t even estimate the Phase B investment.


The Name — Why bateson

Gregory Bateson’s Ecosystem / Context Theory

The name bateson comes from Gregory Bateson (1904-1980), anthropologist and epistemologist. His major work “Steps to an Ecology of Mind” (1972) is an accumulation of thought on information, context, and ecology.

Bateson’s central inquiry was the structure of “context generating meaning.” A single stimulus (message) has no meaning without context. The same stimulus means different things in different contexts. Information never exists in isolation—it always exists within context.

”The Difference That Makes a Difference” — The Engine Makes a Difference Within the Full System Context

One of Bateson’s most-cited phrases is “a difference that makes a difference.” Information is “the difference of differences” and holds meaning only in context.

The reason booking SaaS ends up as “just a scheduling tool” is that it’s disconnected from context. The name bateson expresses the design intent that “the engine doesn’t hold meaning on its own—it makes a difference within the full system context.” A consultation booking is the precursor to a business-issue discovery, the entrance to an AI agent development proposal, the path to a signed contract. Not an isolated scheduling tool, but an element within an ecology—that intent is embedded in the name.

The Named Namespace in lib

Yakumo’s src/lib/ has engines running in parallel under human names. mcluhan (Marshall McLuhan, “The medium is the message”) and bateson (Gregory Bateson, “the difference that makes a difference”) form a complementary pair.

Just as Marshall McLuhan argued “the medium itself is the message,” mcluhan engine is designed around the idea that the structure operating articles—not the article content itself—conveys the message of owned media. Just as Gregory Bateson argued “meaning is generated within context,” bateson engine situates the act of booking within the context of the flow.

The two thinkers share a common conceptual space: “media / message / context / ecology.” The naming being a complementary pair is not coincidence—it is the result of expressing design intent through thinkers’ names.


Technical Detail: Implementing the 4 Rules {#tech-detail}

From here, we use actual code fragments to explain the 4 abstraction rule implementations concretely. Until tech spokes (detailed articles) are ready, this H2 is the most detailed technical documentation available.

The Core of the Adapter Pattern — 4 Interface Definitions

The 4 adapter types are each defined as TypeScript interfaces. These type contracts are what guarantee the swappability of implementations.

CalendarProvider (adapters/calendar/interface.ts)

export interface CalendarProvider {
  freeBusy(input: FreeBusyInput): Promise<FreeBusyResult>;
  createEvent(input: CreateCalendarEventInput): Promise<CreateCalendarEventResult>;
  updateEvent(input: UpdateCalendarEventInput): Promise<void>;
  deleteEvent(input: DeleteCalendarEventInput): Promise<void>;
}

freeBusy retrieves busy times to compute available slots. createEvent creates a calendar event and returns a Google Meet URL (when createMeetUrl: true).

EmailProvider (adapters/email/interface.ts)

export interface EmailProvider {
  send(input: SendEmailInput): Promise<SendEmailResult>;
}

SendEmailInput carries tenantId / to / subject / html / text / attachments. Yakumo implements it with the Gmail API, but swapping to a SendGrid implementation doesn’t change core.

BookingStorage (adapters/storage/interface.ts)

export interface BookingStorage {
  create(input: CreateBookingStorageInput): Promise<{ id: string }>;
  findByTokenHash(tenantId: TenantId, tokenHash: string): Promise<Booking | null>;
  findById(tenantId: TenantId, id: string): Promise<Booking | null>;
  update(tenantId: TenantId, id: string, patch: Partial<...>): Promise<void>;
  logEvent(input: LogEventInput): Promise<void>;
  listDueReminders(input: ListDueRemindersInput): Promise<Booking[]>;
}

Yakumo uses a Google Sheets implementation. The reasoning: the SSOT for customer data was already in existing Sheets, and adding a new external dependency wasn’t warranted. Swapping to Postgres / Notion is just adding one adapter.

findByTokenHash is central to the cancel / reschedule flow. It locates a booking by matching the hash of a token passed to the user via email. Storing only the hash means raw tokens never appear in Sheets.

ReminderScheduler (adapters/scheduler/interface.ts)

export interface ReminderScheduler {
  schedule(input: ScheduleInput): Promise<ScheduleResult>;
  cancel(input: CancelScheduleInput): Promise<void>;
}

Yakumo implements this with Vercel Cron + Sheets polling. schedule records the reminder delivery time in the next_reminder_at column of Sheets, and Vercel Cron polls every minute to deliver.

createBookingCore DI Structure — Side Effects All Delegated to Adapters

All core functionality is contained in the object returned by the createBookingCore factory function. The factory receives 4 adapters + config + templates + tokenSecret as deps.

export function createBookingCore(deps: {
  config: BookingConfig;
  calendar: CalendarProvider;
  storage: BookingStorage;
  email: EmailProvider;
  scheduler: ReminderScheduler;
  templates: {
    confirmation(input: TemplateInput): EmailTemplateOutput;
    cancellation(input: TemplateInput): EmailTemplateOutput;
    reschedule(input: TemplateInput): EmailTemplateOutput;
    reminder(input: TemplateInput): EmailTemplateOutput;
  };
  tokenSecret: string;
}): BookingCore {
  // ...
  return { listAvailableSlots, createBooking, cancelBooking, rescheduleBooking, processDueReminders };
}

Side effects (calendar operations / email sends / storage writes / scheduler registration) occurring within the 5 methods returned by the BookingCore interface are all executed through the injected adapters.

Three benefits of this structure:

  1. Testability: Swapping adapters for mocks allows unit tests. Core logic can be verified without calling real Google Calendar / Gmail / Sheets
  2. Environment swapping: Different adapters can be injected in staging / production (e.g., staging uses a different Sheets sheet)
  3. Repository independence: Core only depends on adapter interfaces, so after Phase C repository extraction, only adapter implementations need swapping

tenantId Abstraction — Single-Tenant That Doesn’t Block Multi-Tenancy

Define TenantId = string in core/types.ts and include it in all I/O types.

// core/types.ts
export type TenantId = string;

export type ListAvailableSlotsInput = {
  tenantId: TenantId;
  eventTypeId: string;
  rangeStart: string;   // ISO 8601
  rangeEnd: string;
};

export type CreateBookingInput = {
  tenantId: TenantId;
  eventTypeId: string;
  startAt: string;
  endAt: string;
  guest: Guest;
  metadata?: Record<string, unknown>;
};

export type CancelBookingInput = {
  tenantId: TenantId;
  cancelToken: string;
  reason?: string;
};

At Phase A, tenantId: 'yakumo' is passed as a constant. At Phase B, data areas within the storage adapter just need to be split using tenantId as the key. For the Sheets implementation, using “tenantId + bookingId” as the row key completes tenant separation.

The cancelToken / rescheduleToken design is also consistent with the tenantId strategy. cancelToken === rescheduleToken (same token) is a design decision that allows storing a single hash in one Sheets column. Tokens are generated with HMAC-SHA256(bookingId:action, tokenSecret), and only SHA256(token) hash is stored in Sheets.

UI Also Goes Headless — Hooks and Headless Components

The UI layer also follows bateson’s 4 rules, separating logic from presentation.

useAvailability hook

const { slots, isLoading, error, refetch } = useAvailability({
  tenantId: 'yakumo',
  eventTypeId: 'initial-meeting',
  rangeStart: '2026-06-01T00:00:00Z',
  rangeEnd:   '2026-06-02T00:00:00Z',
});

Fetches POST /api/booking/availability and returns available slots as AvailableSlot[]. Auto-refetches when rangeStart / rangeEnd change. Errors are returned as Error objects for the component to handle.

useBooking hook

const { isSubmitting, error, bookingId, meetUrl, submit } = useBooking({
  tenantId: 'yakumo',
  eventTypeId: 'initial-meeting',
});

await submit({ startAt, endAt, guest });

Calls POST /api/booking/create. On success, saves bookingId / meetUrl to state. Prevents double-submit during isSubmitting.

Calendar / TimeSlots / BookingFlow components

All 3 components are headless-leaning with minimal built-in styles (cursor / display only). They are Tailwind / CSS framework agnostic, with a classNames prop for injecting styles.

<Calendar
  value={selectedDate}
  onSelectDate={setSelectedDate}
  workingDays={[1, 2, 3, 4, 5]}
  advanceNoticeHours={24}
  maxAdvanceDays={30}
  timezone="Asia/Tokyo"
  locale="ja"
  classNames={{
    root: '...',
    daySelected: 'bg-accent text-white',
    dayDisabled: 'opacity-30',
  }}
/>

Accessibility implemented with <table role="grid"> and aria-pressed, plus keyboard navigation (arrow keys).

BookingFlow is a high-level component integrating Calendar + TimeSlots + Submit button, for cases where assembling the 3 components individually is not needed.

<BookingFlow
  tenantId="yakumo"
  eventTypeId="initial-meeting"
  guest={{ name: 'Taro Yamada', email: 'yamada@example.com' }}
  onSuccess={({ bookingId, meetUrl }) => { /* completion handling */ }}
  onError={(err) => { /* error handling */ }}
  workingDays={[1, 2, 3, 4, 5]}
  advanceNoticeHours={24}
  maxAdvanceDays={30}
  timezone="Asia/Tokyo"
  locale="ja"
/>

cancelToken / rescheduleToken Hash Design and Cross-Tenant Prevention

The design decision cancelToken === rescheduleToken prioritizes simplicity—keeping the cancel_token_hash column in Sheets to a single column.

// Token generation: HMAC-SHA256
function generateToken(secret: string, bookingId: string, action: 'cancel' | 'reschedule'): string {
  const mac = createHmac('sha256', Buffer.from(secret, 'hex'));
  mac.update(`${bookingId}:${action}`);
  return base64url(mac.digest());
}

// Storage only holds the hash
function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

Cross-tenant prevention is ensured by findByTokenHash taking tenantId as an argument. Only rows where both token hash and tenantId match are returned. Even if you know another tenant’s token, you can’t manipulate their booking if the tenantId differs.

Implementation detail for each rule will be published as tech spokes when ready. This article as a pillar presents concepts and structure; the deep dives are delegated to each spoke.

Spokes in preparation (publication dates TBD):

  • Adapter pattern detail — Implementation walkthrough for Calendar / Email / Storage / Scheduler (2026-06-bateson-adapter-pattern-detail)
  • tenantId scoped storage — Multi-tenant separation implementation in Sheets (2026-06-bateson-tenantid-storage-scoping)
  • Headless UI classNames injection — classNames prop design in detail (2026-06-bateson-headless-ui-classnames)
  • cancelToken design — HMAC generation, hash storage, cross-tenant prevention detail (2026-06-bateson-cancel-token-design)

Summary — Booking Engine Design Decisions and Phase B-D Extension Room

The 4 Rules Are a Structuring of Design Responsibility

Restating the 4 rules:

  1. Reverse dependency only: src/lib/bateson/ imports nothing from corporate-site
  2. tenantId in all arguments: All public APIs have tenantId as an argument from Phase A
  3. Adapters swappable: All 4 external dependencies—Calendar / Email / Storage / Scheduler—closed behind interfaces
  4. Templates injected: Email copy and brand elements are passed as injectable functions to createBookingCore

These are commitments to maintain implementation invariants. No need to complicate the architecture. Just explicitly state the constraints to honor and apply them mechanically from Phase A, and the path to Phase B-D is secured.

The Next Implementation Challenges for Phase B-D

When Phase B (multi-tenant refactor) becomes necessary, with the 4 rules honored, only the following are needed:

  • Storage adapter internal implementation: add tenant-area filter by tenantId to all read / write operations
  • BookingConfig per-tenant branching: change currently Yakumo-fixed config values to a Map keyed by tenantId

No signature changes needed. Callers (UI / API endpoints / E2E tests) work with Phase A code as-is.

When Phase C (repository extraction) becomes necessary, if Rule 1 (reverse dependency only) is satisfied, git filter-branch can extract src/lib/bateson/ to a separate repository. The corporate-site side just needs to rewrite import targets to the npm package or git submodule.

The distribution form for Phase D (productization)—SaaS / OSS / closed license—can remain undecided through Phase A-C. Lock it in after gaining hands-on feel through dogfooding. The only thing Phase A needs is “design decisions that don’t close off Phase D options”—and those are already implemented as the 4 rules.

Phase A Design Cost and Phase B-D Savings

The additional cost of honoring the 4 rules corresponds to design discipline spanning a few hours to a few days at Phase A. Compared to an implementation not honoring them, the functional outcome is the same. Including tenantId in arguments, abstracting external dependencies behind interfaces, making templates injectable—these are not feature additions; they are structural matters.

This structural cost prevents weeks-to-months of rework across Phase B-D. For questions about bateson’s design, or consultation on adapter / tenantId / DI design patterns, please reach out via the contact form at the bottom of this article.