Skip to main content

APPLICATION_LOGIC — bff-consumer-service

Sibling: DOMAIN_MODEL · API_CONTRACTS · DATA_MODEL · SECURITY_MODEL · FAILURE_MODES

This document is the orchestration logic for bff-consumer-service. The BFF is a stateless composition tier — every request is a small saga of (read upstream → cache → compose view-model → emit telemetry → respond). There is no mutating use-case for any business domain. The mutations we do perform are session-blob writes (Memorystore), wishlist mirror writes (Postgres), handoff record writes (Postgres), and analytics-outbox writes (Postgres).

1. Use-case catalogue

Use caseTriggerReadsWritesTelemetry emittedIdempotent
BootstrapGuestSessionAny inbound request without gms_ cookienoneMemorystore sessionsession.started.v1n/a
ExecuteSearchListViewPOST /searchsearch-aggregation-service, pricing-service (top-N), theme-config-service (peek)Memorystore search session, search historysearch.executed.v1 (sampled)yes (cache key)
ExecuteSearchMapViewPOST /search/mapsearch-aggregation-service (bbox)Memorystoresearch.executed.v1 (sampled, kind=map)yes
ReadHotelDetailGET /hotels/{id}property-service, search-aggregation-service, pricing-service (preview), theme-config-serviceMemorystore detail cachedetail.viewed.v1 (sampled)yes
ReadHotelAvailabilityGET /hotels/{id}/availabilitysearch-aggregation-service (light availability projection)Memorystorenoneyes
AddToWishlistPOST /wishlistoptional property-service (validate)Memorystore session, Postgres mirrorwishlist.added.v1yes (Idempotency-Key)
RemoveFromWishlistDELETE /wishlist/{propertyId}noneMemorystore session, Postgres mirrorwishlist.removed.v1yes
ListWishlistGET /wishlistproperty-service for light enrichment, theme-config-service peekMemorystorenoneyes
MintBookingHandoffPOST /handoff/{tenantId}/{propertyId}tenant-service (slug + suspension), property-service (validate)Postgres handoff_replay_log, outboxhandoff.initiated.v1yes (Idempotency-Key)
SetLocalePreferencePOST /session/localenoneMemorystore sessionlocale.changed.v1yes
SetCurrencyPreferencePOST /session/currencynoneMemorystore sessioncurrency.changed.v1yes
RecordPageViewPOST /telemetry/page-viewnonePostgres outbox(writes MetaPageView)yes
RecordClickPOST /telemetry/clicknonePostgres outboxclick.recorded.v1yes
EvaluateBotScore (interceptor)every requestnonePostgres bot_score_log (only on suspect/bot verdict)bot_suspected.v1n/a
InvalidateCacheOnEvent (background)Pub/Sub consumer for theme.published.v1, listing.indexed.v1, tenant.suspended.v1noneMemorystorenoneyes (event id)

Each use-case lives in src/application/use-cases/<verb-noun>.use-case.ts per NAMING.md.

2. Ports

// src/application/ports/

export interface SearchAggregationPort {
searchListings(q: SearchQuery, ctx: RequestContext): Promise<SearchAggregationResult>;
searchPins(q: SearchPinsQuery, ctx: RequestContext): Promise<MapPin[]>;
getLightAvailability(propertyId: PropertyId, range: DateRange, ctx: RequestContext): Promise<LightAvailability>;
getFacetCatalog(geo: GeoQuery, ctx: RequestContext): Promise<FacetCatalog>;
getPopularitySummary(propertyId: PropertyId, ctx: RequestContext): Promise<PopularitySummary>;
}

export interface PricingPreviewPort {
// Read-only preview, NEVER pins a quote (tenant booking BFF will pin for real)
getCheapestRateSnapshot(input: CheapestRateInput, ctx: RequestContext): Promise<RateSnapshotVM | null>;
getPriceCalendarPreview(propertyId: PropertyId, days: number, ctx: RequestContext): Promise<PriceCalendarPreview | null>;
}

export interface PropertyPort {
getPropertyDetail(propertyId: PropertyId, ctx: RequestContext): Promise<PropertyDetail | null>;
getPropertySummaryBatch(ids: PropertyId[], ctx: RequestContext): Promise<Map<PropertyId, PropertySummary>>;
}

export interface ThemeConfigBrandPeekPort {
getBrandPeek(tenantId: TenantId, ctx: RequestContext): Promise<BrandPeekVM | null>;
getBrandPeekBatch(tenantIds: TenantId[], ctx: RequestContext): Promise<Map<TenantId, BrandPeekVM>>;
}

export interface TenantResolverPort {
resolveSlug(tenantSlug: string, ctx: RequestContext): Promise<{ tenantId: TenantId; suspended: boolean } | null>;
resolveTenant(tenantId: TenantId, ctx: RequestContext): Promise<{ tenantSlug: string; suspended: boolean } | null>;
}

export interface SessionStorePort {
load(id: GuestSessionId): Promise<GuestSession | null>;
save(session: GuestSession): Promise<void>;
touch(id: GuestSessionId): Promise<void>; // updates lastSeenAt without rewrite
destroy(id: GuestSessionId): Promise<void>;
}

export interface CachePort {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
singleFlight<T>(key: string, ttlSeconds: number, loader: () => Promise<T>): Promise<{ value: T; fromCache: boolean }>;
invalidate(keyOrPrefix: string): Promise<number>; // returns count
}

export interface HandoffSignerPort {
mint(payload: HandoffPayload): Promise<{ token: string; record: BookingHandoff }>;
verify(token: string): Promise<{ ok: true; payload: HandoffPayload } | { ok: false; reason: HandoffReason }>;
// verify() is consumed by bff-tenant-booking-service via the same signing library; not used here directly
}

export interface HandoffReplayLogPort {
insertOnce(record: BookingHandoff): Promise<'inserted' | 'duplicate'>;
markConsumed(id: BookingHandoffId, by: string): Promise<'ok' | 'already-consumed' | 'not-found'>;
}

export interface BotDetectorPort {
evaluate(req: InboundRequest): Promise<BotScore>;
}

export interface AnalyticsOutboxPort {
emit(event: BffConsumerEvent): Promise<void>; // transactional with any DB write in-flight
}

export interface RateLimitPort {
consume(bucketKey: string, cost: number): Promise<{ allowed: boolean; retryAfterSeconds?: number }>;
}

export interface RequestContext {
requestId: string; // req_<ulid>
traceId: string; // W3C
guestSessionId: GuestSessionId | null; // null pre-bootstrap
ipHash: string;
userAgentHash: string;
fingerprintHash: string;
acceptLanguage: string;
forwardedFor: string;
region: string; // GCP region of the request
campaignAttribution?: CampaignAttribution;
consentTelemetry: boolean;
}

All ports live in src/application/ports/. Their adapters live in src/infrastructure/adapters/ (e.g., rest-search-aggregation.adapter.ts, rest-pricing-preview.adapter.ts, redis-session-store.adapter.ts, postgres-handoff-replay-log.adapter.ts, pubsub-analytics-outbox.adapter.ts).

3. Orchestration: ExecuteSearchListView

The most-trafficked use case. The flow below is the production path.

async function executeSearchListView(
cmd: ExecuteSearchListViewCommand,
deps: SearchListViewDeps,
ctx: RequestContext,
): Promise<SearchListViewResult> {

// 1. Bot pre-screen (interceptor already ran; here we just consume rate-limit budget)
const rl = await deps.rateLimit.consume(`consumer:search:${ctx.fingerprintHash}`, 1);
if (!rl.allowed) throw new RateLimitedError(rl.retryAfterSeconds);

// 2. Resolve / bootstrap session
const session = await ensureGuestSession(cmd, deps, ctx);

// 3. Build the canonical cache key
const queryHash = canonicalHash(cmd.query, session.localePreference, session.currencyPreference);
const cacheKey = `consumer:search:list:${queryHash}`;

// 4. Single-flight cache lookup
const { value: vm, fromCache } = await deps.cache.singleFlight(
cacheKey, /*ttlSeconds*/ 60, async () => {
// 4a. Upstream: aggregation
const agg = await deps.searchAggregation.searchListings(cmd.query, ctx);

// 4b. Top-N rate enrichment in parallel (capped at 4 concurrent calls per request budget)
const topN = agg.results.slice(0, cmd.enrichTopN ?? 10);
const rateSnapshots = await pLimit(4)(
topN.map(r => () => deps.pricingPreview.getCheapestRateSnapshot({
propertyId: r.propertyId,
dates: cmd.query.dates,
occupancy: cmd.query.occupancy,
currency: session.currencyPreference,
}, ctx)),
);

// 4c. Brand-peek batch (one call returns all)
const tenantIds = unique(agg.results.map(r => r.tenantId));
const brandPeeks = await deps.themeBrandPeek.getBrandPeekBatch(tenantIds, ctx);

// 4d. Compose
return composeListingCardVMs({
agg, rateSnapshots, brandPeeks,
currency: session.currencyPreference,
locale: session.localePreference,
});
},
);

// 5. Persist search-session in Memorystore (history + last query)
await persistSearchSession(session, cmd, deps);

// 6. Telemetry (sampled)
if (sampled(0.1, ctx.requestId)) {
await deps.analyticsOutbox.emit({
type: 'melmastoon.bff.consumer.search.executed.v1',
payload: { /* see EVENT_SCHEMAS.md */ },
});
}

return { vm, fromCache, cacheKey };
}

Failure handling:

  • searchAggregation.searchListings failure → bubble up MELMASTOON.SEARCH_AGGREGATION.UPSTREAM_UNAVAILABLE mapped to 502. Serve last cached result with stale=true if present.
  • pricingPreview partial failure → continue without rateSnapshot for failing entries; mark rateSnapshot.isStale=true on entries that succeeded but with staleHint=true upstream.
  • themeBrandPeek failure → fall back to platform default BrandPeekVM (Melmastoon scaffold colors); log WARN with tenantId.
  • cache.singleFlight lock-acquire timeout (4 s) → fall back to direct uncached call with circuit-breaker awareness; emit consumer.cache.stampede_lock_timeout metric.

Per-request budget: total upstream call count ≤ 6 (1 aggregation + 1 brand-peek-batch + max 4 rate-snapshots). Exceeding the budget returns MELMASTOON.BFF.CONSUMER.UPSTREAM_BUDGET_EXCEEDED.

4. Orchestration: ReadHotelDetail

async function readHotelDetail(propertyId: PropertyId, deps, ctx): Promise<HotelDetailVM> {
const session = await ensureGuestSession(/*...*/);

const cacheKey = `consumer:detail:${propertyId}:${session.localePreference}:${session.currencyPreference}`;

return (await deps.cache.singleFlight(cacheKey, 300, async () => {
const [property, popularity, rate, calendar, brandPeek] = await Promise.allSettled([
deps.property.getPropertyDetail(propertyId, ctx),
deps.searchAggregation.getPopularitySummary(propertyId, ctx),
deps.pricingPreview.getCheapestRateSnapshot({ propertyId, dates: defaultStay(7), occupancy: { adults: 2, children: 0, rooms: 1 }, currency: session.currencyPreference }, ctx),
deps.pricingPreview.getPriceCalendarPreview(propertyId, 7, ctx),
deps.themeBrandPeek.getBrandPeek(/*tenantId from property*/, ctx),
]);

if (property.status !== 'fulfilled' || !property.value) {
throw new ResourceNotFoundError('property', propertyId);
}

return composeHotelDetailVM({
property: property.value,
popularity: settledValue(popularity),
rate: settledValue(rate),
calendar: settledValue(calendar),
brandPeek: settledValue(brandPeek) ?? defaultBrandPeek(),
});
})).value;
}

Promise.allSettled ensures partial degradation: detail still renders if popularity, rate, calendar, or brand-peek fail individually. Only property.value is hard-required.

5. Orchestration: MintBookingHandoff

async function mintBookingHandoff(
cmd: MintHandoffCommand, deps, ctx,
): Promise<{ token: string; redirectUrl: string; expiresAt: ISODateTime }> {

// 1. Resolve target tenant + check suspension
const tenant = await deps.tenantResolver.resolveTenant(cmd.tenantId, ctx);
if (!tenant) throw new ResourceNotFoundError('tenant', cmd.tenantId);
if (tenant.suspended) throw new TenantSuspendedError(cmd.tenantId);

// 2. Validate property exists and belongs to tenant (cheap: search-aggregation projection)
const verifyOwnership = await deps.searchAggregation.getLightAvailability(
cmd.propertyId, cmd.dates, ctx,
);
if (!verifyOwnership || verifyOwnership.tenantId !== cmd.tenantId) {
throw new ResourceNotFoundError('property', cmd.propertyId);
}

// 3. Mint via the signer port (HMAC-SHA256, key id from Secret Manager rotation)
const session = await ensureGuestSession(cmd, deps, ctx);
const handoffId = newHandoffId();
const payload: HandoffPayload = {
id: handoffId,
guestSessionId: session.id,
tenantId: cmd.tenantId,
propertyId: cmd.propertyId,
dates: cmd.dates,
occupancy: cmd.occupancy,
currency: cmd.currency ?? session.currencyPreference,
locale: cmd.locale ?? session.localePreference,
sourceCampaign: session.campaignAttribution,
mintedAt: now(),
expiresAt: addMinutes(now(), 30),
};
const { token, record } = await deps.handoffSigner.mint(payload);

// 4. Insert into replay log (atomic; outbox + record share txn)
await deps.db.transaction(async (tx) => {
const status = await deps.handoffReplayLog.insertOnce(record, tx);
if (status === 'duplicate') return; // idempotent; same Idempotency-Key
await deps.analyticsOutbox.emit({
type: 'melmastoon.bff.consumer.handoff.initiated.v1',
payload: { /* see EVENT_SCHEMAS.md */ },
}, tx);
});

// 5. Construct redirect URL
const redirectUrl = `https://${tenant.tenantSlug}.melmastoon.ghasi.io/book?h=${token}`;

return { token, redirectUrl, expiresAt: payload.expiresAt };
}

Idempotency is keyed on (guestSessionId, tenantId, propertyId, dates.checkIn, dates.checkOut, Idempotency-Key); replay returns the original (token, redirectUrl, expiresAt).

6. Orchestration: AddToWishlist

async function addToWishlist(cmd: AddToWishlistCommand, deps, ctx): Promise<Wishlist> {
const session = await ensureGuestSession(/*...*/);

if (session.wishlistRefs.length >= 100) {
throw new WishlistLimitExceededError();
}

if (session.wishlistRefs.some(w => w.propertyId === cmd.propertyId)) {
return readExistingWishlist(session, cmd.propertyId, deps);
}

const wishlist: Wishlist = newWishlist(session.id, cmd);

await deps.db.transaction(async (tx) => {
await deps.wishlistRepo.insert(wishlist, tx);
await deps.analyticsOutbox.emit({
type: 'melmastoon.bff.consumer.wishlist.added.v1',
payload: { /*...*/ },
}, tx);
});

// Append to session blob (Lua script for atomic cap-and-push in Memorystore)
await deps.sessionStore.appendWishlistRef(session.id, {
wishlistId: wishlist.id, propertyId: wishlist.propertyId,
tenantId: wishlist.tenantId, addedAt: wishlist.addedAt,
});

return wishlist;
}

7. Cross-cutting interceptors

In NestJS, registered globally:

InterceptorOrderPurpose
RequestContextInterceptor1Builds RequestContext, binds AsyncLocalStorage
BotDetectionInterceptor2Evaluates BotScore; on verdict='bot' short-circuits with CAPTCHA challenge response
RateLimitInterceptor3Per-route bucket (per fingerprint/IP/cookie); returns 429 with Retry-After
IdempotencyInterceptor4For mutating routes; replays the original response from Memorystore
SessionBootstrapInterceptor5Mints gms_ cookie if missing; emits session.started.v1
LocaleCurrencyInterceptor6Resolves Accept-Language + X-Currency from header / session / geo-IP
CacheInterceptor (per route)7Wraps GET handlers with single-flight + Memorystore
OutboxInterceptor8Ensures any analytics emission inside the handler shares the controller-level transaction
TelemetryInterceptor9Records page-view + funnel-event hooks
ErrorMappingInterceptorlastMaps domain errors → MELMASTOON.BFF.CONSUMER.* codes per ERROR_CODES

8. Saga participation

The BFF participates in no business saga. It is the mint side of the booking-handoff handshake; the verify + consume side lives in bff-tenant-booking-service. The handshake is not a saga (no compensation, no multi-step orchestration) — it is a one-shot signed redirect.

The booking saga itself (04 §7.3) is owned by reservation-service and orchestrated through bff-tenant-booking-service. This BFF terminates at the handoff URL.

9. Concurrency, transactions, and idempotency

  • Single-flight on cache misses: Memorystore key lock:<cacheKey> set with SET NX EX 5; followers BLPOP the leader's signal queue or fall back after 4 s.
  • Wishlist add/remove: Lua script in Memorystore for atomic cap-and-push; Postgres mirror in the same controller-level transaction with the outbox emission.
  • Handoff mint: transactional outbox; handoff_replay_log insert + outbox event in one Postgres transaction.
  • Idempotency: Idempotency-Key (ULID) header on every mutating route; cached in Memorystore for 24 h with (guestSessionId, route, key) composite, value = full response body. Replay with different body returns MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED.

10. Configuration toggles

FlagSourceDefaultEffect
bff.consumer.search.sample_rateFirebase Remote Config0.1Telemetry sampling for search.executed.v1
bff.consumer.cache.search.ttl_secondsRemote Config60Search-list cache TTL
bff.consumer.cache.detail.ttl_secondsRemote Config300Detail cache TTL
bff.consumer.campaign_modeRemote ConfigfalseRaises cache TTL to 5 min, lowers /handoff rate-limit
bff.consumer.handoff.ttl_minutesEnv30BookingHandoff.expiresAt
bff.consumer.bot.threshold_suspectRemote Config0.6Score above triggers CAPTCHA
bff.consumer.bot.threshold_botRemote Config0.85Score above hard-blocks
bff.consumer.upstream.max_concurrencyEnv4Per-request fanout cap
bff.consumer.locale.fallback_chainEnvenFallback when Accept-Language missing