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
| Field | Value |
|---|---|
| Bounded Context | Discovery |
| Domain Type | Core |
| Strategic intent | Differentiator: 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 contexts | Property, Pricing, Inventory, Tenant, File Storage |
| Downstream contexts | BFF Consumer, Analytics, AI Orchestrator (Phase 2+) |
| Pattern with upstream | Conformist (consumes published events; never calls back) |
| Pattern with downstream | Open Host Service (REST + Published Language click/query events) |
| Shared kernel | TenantId, PropertyId, Locale, I18nString, GeoPoint, Money, ISO4217, MediaRef from @ghasi/contracts-melmastoon |
3. Capabilities
| Capability | Notes |
|---|---|
| Meta-search query | Text + filters (dates, occupancy, price band, amenities, star rating, regional pinning) + sort (price asc/desc, distance, popularity, rating) + pagination (cursor, max 200) |
| Geo search | bounding-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 aggregate | Full denormalized property card (multi-locale name/desc, hero photo, amenities, cheapest rate snapshot, available rooms hint) in a single call |
| Autocomplete suggest | City + property name suggestions per locale (edge n-grams) |
| Facets | Counts per amenity, price band, star rating, language, country |
| Click recording | POST /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 lifecycle | Index template, alias-based blue/green swap, ILM (rollover, hot/warm/delete), reindex on mapping change |
| Currency conversion | FX-snapshot-based display (pricing-service.fx_snapshot.updated.v1); price floors/ceilings per region |
| Multi-language | Per-locale OpenSearch analyzer (e.g., pashto_standard, arabic_standard, english_standard); native-script collation respected |
| Region pinning | First launch: only show properties in tenant.region ∈ {AF, TJ, IR}; expand globally as new regions open |
4. Non-Capabilities (explicitly out of scope)
| Capability | Owned by |
|---|---|
| Property catalog (CRUD, photos, geo set) | property-service |
| Per-night availability and holds | inventory-service |
| Rate plans, prices, derivations | pricing-service |
| Booking and reservation flow | reservation-service (rendered via bff-tenant-booking-service) |
| FX rate calculation | pricing-service (we consume snapshots only) |
| Guest PII, payment, lock secrets, financial ledgers | Their respective owners — never indexed here |
| Tenant configuration / theming | tenant-service, theme-config-service |
| Reporting / dashboards | reporting-service, analytics-service |
| Cache invalidation broadcast for tenant booking pages | bff-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
| Layer | Choice |
|---|---|
| Language / runtime | TypeScript on Node 20 LTS |
| HTTP framework | NestJS (Fastify adapter) |
| ORM / DB driver | pg + kysely (typed query builder); migrations via node-pg-migrate |
| Validation | zod for DTOs and event payloads |
| Search engine | OpenSearch 2.x (Aiven-managed on GCP — preferred; self-managed on GKE as fallback) via @opensearch-project/opensearch |
| Cache | Memorystore Redis 7 |
| Geo | PostGIS 3.x (canonical), OpenSearch geo_point + geo_shape (read index) |
| Vector ranking (Phase 2+) | pgvector on the same Postgres instance + OpenSearch k-NN plugin |
| Messaging | GCP Pub/Sub (transactional outbox + inbox dedupe) |
| Logging | pino JSON |
| Tracing | OpenTelemetry → Cloud Trace |
| Metrics | OpenTelemetry → Cloud Monitoring |
7. SLOs
| SLI | Target |
|---|---|
| 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 failures | 0 |
| Boost-rule activation propagation | ≤ 30 s |
8. Quotas / limits
| Resource | Default cap |
|---|---|
| Results per query | 200 (cursor-paginated) |
| Cursor TTL | 5 min |
| Geo radius | 50 km default, 200 km max |
| Geo bbox area | 50 000 km² |
| Suggest result count | 10 |
| Boost rules per tenant | 50 |
Cache key TTL (srh:q:*) | 60 s |
Cache key TTL (srh:detail:*) | 300 s |
| Reindex concurrency | 1 active build per region |
9. Risks Snapshot
| Risk | Mitigation |
|---|---|
| Cross-tenant leak via field drift | Field allow-list policy + nightly auditor + integration tests + RLS-style guard at projection write |
| Stale projection on out-of-order events | Last-write-wins on occurredAt + vector clock; alarmed if eventLagSeconds > 30s |
| OpenSearch outage | Postgres-only fallback (degraded ranking) + circuit breaker + health alert |
| FX snapshot lag → wrong displayed price | TTL 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 ranking | Per-tenant click cap in popularity score; outlier dampener |
| Multi-language analyzer misconfiguration | Per-locale analyzer fixtures + golden-query test; visual diff on top-50 results |
| Boost rule abuse | Operator-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
| Term | Meaning |
|---|---|
HotelIndexEntry | Denormalized property card written to Postgres canonical projection and OpenSearch read index. |
RateSnapshot | Cheapest available rate per (propertyId, date, currency) derived from pricing-service events. |
AvailabilityHint | Per-date count of available rooms — never per-room IDs. |
AmenityIndex | Canonical amenity ↔ property mapping for fast facet calculation. |
LocationIndex | PostGIS + geohash structure for bbox/radius queries. |
SearchQuery (logged) | Canonicalized, hashed query persisted for analytics with anonymization at 30 d. |
BoostRule | Operator-configurable lift on a (propertyId, criteria) slice for surfacing (Phase 3+). |
SponsoredRanking | Auction-driven slot fill in result lists (Phase 3+). |
IndexBuild | Control aggregate for a full reindex from event archive with alias swap. |
cross_tenant_searchable | Field-level allow-list flag; only true fields are projected into the cross-tenant index. |
| Region pinning | Initial-launch filter restricting visible properties to tenant.region ∈ {AF, TJ, IR}. |