Skip to main content

APPLICATION_LOGIC — bff-tenant-booking-service

Sibling: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL

Cross-cutting: 02 Enterprise Architecture · §5.2 BFF orchestration · Standards · NAMING

This document specifies use cases, ports, and orchestration flows. The four-layer split mandated by SERVICE_TEMPLATE holds: domain → application → (infrastructure + presentation), with the strict additional constraint that no use case in this BFF mutates a domain aggregate. Every "write" use case is either an upstream-proxied write (hold, confirm, payment-intent) or a session-mirror write (Memorystore + Postgres outbox).

1. Use cases (catalogue)

IDUse caseTriggerReadsWrites
UC-01ResolveTenantSlugevery requesttenant-service (cached)session.tenantId
UC-02ComposeTenantBootstrapGET /bootstraptenant-service, theme-config-serviceTenantBootstrap cache; outbox bootstrap.served.v1
UC-03VerifyAndConsumeHandoffPOST /handoff/consumebff-consumer-service HMAC verifier (no network), handoff_arrival_logBookingHandoffArrival row; outbox handoff.consumed.v1
UC-04ComposeAvailabilityGET /availabilityinventory-service, property-service, pricing-servicenone
UC-05IssueQuotePOST /quotepricing-serviceBookingDraft.quote
UC-06CreateHoldPOST /holdreservation-serviceBookingDraft.hold, BookingDraft itself; outbox draft.created.v1
UC-07UpdateDraftPATCH /draft/{id}noneBookingDraft.guest, BookingDraft.flowState
UC-08CreatePaymentIntentPOST /draft/{id}/payment-intentpayment-gateway-serviceBookingDraft.paymentIntent; outbox payment_intent.created.v1
UC-09HandlePaymentReturnPOST /draft/{id}/returnpayment-gateway-service, reservation-service /confirmBookingDraft.flowState; outbox draft.converted.v1 (or flow.error_encountered.v1)
UC-10ConfirmReservationDirectPOST /draft/{id}/confirm (non-redirect rails)reservation-serviceBookingDraft.flowState confirmed; outbox draft.converted.v1
UC-11ComposeConfirmationViewGET /confirmation/{reservationId}reservation-service, billing-service, lock-integration-service, tenant-serviceoutbox confirmation.viewed.v1
UC-12SetLocalePOST /session/localenonesession.localePreference; outbox locale.changed.v1
UC-13SetCurrencyPOST /session/currencynonesession.displayCurrency; outbox currency.changed.v1
UC-14ReadSessionGET /sessionnonenone
UC-15ReadPoliciesGET /policiestenant-service, theme-config-servicenone
UC-16SweepAbandonedDraftsscheduled (every 60 s)Memorystore key TTLoutbox draft.abandoned.v1; cold mirror to booking_draft_snapshots

2. Ports

// Upstream gateways
export interface TenantPort {
resolveSlug(slug: string): Promise<TenantResolution>;
getSuspensionStatus(tenantId: TenantId): Promise<SuspensionStatus>;
getConfig(tenantId: TenantId): Promise<TenantConfig>;
}
export interface ThemeConfigPort {
getThemeBundle(tenantId: TenantId, opts: { locale: string }): Promise<ThemeBundle>;
getFlowConfig(tenantId: TenantId): Promise<FlowConfig>;
getPolicies(tenantId: TenantId, locale: string): Promise<PolicyVM[]>;
}
export interface PropertyPort {
getProperty(tenantId: TenantId, propertyId: PropertyId, locale: string): Promise<PropertyVM>;
getRoomTypes(tenantId: TenantId, propertyId: PropertyId, locale: string): Promise<RoomTypeVM[]>;
}
export interface InventoryPort {
getAvailability(tenantId: TenantId, q: AvailabilityQuery): Promise<AvailabilityResult>;
}
export interface PricingPort {
cheapestRates(tenantId: TenantId, q: CheapestRateQuery): Promise<RoomRateMap>;
issueQuote(tenantId: TenantId, q: QuoteRequest): Promise<Quote>;
}
export interface ReservationPort {
createHold(tenantId: TenantId, req: HoldRequest, idemKey: IdempotencyKey): Promise<HoldResult>;
confirm(tenantId: TenantId, req: ConfirmRequest, idemKey: IdempotencyKey): Promise<ConfirmResult>;
getReservation(tenantId: TenantId, reservationId: ReservationId): Promise<ReservationSummaryVM>;
}
export interface PaymentGatewayPort {
createIntent(tenantId: TenantId, req: PaymentIntentRequest, idemKey: IdempotencyKey): Promise<PaymentIntentVM>;
verifyReturn(tenantId: TenantId, intentId: PaymentIntentId, returnState: string): Promise<PaymentReturnVerification>;
}
export interface BillingPort {
getFolioSummary(tenantId: TenantId, reservationId: ReservationId): Promise<FolioSummaryVM>;
}
export interface LockIntegrationPort {
getCredentialPlaceholder(tenantId: TenantId, reservationId: ReservationId): Promise<KeyCredentialPlaceholderVM | null>;
}
export interface IamPort {
getLoyaltyContext?(userId: UserId): Promise<LoyaltyContext | null>; // Phase 2+
}

// Internal session storage
export interface SessionStore {
get(sessionId: string): Promise<SessionBlob | null>;
put(sessionId: string, blob: SessionBlob, ttlSec: number): Promise<void>;
patch<K extends keyof SessionBlob>(sessionId: string, key: K, value: SessionBlob[K]): Promise<void>;
}

export interface BookingDraftStore {
create(draft: BookingDraft): Promise<void>;
get(draftId: BookingDraftId): Promise<BookingDraft | null>;
patch(draftId: BookingDraftId, mutator: (d: BookingDraft) => BookingDraft, expectedUpdatedAt: string): Promise<BookingDraft>;
delete(draftId: BookingDraftId): Promise<void>;
listExpiringSoon(beforeIso: string, limit: number): Promise<BookingDraftId[]>;
snapshot(draft: BookingDraft, finalState: 'converted' | 'abandoned' | 'failed'): Promise<DraftSnapshotId>;
}

export interface HandoffArrivalRepository {
create(row: BookingHandoffArrival): Promise<void>;
markConsumed(id: HandoffArrivalId): Promise<{ alreadyConsumed: boolean }>;
}

// Telemetry
export interface OutboxPort {
enqueue<T>(subject: string, payload: T, sample: number): Promise<void>;
}

// Cache
export interface CacheStore {
getOrSet<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T>;
invalidate(key: string): Promise<void>;
byPrefix(prefix: string): Promise<string[]>;
}

Adapters live in src/infrastructure/adapters/ per SERVICE_TEMPLATE.

3. Use case detail (selected)

3.1 UC-02 ComposeTenantBootstrap

class ComposeTenantBootstrapUseCase {
async execute(tenantSlug: string, ctx: RequestContext): Promise<TenantBootstrap> {
const resolution = await this.tenant.resolveSlug(tenantSlug);
if (resolution.status === 'unknown') throw new SlugUnknownError();
if (resolution.status === 'suspended') throw new TenantSuspendedError();

const cacheKey = `tenant-bootstrap:${resolution.tenantId}:${ctx.locale}:${ctx.currency}`;
return this.cache.getOrSet(cacheKey, 300, async () => {
const [config, theme, flowConfig, policies] = await Promise.all([
this.tenant.getConfig(resolution.tenantId),
this.theme.getThemeBundle(resolution.tenantId, { locale: ctx.locale }),
this.theme.getFlowConfig(resolution.tenantId),
this.theme.getPolicies(resolution.tenantId, ctx.locale),
]);

const handoffPayload = ctx.handoffArrivalId
? await this.loadHandoffPayload(ctx.handoffArrivalId)
: null;

const bootstrap: TenantBootstrap = {
tenantId: resolution.tenantId,
tenantSlug,
brandName: config.brandName,
brandTagline: config.brandTagline ?? null,
theme,
flowConfig,
locales: config.locales,
currencies: config.currencies,
paymentMethods: config.paymentMethods,
policies,
handoffPayload,
serverTime: new Date().toISOString(),
csp: { nonce: ctx.cspNonce },
composedAt: new Date().toISOString(),
composedFrom: { themeVersion: theme.paletteRef, tenantConfigVersion: config.version },
};

await this.outbox.enqueue('melmastoon.bff.tenant.bootstrap.served.v1', toBootstrapServedPayload(bootstrap, ctx), 1.0);
return bootstrap;
});
}
}

3.2 UC-04 ComposeAvailability

3-way parallel fanout with per-call deadline + partial-result fallback:

class ComposeAvailabilityUseCase {
async execute(tenantId: TenantId, q: AvailabilityQuery): Promise<AvailabilityVM> {
const tasks = await Promise.allSettled([
withDeadline(this.inventory.getAvailability(tenantId, q), 700),
withDeadline(this.property.getRoomTypes(tenantId, q.propertyId, q.locale), 600),
withDeadline(this.pricing.cheapestRates(tenantId, toCheapestQuery(q)), 800),
]);

const inventory = unwrapOr(tasks[0], { allocations: [], stale: true });
const roomTypes = unwrapOr(tasks[1], []);
const cheapest = unwrapOr(tasks[2], {});

if (roomTypes.length === 0) throw new BffAggregateFailedError(
'roomTypes are mandatory; refusing to render empty availability'
);

return composeAvailabilityVM({ inventory, roomTypes, cheapest, query: q });
}
}

withDeadline cancels via AbortController; failures populate errorTrail for telemetry.

3.3 UC-06 CreateHold

class CreateHoldUseCase {
async execute(tenantId: TenantId, req: HoldFlowRequest, ctx: RequestContext): Promise<BookingDraft> {
if (!req.quoteId) throw new ValidationError('quoteId required');

const idemKey = ctx.idempotencyKey; // required by API gate
const upstream = await this.reservation.createHold(tenantId, {
quoteId: req.quoteId,
occupancy: req.occupancy,
currency: req.currency,
sessionId: ctx.sessionId,
}, idemKey);

const draft: BookingDraft = newBookingDraft({
tenantId,
sessionId: ctx.sessionId,
searchParams: req.searchParams,
selectedRoom: req.selectedRoom,
quote: { id: req.quoteId, expiresAt: req.quoteExpiresAt, totalDisplay: upstream.totalDisplay },
hold: { reservationId: upstream.reservationId, expiresAt: upstream.holdExpiresAt },
marketingAttribution: ctx.attribution,
handoffArrivalId: ctx.handoffArrivalId,
flowState: 'collecting_details',
});

await this.draftStore.create(draft);
await this.outbox.enqueue('melmastoon.bff.tenant.booking.draft.created.v1', toDraftCreatedPayload(draft), 1.0);
await this.outbox.enqueue('melmastoon.bff.tenant.flow.step_completed.v1', toFlowStepPayload(draft, 'holding'), 0.25);
return draft;
}
}

3.4 UC-08 CreatePaymentIntent

class CreatePaymentIntentUseCase {
async execute(tenantId: TenantId, draftId: BookingDraftId, req: PaymentIntentInput, ctx: RequestContext): Promise<PaymentIntentVM> {
const draft = await this.requireDraft(draftId);
this.assertTransition(draft, 'collecting_details', 'paying');

const intentReq: PaymentIntentRequest = {
reservationId: draft.hold!.reservationId,
amount: draft.quote!.totalDisplay,
method: req.method,
provider: req.provider,
returnUrl: this.urls.returnUrl(tenantId, draftId),
metadata: { draftId, sessionId: ctx.sessionId, traceId: ctx.traceId },
};

const intent = await this.payments.createIntent(tenantId, intentReq, ctx.idempotencyKey);

const updated = await this.draftStore.patch(draftId, d => ({
...d,
flowState: 'paying',
paymentSelection: { method: req.method, provider: req.provider },
paymentIntent: { intentId: intent.intentId, redirectUrl: intent.redirectUrl, status: 'created' },
updatedAt: new Date().toISOString(),
}), draft.updatedAt);

await this.outbox.enqueue('melmastoon.bff.tenant.payment_intent.created.v1', toIntentCreatedPayload(updated, intent), 1.0);
return intent;
}
}

3.5 UC-09 HandlePaymentReturn

The most consequential orchestration. Idempotent across browser back-button, double-click, and provider replay.

class HandlePaymentReturnUseCase {
async execute(tenantId: TenantId, draftId: BookingDraftId, returnReq: PaymentReturnRequest, ctx: RequestContext): Promise<ConfirmationOutcome> {
const draft = await this.requireDraft(draftId);

// Idempotent re-entry: already confirmed → return cached confirmation
if (draft.flowState === 'confirmed' && draft.hold) {
return { kind: 'already_confirmed', reservationId: draft.hold.reservationId };
}
if (!['paying', 'awaiting_return', 'confirming'].includes(draft.flowState)) {
throw new InvalidFlowTransitionError(draft.flowState, 'awaiting_return');
}

const verification = await this.payments.verifyReturn(tenantId, draft.paymentIntent!.intentId, returnReq.returnState);
if (!verification.success) {
await this.markFlowError(draft, 'payment_return_invalid', verification.message);
throw new PaymentReturnInvalidError(verification.code);
}

await this.transitionTo(draft, 'confirming');

const confirmIdem = `confirm:${draft.hold!.reservationId}:${verification.providerReference}`;
const confirmed = await this.reservation.confirm(tenantId, {
reservationId: draft.hold!.reservationId,
paymentIntentId: draft.paymentIntent!.intentId,
guest: draft.guest!,
}, confirmIdem as IdempotencyKey);

await this.draftStore.patch(draftId, d => ({ ...d, flowState: 'confirmed', updatedAt: new Date().toISOString() }), draft.updatedAt);
await this.draftStore.snapshot(draft, 'converted');
await this.outbox.enqueue('melmastoon.bff.tenant.booking.draft.converted.v1', toDraftConvertedPayload(draft, confirmed), 1.0);

return { kind: 'confirmed', reservationId: confirmed.reservationId };
}
}

3.6 UC-16 SweepAbandonedDrafts

Scheduled every 60 s by a Cloud Scheduler hitting /internal/jobs/sweep-abandoned. Worker invokes:

class SweepAbandonedDraftsJob {
async run() {
const ids = await this.draftStore.listExpiringSoon(new Date().toISOString(), 200);
for (const id of ids) {
const draft = await this.draftStore.get(id);
if (!draft || draft.flowState === 'confirmed') continue;
await this.draftStore.snapshot(draft, 'abandoned');
await this.outbox.enqueue('melmastoon.bff.tenant.booking.draft.abandoned.v1', toDraftAbandonedPayload(draft), 1.0);
await this.draftStore.delete(id);
}
}
}

4. Cross-cutting concerns

4.1 Idempotency

All mutating endpoints require X-Idempotency-Key. The BFF stores (idempotencyKey, requestHash, response, expiresAt) in Postgres idempotency for 24 h. Conflicting body with same key → MELMASTOON.GENERAL.PRECONDITION_FAILED. Same body returns the cached response.

For upstream calls, the BFF derives a deterministic upstream idempotency key:

  • Hold: hold:<draftId>:<quoteId>
  • Confirm: confirm:<reservationId>:<providerReference>
  • Payment intent: intent:<draftId>:<methodHash>

4.2 Optimistic concurrency on BookingDraft

patch requires expectedUpdatedAt. Mismatch raises BookingDraftConflictError. Implemented via Memorystore Lua script:

-- expects KEYS[1]=draftKey, ARGV[1]=expectedUpdatedAt, ARGV[2]=newJson
local current = redis.call('GET', KEYS[1])
if not current then return -1 end
local cur = cjson.decode(current)
if cur.updatedAt ~= ARGV[1] then return 0 end
redis.call('SET', KEYS[1], ARGV[2], 'EX', tonumber(ARGV[3]))
return 1

4.3 Deadlines + circuit breakers

UpstreamDeadlineRetriesCircuit
tenant-service400 ms0 (cached)open after 10 errors / 30 s
theme-config-service500 ms1open after 5 errors / 30 s
property-service800 ms1open after 5 errors / 30 s
inventory-service700 ms1 (idempotent)open after 5 errors / 30 s
pricing-service quote800 ms0 (non-idempotent)open after 3 errors / 15 s
reservation-service1500 ms0 (non-idempotent without key); 1 (with key)open after 3 errors / 15 s
payment-gateway-service2000 ms0open after 3 errors / 15 s
billing-service600 ms1open after 5 errors / 30 s
lock-integration-service600 ms0 (soft-fail)open after 5 errors / 30 s

4.4 Tenant guard

The TenantContextGuard runs before every controller. It:

  1. Resolves tenantSlug from subdomain or path; rejects unresolvable.
  2. Looks up suspension; rejects suspended.
  3. Attaches tenantId + tenantConfig to the request; all downstream code uses req.tenantId exclusively.
  4. Enforces req.tenantId matches BookingDraft.tenantId on every draft access; cross-tenant reference raises MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE.

4.5 Cache + invalidation

Key prefixTTLInvalidator
tenant-bootstrap:<tenantId>:<locale>:<currency>5 mintheme.published.v1, tenant.config_updated.v1
tenant-policies:<tenantId>:<locale>1 htenant.config_updated.v1
slug:<tenantSlug>1 htenant.config_updated.v1, tenant.suspended.v1
availability:<tenantId>:<propertyId>:<dateRangeHash>:<currency>30 sinventory.allocation.committed.v1, pricing.rate_plan.published.v1
cheapest:<tenantId>:<propertyId>:<dateRangeHash>:<currency>60 spricing.rate_plan.published.v1
confirmation:<tenantId>:<reservationId>5 minnone (regenerate on demand)

4.6 Saga participation

This BFF is not a saga participant in the booking-confirmation saga (which lives in reservation-service). It is the client of the saga: it triggers createHold and confirm and reads the resulting state. It must therefore handle the MELMASTOON.RESERVATION.HOLD_EXPIRED, MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED, and MELMASTOON.PAYMENT.GATEWAY_TIMEOUT cases gracefully.

5. Module boundaries

src/
domain/
booking-draft.ts
handoff-arrival.ts
state-machine.ts
errors/*.error.ts
events/*.event.ts
application/
use-cases/
compose-tenant-bootstrap.use-case.ts
compose-availability.use-case.ts
issue-quote.use-case.ts
create-hold.use-case.ts
update-draft.use-case.ts
create-payment-intent.use-case.ts
handle-payment-return.use-case.ts
confirm-reservation-direct.use-case.ts
compose-confirmation-view.use-case.ts
verify-and-consume-handoff.use-case.ts
sweep-abandoned-drafts.use-case.ts
set-locale.use-case.ts
set-currency.use-case.ts
ports/
tenant.port.ts
theme-config.port.ts
property.port.ts
inventory.port.ts
pricing.port.ts
reservation.port.ts
payment-gateway.port.ts
billing.port.ts
lock-integration.port.ts
session.store.ts
booking-draft.store.ts
handoff-arrival.repository.ts
cache.store.ts
outbox.port.ts
infrastructure/
adapters/
http-tenant.adapter.ts
http-theme.adapter.ts
http-property.adapter.ts
http-inventory.adapter.ts
http-pricing.adapter.ts
http-reservation.adapter.ts
http-payment-gateway.adapter.ts
http-billing.adapter.ts
http-lock-integration.adapter.ts
redis-session.adapter.ts
redis-booking-draft.adapter.ts
postgres-handoff-arrival.adapter.ts
redis-cache.adapter.ts
pubsub-outbox.adapter.ts
config/env.ts
presentation/
controllers/
bootstrap.controller.ts
availability.controller.ts
quote.controller.ts
hold.controller.ts
draft.controller.ts
confirmation.controller.ts
handoff.controller.ts
session.controller.ts
policies.controller.ts
health.controller.ts
guards/
tenant-context.guard.ts
idempotency.guard.ts
csp-nonce.middleware.ts

The domain layer imports nothing outside @ghasi/domain-primitives. ESLint import-boundary rules enforce this.

6. Side-effect taxonomy

Side effectWhereWhen
Memorystore BookingDraft writeadapter from use caseevery state transition
Memorystore TenantBootstrap cache writeadapterfirst compose
Postgres outbox insertoutbox adapterevery telemetry event
Postgres booking_draft_snapshots insertsnapshot pathconversion / abandonment
Postgres handoff_arrival_log inserthandoff use case/handoff/consume
Postgres idempotency insertidempotency middlewareevery mutating request
Upstream HTTP calladapterper port method

This service performs no direct DB writes outside the listed tables and no Pub/Sub publish — Pub/Sub is reached only through the per-service outbox relay worker.