Domain Model
:::info Source
Sourced from services/search-service/DOMAIN_MODEL.md in the documentation repo.
:::
Companion: SERVICE_OVERVIEW.md · DATA_MODEL.md · APPLICATION_LOGIC.md
Search-service is a read-model context. It has no native business invariants beyond "every document is a faithful projection of exactly one authoritative source." This doc captures the aggregates, value objects, invariants, and the domain language used throughout the code.
1. Ubiquitous Language
| Term | Meaning |
|---|---|
| SearchableDocument | A denormalized, index-ready record built by projecting one or more domain events. |
| Index | A logical tenant-scoped bucket of SearchableDocuments in OpenSearch. |
| Alias | An OpenSearch alias that maps logical index names to physical indices (allows zero-downtime rebuild). |
| Projection | The process of turning a domain event into a SearchableDocument upsert. |
| Embedding | A fixed-dim float vector produced by an embedding model (via ai-gateway) representing semantic content. |
| Hybrid score | alpha * lex + (1 - alpha) * sem, typically alpha = 0.5. |
| Recommendation | A ranked list of items for a specific user derived from embeddings + collaborative filtering + rules. |
| Reindex | Full rebuild of a tenant index from the domain event log + snapshot API. |
| Backfill | One-shot ingest from a source service's snapshot endpoint (used on cold start). |
| Visibility | Enum governing who can see a document: private, org, marketplace, public. |
2. Aggregates
2.1 SearchableDocument (aggregate root)
type DocumentType =
| 'course'
| 'lesson'
| 'block'
| 'listing'
| 'user'
| 'assignment'
| 'certificate';
interface SearchableDocument {
// Identity
id: string; // globally unique "svc:type:aggregateId" form
tenantId: TenantId;
// Typing
type: DocumentType;
source: { service: string; aggregateId: string; aggregateVersion: number };
// Content
title: I18nString; // { en: 'Intro to Algebra', ar: 'مقدمة في الجبر' }
body: I18nMarkup; // sanitized markdown per locale
summary?: I18nString;
// Categorization
tags: string[]; // free-form
taxonomy: string[]; // catalog taxonomy node IDs (dot-path)
facets: Record<string, JSONValue>; // e.g. { level: 'beginner', durationMin: 45 }
// Access
visibility: 'private' | 'org' | 'marketplace' | 'public';
audiences?: string[]; // role/cohort IDs the doc is meaningful to
// Semantic layer
embedding?: number[]; // 768 or 1024 dim depending on model
embeddingModelId?: string; // e.g. 'text-embed-3-small@2025-04-01'
embeddingHash?: string; // sha256 over content used to build embedding
// Lifecycle
publishedAt?: ISODate;
updatedAt: ISODate;
deletedAt?: ISODate; // soft delete (tombstone)
// Metadata
locale: BCP47; // primary locale for tie-breaking
region: 'us' | 'eu' | 'me' | 'ap';
quality?: { ratingAvg?: number; enrollmentCount?: number; completionRate?: number };
}
Invariants
| # | Invariant | Enforced by |
|---|---|---|
| D1 | tenantId is required and immutable | Projector validation + OpenSearch mapping |
| D2 | id form is {service}:{type}:{aggregateId}; uniqueness per tenant | Indexer |
| D3 | aggregateVersion monotonically non-decreasing per id | Indexer with if_seq_no/ version compare |
| D4 | If embedding present, embeddingModelId and embeddingHash must also be present | Indexer validation |
| D5 | visibility = marketplace forbidden unless source is marketplace-service | Projector policy |
| D6 | deletedAt set → no fulltext match; retained for tombstone replay | Query builder + TTL |
| D7 | No PII in body for documents with visibility ∈ {marketplace, public} | Sanitizer (see AI_INTEGRATION §6) |
| D8 | region equals tenant data-residency region | Projector |
2.2 Recommendation (aggregate root)
interface Recommendation {
id: ULID; // generation id
tenantId: TenantId;
userId: UserId;
generatedAt: ISODate;
modelVersion: string; // 'rec-l2r@2025-03-14'
items: RecommendedItem[];
expiresAt: ISODate;
reasonSummary?: string;
}
interface RecommendedItem {
itemId: string;
itemType: DocumentType;
score: number; // normalized 0..1
reason: 'similar_to_enrolled' | 'completed_next_step' | 'cohort_peer' | 'trending' | 'ai_editor_pick';
explanation?: string; // surfaced in UI ("because you completed X")
features?: Record<string, number>; // sparse top-k feature contribs (for debugging)
}
Invariants:
- R1:
items[].itemType ∈ {course, lesson, listing}only. - R2:
expiresAt - generatedAt ≤ 24h(freshness cap). - R3: Items must pass authorization filter at serve-time — the list is not pre-authorized.
2.3 IndexPolicy (aggregate root)
Governs per-tenant index behavior (shard count, embedding model, retention).
interface IndexPolicy {
tenantId: TenantId;
alias: string; // 'tenant_01H...'
physicalIndex: string; // 'tenant_01H..._2025-04-15'
primaryShards: number; // default 1; bumped for largest tenants
replicas: number;
embeddingModelId: string;
reindexVersion: number; // bump to trigger rebuild
createdAt: ISODate;
lastReindexAt?: ISODate;
}
2.4 SuggestEntry (value object)
interface SuggestEntry {
text: string;
weight: number; // from CTR + recency
contexts: { tenantId: TenantId; type?: DocumentType; locale?: BCP47 };
}
3. Bounded Context Boundaries
Search-service does not model:
- Course structure → catalog-service.
- Enrollment/purchase state → enrollment-service.
- Authoring draft state → authoring-service.
- User identity → identity-service.
When a search result is clicked, the authoritative service is queried for the current state. Search indexes may be stale by up to 2s (SLO).
4. Document Type Matrix
| Type | Sourced from | Key fields | Visibility rules |
|---|---|---|---|
course | catalog (course_version.published) | title, summary, taxonomy, level, durationMin | org within tenant; marketplace if listed; public if opened |
lesson | catalog + authoring | title, body, blocks summary, parent course | inherits from course |
block | authoring (block.updated) | block body slice, type, parent lesson | org only (drafts not searchable across org) |
listing | marketplace (listing.approved) | title, description, price, rating | always marketplace (cross-tenant for buyers) |
user | tenant (membership_activated) | display name, roles, skills | org only, never marketplace |
assignment | assignment-service | title, description, due date | org only |
certificate | certification (certificate.issued) | title, recipient, issuedAt | private + optional share link (public URL but not indexed public) |
5. State Transitions
5.1 Document lifecycle
5.2 Recommendation generation
6. Policies
| Policy | Rule |
|---|---|
| Deletion policy | Tombstone retained 30d; then hard-delete |
| Visibility demotion | Any event that demotes visibility triggers immediate reindex |
| Locale fallback | If query locale missing in document, fallback = tenant default locale |
| Embedding drift | Any change in embeddingModelId forces re-embedding over ≤ 14d rolling window |
| Tenant deletion | Alias + physical index hard-deleted within 24h (GDPR erasure) |
7. Domain Events (internal)
Search-service emits two internal events for its own state changes:
search.document.indexed.v1— bookkeeping / analytics.search.reindex.completed.v1— signals tenants can rely on freshness.
See EVENT_SCHEMAS.md for full schemas.