Skip to main content

DOMAIN_MODEL — bff-consumer-service

Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · API_CONTRACTS · EVENT_SCHEMAS

Standards: NAMING · 06 Data Models · 07 Security/Compliance/Tenancy

Important. This BFF owns no business domain. It owns session ergonomics, view-model projections, and conversion telemetry. The "aggregates" listed here are session/projection types that exist to give the consumer surface a consistent shape and to anchor invariants for cache/handoff safety. They do not participate in the booking, pricing, inventory, or any other authoritative domain.

1. Bounded-context model in one diagram

GuestSession (gms_*)
─────────────────────
• localePreference
• currencyPreference
• createdAt / lastSeenAt
• cookieFingerprintHash

┌─────────────────┼──────────────────┬───────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
SearchSession RecentlyViewed[] Wishlist BotScore
(srs_*) (capped 50, FIFO) (wsh_*, capped 100) (append-only)


│ mints (single-use, signed)

BookingHandoff (bhd_*)
──────────────────────
• tenantId (target)
• propertyId
• dates / occupancy
• currency / locale
• sourceCampaign?
• expiresAt
• hmac

MetaPageView and ConversionFunnelEvent are append-only telemetry rows; they are not aggregates in the DDD sense and live in the analytics outbox table.

2. Aggregates

2.1 GuestSession (root)

type GuestSessionId = Branded<string, 'GuestSessionId'>; // gms_<ulid>

type LocaleTag = Branded<string, 'LocaleTag'>; // BCP-47, e.g. 'ps-AF'
type CurrencyCode = Branded<string, 'CurrencyCode'>; // ISO 4217, e.g. 'AFN'

interface GuestSession {
readonly id: GuestSessionId;
readonly createdAt: ISODateTime;
lastSeenAt: ISODateTime;
localePreference: LocaleTag; // default resolved from Accept-Language
currencyPreference: CurrencyCode; // default resolved from geo-IP if not set
cookieFingerprintHash: string; // SHA-256 over UA + Accept-Language + screen + tz
recentlyViewed: RecentlyViewedEntry[]; // max 50, FIFO
wishlistRefs: WishlistRef[]; // max 100, dedup by propertyId
searchHistory: SearchHistoryEntry[]; // max 25, FIFO
campaignAttribution?: CampaignAttribution;
flags: {
consentTelemetry: boolean; // default true; respects Do-Not-Track / consent banner
consentMarketing: boolean; // default false
};
}

interface RecentlyViewedEntry {
propertyId: PropertyId;
tenantId: TenantId; // visible only on this transient projection
viewedAt: ISODateTime;
source: 'search-list' | 'search-map' | 'detail-link' | 'wishlist';
}

interface WishlistRef {
wishlistId: WishlistId; // wsh_<ulid>, links to Postgres mirror row
propertyId: PropertyId;
tenantId: TenantId;
addedAt: ISODateTime;
}

interface SearchHistoryEntry {
searchSessionId: SearchSessionId;
queryHash: string; // SHA-256 of normalized query
geo: GeoQuery;
dates: DateRange;
occupancy: Occupancy;
executedAt: ISODateTime;
}

interface CampaignAttribution {
source?: string; // utm_source
medium?: string; // utm_medium
campaign?: string; // utm_campaign
capturedAt: ISODateTime;
}

Invariants (enforced in GuestSession factory):

  1. recentlyViewed.length ≤ 50 — oldest evicted on add.
  2. wishlistRefs.length ≤ 100 — add beyond cap returns MELMASTOON.BFF.CONSUMER.WISHLIST_LIMIT_EXCEEDED.
  3. searchHistory.length ≤ 25 — oldest evicted.
  4. localePreference must parse as a valid BCP-47 tag.
  5. currencyPreference must be a valid ISO 4217 code present in the platform-supported set (AFN, USD, EUR, IRR, PKR, AED, GBP).
  6. tenantId on RecentlyViewedEntry and WishlistRef is read-only metadata captured at the time of view/add — it carries no authorisation weight here.
  7. Setting flags.consentTelemetry = false short-circuits all event emission for this session immediately.

2.2 SearchSession

type SearchSessionId = Branded<string, 'SearchSessionId'>; // srs_<ulid>

interface SearchSession {
readonly id: SearchSessionId;
readonly guestSessionId: GuestSessionId;
readonly queryHash: string;
geo: GeoQuery;
dates: DateRange;
occupancy: Occupancy;
filters: SearchFilters;
sortKey: SortKey;
locale: LocaleTag;
currency: CurrencyCode;
startedAt: ISODateTime;
lastInteractionAt: ISODateTime;
resultCount?: number;
ttlExpiresAt: ISODateTime; // startedAt + 1h
}

type SortKey = 'recommended' | 'price-asc' | 'price-desc' | 'rating-desc' | 'distance-asc';

interface GeoQuery {
mode: 'point' | 'bounding-box' | 'city' | 'region';
point?: { lat: number; lng: number; radiusKm: number };
boundingBox?: { swLat: number; swLng: number; neLat: number; neLng: number };
city?: string;
region?: string;
}

interface DateRange { checkIn: ISODate; checkOut: ISODate; }
interface Occupancy { adults: number; children: number; rooms: number; childrenAges?: number[]; }

interface SearchFilters {
priceRange?: { min: number; max: number; currency: CurrencyCode };
amenities?: string[]; // e.g. 'wifi','breakfast','halal-kitchen','prayer-room'
starRating?: number[];
guestRatingMin?: number;
propertyType?: string[]; // 'hotel','guesthouse','hostel','apartment'
cancellationFlexibility?: 'free' | 'partial' | 'any';
neighbourhoods?: string[];
}

Invariants:

  1. dates.checkIn < dates.checkOut.
  2. occupancy.adults ≥ 1; occupancy.rooms ≥ 1.
  3. geo must be exactly one mode (XOR).
  4. ttlExpiresAt = startedAt + 1h; expired sessions are read-only and trigger a fresh srs_ on next interaction.

2.3 Wishlist (mirror)

type WishlistId = Branded<string, 'WishlistId'>; // wsh_<ulid>

interface Wishlist {
readonly id: WishlistId;
readonly guestSessionId: GuestSessionId;
propertyId: PropertyId;
tenantId: TenantId;
addedAt: ISODateTime;
source: 'detail' | 'list' | 'map' | 'recently-viewed';
note?: string; // user-typed, max 280 chars
}

Invariants: unique on (guestSessionId, propertyId). Removal is hard-delete from Postgres mirror; session blob is updated atomically (Lua script in Memorystore).

2.4 BookingHandoff (signed token)

type BookingHandoffId = Branded<string, 'BookingHandoffId'>; // bhd_<ulid>

interface BookingHandoff {
readonly id: BookingHandoffId;
readonly guestSessionId: GuestSessionId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly dates: DateRange;
readonly occupancy: Occupancy;
readonly currency: CurrencyCode;
readonly locale: LocaleTag;
readonly sourceCampaign?: CampaignAttribution;
readonly mintedAt: ISODateTime;
readonly expiresAt: ISODateTime; // mintedAt + 30 min
readonly hmac: string; // HS256(secret, canonicalString)
readonly hmacKeyId: string; // 'hmac-2026-04' for rotation
consumed: boolean; // single-use; set true on first verification by tenant BFF
consumedAt?: ISODateTime;
consumedByBffInstance?: string;
}

Canonical signing string (newline-separated, then HMAC-SHA256):

v1
<id>
<guestSessionId>
<tenantId>
<propertyId>
<checkIn>
<checkOut>
<adults>
<children>
<rooms>
<currency>
<locale>
<mintedAt>
<expiresAt>
<hmacKeyId>

Invariants:

  1. expiresAt - mintedAt = 30 min; reject longer or shorter.
  2. consumed is set exactly once; replay attempts return MELMASTOON.BFF.CONSUMER.HANDOFF_REPLAYED and emit bot_suspected.v1.
  3. hmacKeyId must reference a key currently in the rotation set (active or grace).
  4. tenantId and propertyId must resolve and the tenant must not be suspended at mint time.

2.5 MetaPageView (append-only)

type MetaPageViewId = Branded<string, 'MetaPageViewId'>; // mpv_<ulid>

interface MetaPageView {
readonly id: MetaPageViewId;
readonly guestSessionId: GuestSessionId;
readonly searchSessionId?: SearchSessionId;
readonly path: string; // e.g. '/search', '/hotels/ppt_01H...'
readonly tenantId?: TenantId; // null for cross-tenant pages
readonly propertyId?: PropertyId;
readonly viewedAt: ISODateTime;
readonly referer?: string;
readonly userAgentHash: string;
readonly fingerprintHash: string;
}

2.6 ConversionFunnelEvent (append-only)

type ConversionFunnelEventId = Branded<string, 'ConversionFunnelEventId'>; // cfe_<ulid>

type FunnelStep =
| 'session.started'
| 'search.executed'
| 'list.scrolled-50pct'
| 'list.scrolled-90pct'
| 'map.opened'
| 'detail.viewed'
| 'wishlist.added'
| 'handoff.initiated';

interface ConversionFunnelEvent {
readonly id: ConversionFunnelEventId;
readonly guestSessionId: GuestSessionId;
readonly step: FunnelStep;
readonly occurredAt: ISODateTime;
readonly tenantId?: TenantId;
readonly propertyId?: PropertyId;
readonly searchSessionId?: SearchSessionId;
readonly metadata?: Record<string, string | number | boolean>;
}

2.7 BotScore (append-only audit)

interface BotScore {
readonly guestSessionId: GuestSessionId;
readonly fingerprintHash: string;
readonly ipHash: string;
readonly score: number; // 0..1, higher = more likely bot
readonly verdict: 'human' | 'suspect' | 'bot';
readonly signals: BotSignal[];
readonly evaluatedAt: ISODateTime;
}

interface BotSignal {
kind: 'ua-pattern' | 'cadence' | 'fingerprint-collision' | 'recaptcha' | 'header-anomaly';
weight: number;
detail?: string;
}

3. Value objects (shared kernel candidates)

VOTypeNotes
TenantIdBranded<string, 'TenantId'>Imported from @ghasi/domain-primitives
PropertyIdBranded<string, 'PropertyId'>Same
LocaleTagBranded<string, 'LocaleTag'>BCP-47
CurrencyCodeBranded<string, 'CurrencyCode'>ISO 4217
ISODate / ISODateTimebranded date stringsSame
Money{ minor: bigint; currency: CurrencyCode }Read-only here; we never compute money — we pass through the pricing-service snapshot

4. View-model projections (output shapes)

These are the DTO shapes the BFF returns to the consumer clients. They are not persisted; they are composed per request from upstream calls and (where safe) cached in Memorystore.

4.1 ListingCardVM

interface ListingCardVM {
propertyId: PropertyId;
tenantId: TenantId; // for the brand peek + handoff target
tenantSlug: string; // for handoff URL construction
name: LocalizedString;
city: string;
country: string;
geo: { lat: number; lng: number };
thumbnail: { url: string; alt: string };
starRating?: number;
guestRating?: { value: number; count: number };
amenityHighlights: string[]; // top 5
brandPeek: BrandPeekVM;
rateSnapshot?: RateSnapshotVM; // null if not enriched (e.g. map view non-cursor pin)
badges: ('crossRegionDelivery' | 'shariaCompliant' | 'newProperty')[];
distanceKm?: number; // when geo.point query
}

interface BrandPeekVM {
primaryColor: string; // hex, validated by theme-config
logoUrl: string;
brandName: LocalizedString;
}

interface RateSnapshotVM {
cheapestNightlyMinor: bigint;
totalForStayMinor: bigint;
currency: CurrencyCode;
currencyDisplayPolicy: 'tenant' | 'user-preferred' | 'fallback';
fxSnapshotId?: string; // when conversion was applied
capturedAt: ISODateTime;
ttlExpiresAt: ISODateTime; // 60 s
isStale: boolean;
}

interface LocalizedString { default: string; localized?: Record<string, string>; }

4.2 HotelDetailVM

interface HotelDetailVM {
property: PropertySummaryVM;
rooms: RoomTypeSummaryVM[];
amenities: AmenityVM[];
photos: { url: string; alt: string; isHero: boolean }[];
policies: { cancellation: string; checkIn: string; checkOut: string };
brandPeek: BrandPeekVM;
cheapestRateSnapshot?: RateSnapshotVM;
priceCalendarPreview?: { date: ISODate; cheapestMinor: bigint; currency: CurrencyCode }[]; // 7-day
similarProperties?: ListingCardVM[]; // up to 4
popularitySignals?: { bookedLast24h: number; viewedLast1h: number };
handoffHint: { url: string; ttlSeconds: number }; // pre-computed for the default occupancy
}

4.3 MapPinVM

interface MapPinVM {
propertyId: PropertyId;
geo: { lat: number; lng: number };
cheapestNightlyMinor?: bigint; // null on non-spotlight pins
currency?: CurrencyCode;
brandColor: string; // for cluster styling
}

5. State machines

5.1 GuestSession

┌──────────┐ first-request ┌─────────┐ interaction ┌─────────┐
START → │ unbound │ ───────────────────▶ │ active │ ────────────────▶ │ active │
└──────────┘ └────┬────┘ (lastSeenAt+) └────┬────┘
│ │
│ 30 days no interaction │
▼ │
┌─────────┐ │
│ expired │ ◀───────────────────────┘
└─────────┘
│ explicit /session/clear

┌─────────┐
│ purged │ (Memorystore + Postgres mirror cleared)
└─────────┘

5.2 BookingHandoff

┌────────┐ POST /handoff ┌────────┐ GET tenant-bff /bootstrap?h=... ┌──────────┐
→ │ minted │ ───────────────▶ │ minted │ ────────────────────────────────▶ │ consumed │
└────────┘ └───┬────┘ └──────────┘
│ replay attempt

┌─────────┐
│ rejected│ → MELMASTOON.BFF.CONSUMER.HANDOFF_REPLAYED
└─────────┘ emits bot_suspected.v1

│ expiresAt elapsed

┌─────────┐
│ expired │ → MELMASTOON.BFF.CONSUMER.HANDOFF_EXPIRED
└─────────┘

6. Domain events (telemetry only)

This BFF emits only telemetry events — no domain events. The full list is in EVENT_SCHEMAS.md. Subjects all start with melmastoon.bff.consumer.*. None of them affect any other service's domain state; they are read by analytics-service and audit-service only.

7. Domain errors

ErrorWhenHTTP via ERROR_CODES
WishlistLimitExceededAdding beyond 100 entriesMELMASTOON.BFF.CONSUMER.WISHLIST_LIMIT_EXCEEDED (422)
HandoffReplayedToken already consumedMELMASTOON.BFF.CONSUMER.HANDOFF_REPLAYED (409)
HandoffExpiredexpiresAt < nowMELMASTOON.BFF.CONSUMER.HANDOFF_EXPIRED (410)
HandoffSignatureInvalidHMAC mismatchMELMASTOON.BFF.CONSUMER.HANDOFF_SIGNATURE_INVALID (401)
TenantSuspendedTarget tenant suspended at mint or verifyMELMASTOON.BFF.CONSUMER.TENANT_SUSPENDED (403)
SuspectedBotBotScore verdict = bot or suspect over thresholdMELMASTOON.BFF.CONSUMER.SUSPECTED_BOT (429 + CAPTCHA challenge)
UpstreamBudgetExceededPer-request fanout budget hitMELMASTOON.BFF.CONSUMER.UPSTREAM_BUDGET_EXCEEDED (504)
LocaleNotSupportedBCP-47 tag not in platform setMELMASTOON.BFF.CONSUMER.LOCALE_NOT_SUPPORTED (422)
CurrencyNotSupportedISO 4217 code not in platform setMELMASTOON.BFF.CONSUMER.CURRENCY_NOT_SUPPORTED (422)

8. Privacy + tenancy invariants

  • GuestSession.id is the only stable identifier and is cookie-bound; no email, phone, or name is collected in Phase 1.
  • tenantId may appear on RecentlyViewedEntry, WishlistRef, BookingHandoff, BrandPeek, MetaPageView, ConversionFunnelEvent — only as a reference, never as a tenancy boundary.
  • No row owned by this service participates in Postgres RLS — there is no tenant_id-scoped table to isolate.
  • cookieFingerprintHash, userAgentHash, ipHash are SHA-256 with a per-environment pepper (rotated annually); the raw values never persist.
  • Telemetry-event payloads carry only hashed identifiers and guestSessionId; never raw IP, never raw UA.