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)
| ID | Use case | Trigger | Reads | Writes |
|---|---|---|---|---|
| UC-01 | ResolveTenantSlug | every request | tenant-service (cached) | session.tenantId |
| UC-02 | ComposeTenantBootstrap | GET /bootstrap | tenant-service, theme-config-service | TenantBootstrap cache; outbox bootstrap.served.v1 |
| UC-03 | VerifyAndConsumeHandoff | POST /handoff/consume | bff-consumer-service HMAC verifier (no network), handoff_arrival_log | BookingHandoffArrival row; outbox handoff.consumed.v1 |
| UC-04 | ComposeAvailability | GET /availability | inventory-service, property-service, pricing-service | none |
| UC-05 | IssueQuote | POST /quote | pricing-service | BookingDraft.quote |
| UC-06 | CreateHold | POST /hold | reservation-service | BookingDraft.hold, BookingDraft itself; outbox draft.created.v1 |
| UC-07 | UpdateDraft | PATCH /draft/{id} | none | BookingDraft.guest, BookingDraft.flowState |
| UC-08 | CreatePaymentIntent | POST /draft/{id}/payment-intent | payment-gateway-service | BookingDraft.paymentIntent; outbox payment_intent.created.v1 |
| UC-09 | HandlePaymentReturn | POST /draft/{id}/return | payment-gateway-service, reservation-service /confirm | BookingDraft.flowState; outbox draft.converted.v1 (or flow.error_encountered.v1) |
| UC-10 | ConfirmReservationDirect | POST /draft/{id}/confirm (non-redirect rails) | reservation-service | BookingDraft.flowState confirmed; outbox draft.converted.v1 |
| UC-11 | ComposeConfirmationView | GET /confirmation/{reservationId} | reservation-service, billing-service, lock-integration-service, tenant-service | outbox confirmation.viewed.v1 |
| UC-12 | SetLocale | POST /session/locale | none | session.localePreference; outbox locale.changed.v1 |
| UC-13 | SetCurrency | POST /session/currency | none | session.displayCurrency; outbox currency.changed.v1 |
| UC-14 | ReadSession | GET /session | none | none |
| UC-15 | ReadPolicies | GET /policies | tenant-service, theme-config-service | none |
| UC-16 | SweepAbandonedDrafts | scheduled (every 60 s) | Memorystore key TTL | outbox 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
| Upstream | Deadline | Retries | Circuit |
|---|---|---|---|
tenant-service | 400 ms | 0 (cached) | open after 10 errors / 30 s |
theme-config-service | 500 ms | 1 | open after 5 errors / 30 s |
property-service | 800 ms | 1 | open after 5 errors / 30 s |
inventory-service | 700 ms | 1 (idempotent) | open after 5 errors / 30 s |
pricing-service quote | 800 ms | 0 (non-idempotent) | open after 3 errors / 15 s |
reservation-service | 1500 ms | 0 (non-idempotent without key); 1 (with key) | open after 3 errors / 15 s |
payment-gateway-service | 2000 ms | 0 | open after 3 errors / 15 s |
billing-service | 600 ms | 1 | open after 5 errors / 30 s |
lock-integration-service | 600 ms | 0 (soft-fail) | open after 5 errors / 30 s |
4.4 Tenant guard
The TenantContextGuard runs before every controller. It:
- Resolves
tenantSlugfrom subdomain or path; rejects unresolvable. - Looks up suspension; rejects suspended.
- Attaches
tenantId+tenantConfigto the request; all downstream code usesreq.tenantIdexclusively. - Enforces
req.tenantIdmatchesBookingDraft.tenantIdon every draft access; cross-tenant reference raisesMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE.
4.5 Cache + invalidation
| Key prefix | TTL | Invalidator |
|---|---|---|
tenant-bootstrap:<tenantId>:<locale>:<currency> | 5 min | theme.published.v1, tenant.config_updated.v1 |
tenant-policies:<tenantId>:<locale> | 1 h | tenant.config_updated.v1 |
slug:<tenantSlug> | 1 h | tenant.config_updated.v1, tenant.suspended.v1 |
availability:<tenantId>:<propertyId>:<dateRangeHash>:<currency> | 30 s | inventory.allocation.committed.v1, pricing.rate_plan.published.v1 |
cheapest:<tenantId>:<propertyId>:<dateRangeHash>:<currency> | 60 s | pricing.rate_plan.published.v1 |
confirmation:<tenantId>:<reservationId> | 5 min | none (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 effect | Where | When |
|---|---|---|
Memorystore BookingDraft write | adapter from use case | every state transition |
Memorystore TenantBootstrap cache write | adapter | first compose |
Postgres outbox insert | outbox adapter | every telemetry event |
Postgres booking_draft_snapshots insert | snapshot path | conversion / abandonment |
Postgres handoff_arrival_log insert | handoff use case | /handoff/consume |
Postgres idempotency insert | idempotency middleware | every mutating request |
| Upstream HTTP call | adapter | per 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.