Skip to main content

Adding a search engine

Evolve's three-layer architecture separates schema definitions from implementation. The catalog module defines search interfaces (product search, facets, categories) in @evolve-framework/schemas, while the actual search logic lives in an implementation package. This guide walks through creating a new implementation for a search engine such as Typesense, Meilisearch, or Elasticsearch.

1. Create the implementation package

Create a new package that depends on the core and schemas layers:

packages/search-typesense/
├── src/
│ ├── index.ts
│ ├── module.ts
│ ├── search-provider.ts
│ ├── mappers/
│ │ └── product.ts
│ └── client.ts
└── package.json
{
"name": "@evolve-packages/search-typesense",
"dependencies": {
"@evolve-framework/core": "workspace:*",
"@evolve-framework/schemas": "workspace:*",
"@evolve-framework/commercetools": "workspace:*",
"typesense": "^1.8.0"
}
}

2. Implement a SearchProvider

The catalog module delegates search to a SearchProvider abstraction. Create a subclass that implements search against your engine:

// src/search-provider.ts
import { SearchProvider } from "@evolve-framework/commercetools";

export class TypesenseSearchProvider extends SearchProvider {
async searchProducts(context, args) {
const client = this.createClient(context.config);

const results = await client.collections("products").documents().search({
q: args.query,
filter_by: buildFilters(args.filters, context.storeContext),
sort_by: mapSortOrder(args.sort),
page: args.page,
per_page: args.pageSize,
facet_by: args.facets?.join(","),
});

return {
results: results.hits.map(mapSearchHitToProduct),
total: results.found,
facets: mapFacets(results.facet_counts),
};
}

async searchCategories(context, args) {
// Implement category search
}

private createClient(config: Record<string, unknown>) {
return new Typesense.Client({
nodes: [{ host: config.typesenseHost, port: 443, protocol: "https" }],
apiKey: config.typesenseApiKey,
});
}
}

Note that the results field (not items) and facets field match the ProductsResult type defined in @evolve-framework/schemas.

3. Create the GraphQL module

Subclass CatalogGraphQLModule and override the search-related resolvers. The productSearch resolver dispatches to your search provider based on the module config:

// src/module.ts
import { CatalogGraphQLModule } from "@evolve-framework/commercetools";
import { TypesenseSearchProvider } from "./search-provider.ts";

export class TypesenseCatalogModule extends CatalogGraphQLModule {
getResolvers() {
const resolvers = super.getResolvers();
const provider = new TypesenseSearchProvider();

resolvers.Query.productSearch = async (_parent, args, context) => {
return provider.searchProducts(context, args);
};

return resolvers;
}
}

This replaces only the search resolvers. Product detail pages, categories, and other catalog queries continue to use the default commercetools resolvers.

4. Wire into the catalog service

In the catalog service's server.ts, swap the default module for your new one and pass the search engine configuration:

// backend/services/catalog-commercetools/src/server.ts
import { GraphQLCompositeModule } from "@evolve-framework/commercetools";
import { TypesenseCatalogModule } from "@evolve-packages/search-typesense";

const module = new GraphQLCompositeModule([
new TypesenseCatalogModule({
config: {
searchEngine: "typesense",
typesenseHost: config.TYPESENSE_HOST,
typesenseApiKey: config.TYPESENSE_API_KEY,
},
}),
]);

The config is passed through to context.config so your search provider can access the credentials at request time.

5. Sync product data

Search engines need a product feed. Evolve publishes product-published events when products change. Create an event handler that indexes products into your search engine. See custom events for how to wire a handler.

6. Add credentials to Terraform

Store the search engine API key and host as secrets and inject them as environment variables. Follow the patterns in the existing Algolia configuration in terraform/.

Further reading