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 case | Trigger | Reads | Writes | Telemetry emitted | Idempotent |
|---|---|---|---|---|---|
BootstrapGuestSession | Any inbound request without gms_ cookie | none | Memorystore session | session.started.v1 | n/a |
ExecuteSearchListView | POST /search | search-aggregation-service, pricing-service (top-N), theme-config-service (peek) | Memorystore search session, search history | search.executed.v1 (sampled) | yes (cache key) |
ExecuteSearchMapView | POST /search/map | search-aggregation-service (bbox) | Memorystore | search.executed.v1 (sampled, kind=map) | yes |
ReadHotelDetail | GET /hotels/{id} | property-service, search-aggregation-service, pricing-service (preview), theme-config-service | Memorystore detail cache | detail.viewed.v1 (sampled) | yes |
ReadHotelAvailability | GET /hotels/{id}/availability | search-aggregation-service (light availability projection) | Memorystore | none | yes |
AddToWishlist | POST /wishlist | optional property-service (validate) | Memorystore session, Postgres mirror | wishlist.added.v1 | yes (Idempotency-Key) |
RemoveFromWishlist | DELETE /wishlist/{propertyId} | none | Memorystore session, Postgres mirror | wishlist.removed.v1 | yes |
ListWishlist | GET /wishlist | property-service for light enrichment, theme-config-service peek | Memorystore | none | yes |
MintBookingHandoff | POST /handoff/{tenantId}/{propertyId} | tenant-service (slug + suspension), property-service (validate) | Postgres handoff_replay_log, outbox | handoff.initiated.v1 | yes (Idempotency-Key) |
SetLocalePreference | POST /session/locale | none | Memorystore session | locale.changed.v1 | yes |
SetCurrencyPreference | POST /session/currency | none | Memorystore session | currency.changed.v1 | yes |
RecordPageView | POST /telemetry/page-view | none | Postgres outbox | (writes MetaPageView) | yes |
RecordClick | POST /telemetry/click | none | Postgres outbox | click.recorded.v1 | yes |
EvaluateBotScore (interceptor) | every request | none | Postgres bot_score_log (only on suspect/bot verdict) | bot_suspected.v1 | n/a |
InvalidateCacheOnEvent (background) | Pub/Sub consumer for theme.published.v1, listing.indexed.v1, tenant.suspended.v1 | none | Memorystore | none | yes (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.searchListingsfailure → bubble upMELMASTOON.SEARCH_AGGREGATION.UPSTREAM_UNAVAILABLEmapped to502. Serve last cached result withstale=trueif present.pricingPreviewpartial failure → continue withoutrateSnapshotfor failing entries; markrateSnapshot.isStale=trueon entries that succeeded but withstaleHint=trueupstream.themeBrandPeekfailure → fall back to platform defaultBrandPeekVM(Melmastoon scaffold colors); logWARNwith tenantId.cache.singleFlightlock-acquire timeout (4 s) → fall back to direct uncached call with circuit-breaker awareness; emitconsumer.cache.stampede_lock_timeoutmetric.
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:
| Interceptor | Order | Purpose |
|---|---|---|
RequestContextInterceptor | 1 | Builds RequestContext, binds AsyncLocalStorage |
BotDetectionInterceptor | 2 | Evaluates BotScore; on verdict='bot' short-circuits with CAPTCHA challenge response |
RateLimitInterceptor | 3 | Per-route bucket (per fingerprint/IP/cookie); returns 429 with Retry-After |
IdempotencyInterceptor | 4 | For mutating routes; replays the original response from Memorystore |
SessionBootstrapInterceptor | 5 | Mints gms_ cookie if missing; emits session.started.v1 |
LocaleCurrencyInterceptor | 6 | Resolves Accept-Language + X-Currency from header / session / geo-IP |
CacheInterceptor (per route) | 7 | Wraps GET handlers with single-flight + Memorystore |
OutboxInterceptor | 8 | Ensures any analytics emission inside the handler shares the controller-level transaction |
TelemetryInterceptor | 9 | Records page-view + funnel-event hooks |
ErrorMappingInterceptor | last | Maps 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 withSET NX EX 5; followersBLPOPthe 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_loginsert + 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 returnsMELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED.
10. Configuration toggles
| Flag | Source | Default | Effect |
|---|---|---|---|
bff.consumer.search.sample_rate | Firebase Remote Config | 0.1 | Telemetry sampling for search.executed.v1 |
bff.consumer.cache.search.ttl_seconds | Remote Config | 60 | Search-list cache TTL |
bff.consumer.cache.detail.ttl_seconds | Remote Config | 300 | Detail cache TTL |
bff.consumer.campaign_mode | Remote Config | false | Raises cache TTL to 5 min, lowers /handoff rate-limit |
bff.consumer.handoff.ttl_minutes | Env | 30 | BookingHandoff.expiresAt |
bff.consumer.bot.threshold_suspect | Remote Config | 0.6 | Score above triggers CAPTCHA |
bff.consumer.bot.threshold_bot | Remote Config | 0.85 | Score above hard-blocks |
bff.consumer.upstream.max_concurrency | Env | 4 | Per-request fanout cap |
bff.consumer.locale.fallback_chain | Env | en | Fallback when Accept-Language missing |