Skip to main content

search-aggregation-service — Service Overview

Companion: Summary · Service Template · Naming · 02 Enterprise Architecture · 04 Event-Driven Architecture · 05 API Design · 06 Data Models · 07 Security & Tenancy

1. One-paragraph mission

search-aggregation-service is the only service in Ghasi Melmastoon authorized to query data across tenants. It maintains a cross-tenant materialized search index built from upstream events (property-service, pricing-service, inventory-service, tenant-service) and exposes a public-safe consumer meta-search API consumed by bff-consumer-service. It powers the Trivago-/Booking-like discovery layer where a guest searches a city + dates + occupancy and sees hotels from many tenants ranked by price, distance, popularity, and rating. The service is strictly read-only on cross-tenant data: it never writes to upstream services, and bookings always deep-link out into the matching tenant's bff-tenant-booking-service.

2. Bounded Context Position

FieldValue
Bounded ContextDiscovery
Domain TypeCore
Strategic intentDifferentiator: regional-first meta-search (AF / TJ / IR), multi-language analyzers (Pashto/Dari/Persian/Tajik/EN/AR/UR/RU), price-aware ranking with FX, OpenSearch-backed geo+facet, semantic re-ranking (Phase 2+)
Upstream contextsProperty, Pricing, Inventory, Tenant, File Storage
Downstream contextsBFF Consumer, Analytics, AI Orchestrator (Phase 2+)
Pattern with upstreamConformist (consumes published events; never calls back)
Pattern with downstreamOpen Host Service (REST + Published Language click/query events)
Shared kernelTenantId, PropertyId, Locale, I18nString, GeoPoint, Money, ISO4217, MediaRef from @ghasi/contracts-melmastoon

3. Capabilities

CapabilityNotes
Meta-search queryText + filters (dates, occupancy, price band, amenities, star rating, regional pinning) + sort (price asc/desc, distance, popularity, rating) + pagination (cursor, max 200)
Geo searchbounding-box and great-circle radius (default 50 km from city center, configurable up to 200 km), tile-friendly result shapes for Leaflet/Mapbox
Hotel detail aggregateFull denormalized property card (multi-locale name/desc, hero photo, amenities, cheapest rate snapshot, available rooms hint) in a single call
Autocomplete suggestCity + property name suggestions per locale (edge n-grams)
FacetsCounts per amenity, price band, star rating, language, country
Click recordingPOST /search/clicks records the deep-link click for ranking signal
Boost rules (Phase 3+)Operator-configurable lift on a (propertyId, criteria) for surfacing
Sponsored placement (Phase 3+)Auction-driven slot fill
Index lifecycleIndex template, alias-based blue/green swap, ILM (rollover, hot/warm/delete), reindex on mapping change
Currency conversionFX-snapshot-based display (pricing-service.fx_snapshot.updated.v1); price floors/ceilings per region
Multi-languagePer-locale OpenSearch analyzer (e.g., pashto_standard, arabic_standard, english_standard); native-script collation respected
Region pinningFirst launch: only show properties in tenant.region ∈ {AF, TJ, IR}; expand globally as new regions open

4. Non-Capabilities (explicitly out of scope)

CapabilityOwned by
Property catalog (CRUD, photos, geo set)property-service
Per-night availability and holdsinventory-service
Rate plans, prices, derivationspricing-service
Booking and reservation flowreservation-service (rendered via bff-tenant-booking-service)
FX rate calculationpricing-service (we consume snapshots only)
Guest PII, payment, lock secrets, financial ledgersTheir respective owners — never indexed here
Tenant configuration / themingtenant-service, theme-config-service
Reporting / dashboardsreporting-service, analytics-service
Cache invalidation broadcast for tenant booking pagesbff-tenant-booking-service

5. Architecture (Clean / Hexagonal)

search-aggregation-service/
└── src/
├── domain/ # pure: aggregates, VOs, domain events, invariants
│ ├── hotel-index-entry/
│ │ ├── HotelIndexEntry.ts # denormalized property card aggregate root
│ │ ├── IndexFieldAllowList.ts # field allow-list constants (cross_tenant_searchable)
│ │ └── events/ # IndexEntryUpserted, IndexEntryDeleted, ...
│ ├── rate-snapshot/
│ │ ├── RateSnapshot.ts # cheapest-rate per (property, date, currency)
│ │ ├── CurrencyConverter.ts # uses injected FXSnapshot
│ │ └── events/
│ ├── availability-hint/
│ │ ├── AvailabilityHint.ts # per-date counts (NEVER per-room IDs)
│ │ └── events/
│ ├── amenity-index/
│ │ ├── AmenityIndex.ts # canonical amenity → property bitmap
│ │ └── AmenityCode.ts # canonical registry mirror
│ ├── location-index/
│ │ ├── LocationIndex.ts
│ │ ├── GeoQueryShape.ts # bbox / radius / polygon (later)
│ │ └── Geohash.ts # precision-5 default
│ ├── search-query/
│ │ ├── SearchQuery.ts # canonicalized + hashed
│ │ ├── SortKey.ts # price | distance | popularity | rating
│ │ ├── Filter.ts # union type
│ │ └── events/ # QueryExecuted (sampled)
│ ├── click-event/
│ │ ├── ClickEvent.ts
│ │ └── events/
│ ├── boost-rule/ # Phase 3+
│ │ ├── BoostRule.ts
│ │ ├── BoostScope.ts
│ │ └── events/
│ ├── sponsored-ranking/ # Phase 3+
│ │ ├── SponsoredRanking.ts
│ │ └── events/
│ ├── index-build/
│ │ ├── IndexBuild.ts # control aggregate for full reindexes
│ │ ├── IndexAliasSwap.ts
│ │ └── events/
│ └── shared/
│ ├── PropertyId.ts # branded `ppt_…` (mirrored from property-service)
│ ├── SearchableDocumentId.ts # `srh_…`
│ ├── BoostRuleId.ts # `brt_…`
│ ├── ClickEventId.ts # `clk_…`
│ ├── IndexBuildId.ts # `ibd_…`
│ └── errors/
├── application/ # use cases, ports, CQRS handlers
│ ├── ports/
│ │ ├── HotelIndexRepository.ts # Postgres canonical projection
│ │ ├── RateSnapshotRepository.ts
│ │ ├── AvailabilityHintRepository.ts
│ │ ├── BoostRuleRepository.ts
│ │ ├── SearchEnginePort.ts # OpenSearch (write + query)
│ │ ├── SearchCachePort.ts # Memorystore Redis
│ │ ├── EventPublisher.ts # outbox publisher
│ │ ├── EventArchiveReader.ts # for full reindex from archive
│ │ ├── FxSnapshotPort.ts # FX cache
│ │ ├── ProvinceCenterPort.ts # city → lat/lng/timezone catalog
│ │ ├── AnalyticsSinkPort.ts # publishes click/query events
│ │ ├── Clock.ts
│ │ └── IdGenerator.ts
│ ├── commands/
│ │ ├── upsert-hotel-index-entry.handler.ts
│ │ ├── delete-hotel-index-entry.handler.ts
│ │ ├── upsert-rate-snapshot.handler.ts
│ │ ├── upsert-availability-hint.handler.ts
│ │ ├── purge-tenant-from-index.handler.ts
│ │ ├── record-click.handler.ts
│ │ ├── create-boost-rule.handler.ts
│ │ ├── activate-boost-rule.handler.ts
│ │ └── start-index-rebuild.handler.ts
│ ├── queries/
│ │ ├── execute-search.handler.ts # the consumer search query
│ │ ├── get-hotel-detail.handler.ts
│ │ ├── suggest.handler.ts
│ │ ├── facets.handler.ts
│ │ └── index-health.handler.ts
│ ├── consumers/ # one file per inbound event
│ │ ├── property-published.consumer.ts
│ │ ├── property-updated.consumer.ts
│ │ ├── property-deleted.consumer.ts
│ │ ├── property-unpublished.consumer.ts
│ │ ├── property-amenity-set-updated.consumer.ts
│ │ ├── property-photo-added.consumer.ts
│ │ ├── room-type-updated.consumer.ts
│ │ ├── pricing-rate-plan-updated.consumer.ts
│ │ ├── pricing-fx-snapshot-updated.consumer.ts
│ │ ├── inventory-allocation-confirmed.consumer.ts
│ │ ├── inventory-allocation-released.consumer.ts
│ │ ├── inventory-block-created.consumer.ts
│ │ ├── inventory-block-released.consumer.ts
│ │ └── tenant-deleted.consumer.ts
│ └── policies/
│ ├── projection-allow-list.policy.ts # enforces cross_tenant_searchable
│ ├── ranking.policy.ts # sort/score composition
│ └── region-pinning.policy.ts # AF/TJ/IR initial gate
├── infrastructure/ # adapters
│ ├── postgres/
│ │ ├── HotelIndexRepositoryPg.ts
│ │ ├── RateSnapshotRepositoryPg.ts
│ │ ├── AvailabilityHintRepositoryPg.ts
│ │ ├── BoostRuleRepositoryPg.ts
│ │ ├── ClickEventRepositoryPg.ts
│ │ ├── OutboxRepositoryPg.ts
│ │ ├── InboxRepositoryPg.ts
│ │ └── tenant-context.ts # SET LOCAL app.tenant_id = '__cross_tenant__'
│ ├── opensearch/
│ │ ├── OpenSearchEngineAdapter.ts
│ │ ├── index-templates/
│ │ │ ├── hotel-index.template.json
│ │ │ ├── analyzers/
│ │ │ │ ├── pashto.json
│ │ │ │ ├── dari.json
│ │ │ │ ├── persian.json
│ │ │ │ ├── tajik.json
│ │ │ │ ├── arabic.json
│ │ │ │ ├── urdu.json
│ │ │ │ ├── english.json
│ │ │ │ └── russian.json
│ │ │ └── lifecycle/
│ │ │ └── ilm-policy.json
│ │ └── alias-swap.ts
│ ├── redis/
│ │ ├── SearchCacheRedis.ts
│ │ └── key-builder.ts # srh:q:<sha256>
│ ├── pubsub/
│ │ ├── EventPublisherPubSub.ts
│ │ └── consumers/ # one per upstream topic group
│ ├── archive/
│ │ └── EventArchiveBigQueryAdapter.ts # for replay rebuild
│ ├── fx/
│ │ └── FxSnapshotCacheRedisAdapter.ts
│ ├── geo/
│ │ └── ProvinceCenterCatalogAdapter.ts
│ ├── analytics/
│ │ └── AnalyticsSinkPubSubAdapter.ts
│ └── auditor/
│ └── ProjectionExposureAuditor.ts # nightly forbidden-field scanner
└── presentation/ # controllers, DTOs, OpenAPI
├── http/
│ ├── SearchController.ts # POST /search/queries
│ ├── HotelDetailController.ts # GET /search/hotels/:id
│ ├── SuggestController.ts
│ ├── FacetsController.ts
│ ├── ClicksController.ts
│ ├── BoostRulesController.ts
│ ├── IndexController.ts # rebuild, health
│ ├── ProjectionInternalController.ts # /internal/projection/changes
│ └── HealthController.ts
└── dto/
├── SearchQueryDto.ts
├── SearchResultDto.ts
├── HotelDetailDto.ts
├── SuggestDto.ts
├── FacetsDto.ts
├── ClickEventDto.ts
└── BoostRuleDto.ts

Dependency rule: presentation → application → domain, infrastructure → application (adapters implement ports). domain imports nothing outside itself and @ghasi/contracts-melmastoon shared-kernel VOs. CI enforces with ESLint import-restriction rules.

6. Tech Stack

LayerChoice
Language / runtimeTypeScript on Node 20 LTS
HTTP frameworkNestJS (Fastify adapter)
ORM / DB driverpg + kysely (typed query builder); migrations via node-pg-migrate
Validationzod for DTOs and event payloads
Search engineOpenSearch 2.x (Aiven-managed on GCP — preferred; self-managed on GKE as fallback) via @opensearch-project/opensearch
CacheMemorystore Redis 7
GeoPostGIS 3.x (canonical), OpenSearch geo_point + geo_shape (read index)
Vector ranking (Phase 2+)pgvector on the same Postgres instance + OpenSearch k-NN plugin
MessagingGCP Pub/Sub (transactional outbox + inbox dedupe)
Loggingpino JSON
TracingOpenTelemetry → Cloud Trace
MetricsOpenTelemetry → Cloud Monitoring

7. SLOs

SLITarget
Search query p95 (cache hit)≤ 30 ms
Search query p95 (cache miss)≤ 350 ms
Hotel detail p95≤ 120 ms
Suggest p95≤ 80 ms
Projection freshness p95 (event → indexed)≤ 5 s
Availability (consumer-facing)99.95 % monthly
OpenSearch indexing throughput≥ 2 000 docs/s sustained
Cross-tenant exposure auditor failures0
Boost-rule activation propagation≤ 30 s

8. Quotas / limits

ResourceDefault cap
Results per query200 (cursor-paginated)
Cursor TTL5 min
Geo radius50 km default, 200 km max
Geo bbox area50 000 km²
Suggest result count10
Boost rules per tenant50
Cache key TTL (srh:q:*)60 s
Cache key TTL (srh:detail:*)300 s
Reindex concurrency1 active build per region

9. Risks Snapshot

RiskMitigation
Cross-tenant leak via field driftField allow-list policy + nightly auditor + integration tests + RLS-style guard at projection write
Stale projection on out-of-order eventsLast-write-wins on occurredAt + vector clock; alarmed if eventLagSeconds > 30s
OpenSearch outagePostgres-only fallback (degraded ranking) + circuit breaker + health alert
FX snapshot lag → wrong displayed priceTTL on FX cache 1 h; flag stale conversion in response (fxAgeSeconds)
Index rebuild storms (event replay)One active build per region; alias swap atomic; rate-limited reindex
Hot tenant skew in popularity rankingPer-tenant click cap in popularity score; outlier dampener
Multi-language analyzer misconfigurationPer-locale analyzer fixtures + golden-query test; visual diff on top-50 results
Boost rule abuseOperator-only audit trail; rate-limited; signed approval per activation

Full register: SERVICE_RISK_REGISTER.

10. Definition of Ready / Done

  • Ready (per story): AC, NFRs, OpenAPI delta, event schema delta, projection allow-list delta (any new field gets explicit cross_tenant_searchable: true|false), AI provenance impact (Phase 2+), observability impact, runbook entry.
  • Done (per story): tests in pyramid (unit, integration, contract, projection, ranking-golden, AI), cross-tenant exposure auditor passes, outbox spec passes, inbox spec passes, OpenAPI lint passes, OpenSearch index template diff reviewed, dashboards updated, ADR if cross-cutting.

11. Glossary

TermMeaning
HotelIndexEntryDenormalized property card written to Postgres canonical projection and OpenSearch read index.
RateSnapshotCheapest available rate per (propertyId, date, currency) derived from pricing-service events.
AvailabilityHintPer-date count of available rooms — never per-room IDs.
AmenityIndexCanonical amenity ↔ property mapping for fast facet calculation.
LocationIndexPostGIS + geohash structure for bbox/radius queries.
SearchQuery (logged)Canonicalized, hashed query persisted for analytics with anonymization at 30 d.
BoostRuleOperator-configurable lift on a (propertyId, criteria) slice for surfacing (Phase 3+).
SponsoredRankingAuction-driven slot fill in result lists (Phase 3+).
IndexBuildControl aggregate for a full reindex from event archive with alias swap.
cross_tenant_searchableField-level allow-list flag; only true fields are projected into the cross-tenant index.
Region pinningInitial-launch filter restricting visible properties to tenant.region ∈ {AF, TJ, IR}.