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):
recentlyViewed.length ≤ 50— oldest evicted on add.wishlistRefs.length ≤ 100— add beyond cap returnsMELMASTOON.BFF.CONSUMER.WISHLIST_LIMIT_EXCEEDED.searchHistory.length ≤ 25— oldest evicted.localePreferencemust parse as a valid BCP-47 tag.currencyPreferencemust be a valid ISO 4217 code present in the platform-supported set (AFN,USD,EUR,IRR,PKR,AED,GBP).tenantIdonRecentlyViewedEntryandWishlistRefis read-only metadata captured at the time of view/add — it carries no authorisation weight here.- Setting
flags.consentTelemetry = falseshort-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:
dates.checkIn < dates.checkOut.occupancy.adults ≥ 1;occupancy.rooms ≥ 1.geomust be exactly one mode (XOR).ttlExpiresAt = startedAt + 1h; expired sessions are read-only and trigger a freshsrs_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:
expiresAt - mintedAt = 30 min; reject longer or shorter.consumedis set exactly once; replay attempts returnMELMASTOON.BFF.CONSUMER.HANDOFF_REPLAYEDand emitbot_suspected.v1.hmacKeyIdmust reference a key currently in the rotation set (active or grace).tenantIdandpropertyIdmust 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)
| VO | Type | Notes |
|---|---|---|
TenantId | Branded<string, 'TenantId'> | Imported from @ghasi/domain-primitives |
PropertyId | Branded<string, 'PropertyId'> | Same |
LocaleTag | Branded<string, 'LocaleTag'> | BCP-47 |
CurrencyCode | Branded<string, 'CurrencyCode'> | ISO 4217 |
ISODate / ISODateTime | branded date strings | Same |
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
| Error | When | HTTP via ERROR_CODES |
|---|---|---|
WishlistLimitExceeded | Adding beyond 100 entries | MELMASTOON.BFF.CONSUMER.WISHLIST_LIMIT_EXCEEDED (422) |
HandoffReplayed | Token already consumed | MELMASTOON.BFF.CONSUMER.HANDOFF_REPLAYED (409) |
HandoffExpired | expiresAt < now | MELMASTOON.BFF.CONSUMER.HANDOFF_EXPIRED (410) |
HandoffSignatureInvalid | HMAC mismatch | MELMASTOON.BFF.CONSUMER.HANDOFF_SIGNATURE_INVALID (401) |
TenantSuspended | Target tenant suspended at mint or verify | MELMASTOON.BFF.CONSUMER.TENANT_SUSPENDED (403) |
SuspectedBot | BotScore verdict = bot or suspect over threshold | MELMASTOON.BFF.CONSUMER.SUSPECTED_BOT (429 + CAPTCHA challenge) |
UpstreamBudgetExceeded | Per-request fanout budget hit | MELMASTOON.BFF.CONSUMER.UPSTREAM_BUDGET_EXCEEDED (504) |
LocaleNotSupported | BCP-47 tag not in platform set | MELMASTOON.BFF.CONSUMER.LOCALE_NOT_SUPPORTED (422) |
CurrencyNotSupported | ISO 4217 code not in platform set | MELMASTOON.BFF.CONSUMER.CURRENCY_NOT_SUPPORTED (422) |
8. Privacy + tenancy invariants
GuestSession.idis the only stable identifier and is cookie-bound; no email, phone, or name is collected in Phase 1.tenantIdmay appear onRecentlyViewedEntry,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,ipHashare 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.