Skip to content

Search Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the search-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Two packages compose:

  • @modularityjs/searchSearchService abstract contract + SearchModule + SearchIndexesPool. Methods: ensureIndex(definition) (idempotent), deleteIndex(name), index<T>(indexName, id, document), indexBulk<T>(indexName, entries), get<T>(indexName, id) → T | undefined, delete(indexName, id), refresh(indexName) (visibility barrier), search<T>(indexName, query) → SearchResult<T> ({ total, hits: [{ id, score, document }] }total is the exact pre-pagination match count).
  • @modularityjs/search-{memory,elasticsearch} — drivers. Memory is per-process (capped at SearchMemoryConfig.maxDocumentsPerIndex, default 10 000); Elasticsearch configures via SearchElasticsearchModule.forRoot({ node, indexPrefix, ... }). Both bind via preferences: [{ provide: SearchService, useClass: ... }].

Indices are declared, not created imperatively: contribute a SearchIndexDefinition to SearchIndexesPool — a value pool (useValue: { name, fields }). The active driver ensureIndexes every declared definition in its onInit, so boot fails loudly when the search backend is unreachable. Index names must match [a-z0-9][a-z0-9-_]*; field types are 'text' | 'keyword' | 'number' | 'boolean' | 'date'text is analyzed (use match), keyword is exact (use term / prefix).

Queries use a portable condition union — { kind: 'match' | 'term' | 'prefix' | 'range' | 'and' | 'or' | 'not', ... } — that every driver translates with an exhaustive switch, so the same query object runs against memory and Elasticsearch. filter holds non-scoring conditions AND-ed with condition; omitting condition means match-all.

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
import {
  SearchIndexesPool,
  SearchModule,
  SearchService,
  type SearchResult,
} from '@modularityjs/search';
import { SearchMemoryModule } from '@modularityjs/search-memory';

type Article = {
  title: string;
  category: string;
  views: number;
};

@Injectable()
class ArticleSearchService {
  constructor(@Inject(SearchService) private readonly search: SearchService) {}

  async publish(id: string, article: Article): Promise<void> {
    await this.search.index('articles', id, article);
  }

  async find(text: string, category?: string): Promise<SearchResult<Article>> {
    return this.search.search<Article>('articles', {
      condition: { kind: 'match', field: 'title', value: text },
      filter: category
        ? [{ kind: 'term', field: 'category', value: category }]
        : [],
      sort: [{ field: 'views', order: 'desc' }],
      size: 20,
    });
  }
}

@Module({
  name: 'article-search',
  imports: [SearchModule],
  providers: [ArticleSearchService],
  pools: [
    {
      pool: SearchIndexesPool,
      key: 'articles',
      useValue: {
        name: 'articles',
        fields: {
          title: { type: 'text' },
          category: { type: 'keyword' },
          views: { type: 'number' },
        },
      },
    },
  ],
})
class AppModule {}

const app = await createApp({
  di: inversify,
  modules: [ModularityModule, SearchModule, SearchMemoryModule, AppModule],
});

Switch to Elasticsearch with one line. Replace SearchMemoryModule with SearchElasticsearchModule.forRoot({ node: 'http://localhost:9200' })ArticleSearchService is unchanged. Set indexPrefix to isolate apps or tests sharing a cluster (it's prepended to every index name transparently).

refresh before read-your-writes searches. Elasticsearch is near-real-time — a document indexed a moment ago may not be searchable yet. Call refresh(indexName) after writes when the next search must see them (tests, index-then-list flows); don't call it per-write in hot paths. The memory driver is immediately consistent, so code that refreshes stays portable.

Throw-vs-return split. get returns undefined for a missing document; delete / deleteIndex on a missing target are no-ops; but search against an index that was never ensured throws NotFoundException — that's broken wiring (a missing pool entry), not routine absence. Under an explicit sort, hit.score is undefined; score values are driver-specific, so never compare them across drivers.