Skip to content

Build your own adapter

Every vendor package in Evolve is an adapter: a class that implements a contract the framework defines, for one vendor. The framework owns the flow around it, such as syncing a catalog or taking a payment through checkout. The adapter only talks to the vendor.

When Evolve has no package for your vendor, you write that class in your own project and hand it to the same module the shipped adapters go to. Partners most often need one of these:

You connect Contract Worked example
A search engine SearchIndexAdapter and SearchQueryAdapter @evolve-framework/search-meilisearch
A payment provider PaymentAdapter, through BasePaymentAdapter @evolve-framework/commercetools-mollie
A CMS The content contract in @evolve-framework/schemas, served by a module of your own @evolve-framework/storyblok, @evolve-framework/contentful, @evolve-framework/payload

The other contracts are listed at the end. The examples below connect a made-up vendor, Acme.

Search has two contracts, one per direction:

  • SearchIndexAdapter writes documents into the index. IndexSyncService calls it, on catalog events and on a full reindex.
  • SearchQueryAdapter answers queries. The catalog module calls it for productSearch.

They are separate so the indexer never holds a search key and the API never holds a write key. One class may implement both: the type for that is SearchAdapter.

import type {
DeleteBySourceOptions,
IndexScope,
ProductDocument,
SearchCapabilities,
SearchIndexAdapter,
} from "@evolve-framework/search";
import type { AcmeClient } from "./acme-client.ts";
export const acmeCapabilities: SearchCapabilities = {
categorySearch: false,
suggestions: false,
fullReindex: false,
deleteBySource: true,
personalization: false,
facets: [],
sorts: ["relevance"],
};

Be honest here. The framework checks these flags and degrades on purpose instead of serving a silently empty result. Each flag that is true promises the matching method: categorySearch means searchCategories, suggestions means suggest, fullReindex means beginFullReindex, and deleteBySource means deleteBySource.

The adapter talks to Acme through a small client of your own, so a test can replace it. Acme stores the documents as they are.

import type { ProductDocument } from "@evolve-framework/search";
export type AcmeClient = {
save(index: string, records: ProductDocument[]): Promise<void>;
remove(index: string, ids: string[]): Promise<void>;
removeWhere(index: string, filter: { sourceIds: string[]; keep: string[] }): Promise<void>;
query(
index: string,
request: { term: string; page: number; pageSize: number },
): Promise<{ hits: ProductDocument[]; total: number }>;
};
export const indexName = (
storeKey: string,
locale: string,
documentType: string,
): string => `${documentType}-${storeKey}-${locale}`.toLowerCase();
export class AcmeIndexAdapter implements SearchIndexAdapter<ProductDocument> {
readonly identifier = "acme";
readonly capabilities = acmeCapabilities;
readonly batchSize = 500;
private readonly client: AcmeClient;
constructor(client: AcmeClient) {
this.client = client;
}
async init(): Promise<void> {}
async upsertDocuments(scope: IndexScope, documents: ProductDocument[]): Promise<void> {
const types = new Set(documents.map((document) => document.type));
for (const locale of scope.locales) {
for (const type of types) {
await this.client.save(
indexName(scope.storeKey, locale, type),
documents.filter((document) => document.type === type),
);
}
}
}
async deleteDocuments(
scope: IndexScope,
ids: string[],
documentTypes: string[],
): Promise<void> {
for (const index of this.indexes(scope, documentTypes)) {
await this.client.remove(index, ids);
}
}
async deleteBySource(
scope: IndexScope,
sourceIds: string[],
options: DeleteBySourceOptions,
): Promise<void> {
for (const index of this.indexes(scope, options.documentTypes)) {
await this.client.removeWhere(index, { sourceIds, keep: options.keep ?? [] });
}
}
private indexes(scope: IndexScope, documentTypes: string[]): string[] {
const types = documentTypes.length ? documentTypes : ["product"];
return scope.locales.flatMap((locale) =>
types.map((type) => indexName(scope.storeKey, locale, type)),
);
}
}

What the contract asks of every index adapter:

  • Resolve your own targets from the scope. An IndexScope is a store and its locales, not an index name. Meilisearch keeps one index per store and locale, because it stems per index. Acme adds the document type.
  • Delete from every type you are given. An id does not say which index it went to, so deleteDocuments and deleteBySource receive the document types the source can emit.
  • Throw on failure. A write that logs and resolves acknowledges the queue message that triggered it, and the update is lost.
  • Pass unknown fields through. A project that adds a field to its documents has to get it back on the query side.
  • Keep init() idempotent. Build SDK clients there, once.

Three methods are optional:

Method Implement it when
deleteBySource The catalog source fans one product out into several documents, as VariantDocumentMapper does. IndexSyncService refuses to start without it then.
beginFullReindex The vendor can build an index aside and swap it in. A reindex is then invisible to shoppers until it commits. Meilisearch builds a temporary index per locale and swaps them all in one request. Without it, the reindex writes in place.
applySettings You want to push the vendor’s index configuration from code.
import type {
ProductDocument,
ProductSearchQuery,
SearchQueryAdapter,
SearchResponse,
} from "@evolve-framework/search";
import type { AcmeClient } from "./acme-client.ts";
import { acmeCapabilities, indexName } from "./acme-index-adapter.ts";
export class AcmeQueryAdapter
implements SearchQueryAdapter<ProductDocument, ProductSearchQuery>
{
readonly identifier = "acme";
readonly capabilities = acmeCapabilities;
private readonly client: AcmeClient;
constructor(client: AcmeClient) {
this.client = client;
}
async init(): Promise<void> {}
async search(query: ProductSearchQuery): Promise<SearchResponse<ProductDocument>> {
const { storeKey, locale } = query.scope;
// A real adapter also applies categoryId, skus, filters, and prefilters.
const result = await this.client.query(indexName(storeKey, locale, "product"), {
term: query.term ?? "",
page: query.page ?? 1,
pageSize: query.pageSize ?? 24,
});
return { hits: result.hits, total: result.total, facets: [], raw: result };
}
}
  • A query has exactly one store, locale, and currency in query.scope.
  • Sort on a field you do not know by relevance instead of failing. Which sorts exist is decided by the index configuration, which the query cannot check.
  • When the index does not exist yet, throw SearchIndexNotFoundError. It tells whoever is on call to run a sync, not to look for an outage.

The indexer composes your index adapter with the commerce engine’s document source in SearchIndexerModule:

import { CommercetoolsCatalogSource } from "@evolve-framework/commercetools/catalog";
import { SearchIndexerModule } from "@evolve-framework/search/indexing";
const indexer = new SearchIndexerModule({
source: new CommercetoolsCatalogSource({ clientFactory }),
adapter: new AcmeIndexAdapter(acmeClient),
secret: process.env.INDEXER_SECRET,
});

The catalog module takes your query adapter:

import { CatalogModule, DocumentSearchProvider } from "@evolve-framework/commercetools";
new CatalogModule({
clientFactory,
search: new DocumentSearchProvider({ adapter: new AcmeQueryAdapter(acmeClient) }),
});

Running the indexer, feeding it catalog events, and the first reindex are in Add search.

@evolve-framework/search/testing has the scenarios every index adapter has to pass, as describeSearchIndexAdapterContract. It drives your adapter the way the framework does, through IndexSyncService over a FakeCatalogSource. You hand it a fresh adapter per scenario, and a read that lists the ids in the index for a scope and document type. Replace the Acme client with a map per index:

import type { ProductDocument } from "@evolve-framework/search";
import { describeSearchIndexAdapterContract } from "@evolve-framework/search/testing";
import type { AcmeClient } from "./acme-client.ts";
import { AcmeIndexAdapter, indexName } from "./acme-index-adapter.ts";
const fakeAcme = () => {
const indexes = new Map<string, Map<string, ProductDocument>>();
const index = (name: string) => {
const records = indexes.get(name) ?? new Map<string, ProductDocument>();
indexes.set(name, records);
return records;
};
let failNext = false;
const client: AcmeClient = {
save: async (name, records) => {
if (failNext) {
failNext = false;
throw new Error("Acme is down");
}
for (const record of records) index(name).set(record.id, record);
},
remove: async (name, ids) => {
for (const id of ids) index(name).delete(id);
},
removeWhere: async (name, { sourceIds, keep }) => {
for (const [id, record] of index(name)) {
const stale = sourceIds.includes(record.sourceId) && !keep.includes(id);
if (stale) index(name).delete(id);
}
},
query: async (name) => {
const hits = [...index(name).values()];
return { hits, total: hits.length };
},
};
return {
client,
ids: (name: string) => [...index(name).keys()],
failNextWrite: () => {
failNext = true;
},
};
};
describeSearchIndexAdapterContract("AcmeIndexAdapter", () => {
const acme = fakeAcme();
return {
adapter: new AcmeIndexAdapter(acme.client),
read: async (scope, documentType) =>
acme.ids(indexName(scope.storeKey, scope.locales[0], documentType)),
failNextWrite: acme.failNextWrite,
};
});

The contract checks that:

  • deleting an entity of type "bundle" empties the bundle index, and leaves the product index alone;
  • two locales of one store stay apart;
  • a product that leaves a store disappears from that store’s index;
  • no batch is larger than batchSize;
  • a variant the product no longer has is dropped;
  • a write the backend failed rejects, and the sync passes the error on, so the queue message is not acknowledged;
  • with fullReindex, a rebuild replaces one scope and leaves the others alone, keeps the old index when it fails, and refuses a document type the source did not declare.

A scenario for a capability your adapter does not declare is skipped, not failed: Acme has no fullReindex, so the three rebuild scenarios are skipped. failNextWrite is optional; without it, the failed-write scenario is skipped too. The framework runs the same contract on its own InMemoryIndexAdapter.

Test the calls to the vendor itself with MSW, as the testing guide sets up.

Checkout talks to every provider through PaymentAdapter. In-process, extend BasePaymentAdapter: it loads the cart or order and the commercetools payment, refuses a zero amount, and then calls two methods you write. It also gives you helpers that write payment state back to commercetools, such as addPaymentTransaction, updatePayment, getTargetByPaymentId, and updateTarget.

import type {
Cart,
Order,
Payment,
TransactionState,
} from "@commercetools/platform-sdk";
import type { ClientFactory } from "@evolve-framework/commercetools";
import {
BasePaymentAdapter,
PaymentError,
type PaymentMethod,
type TransactionRequest,
type TransactionResponse,
} from "@evolve-framework/commercetools/payments";
import type { FastifyInstance } from "fastify";
type AcmePayConfig = { apiKey: string; serverUrl: string };
type AcmePayment = { id: string; status: "open" | "paid" | "failed"; checkoutUrl: string };
const toTransactionState = (status: AcmePayment["status"]): TransactionState =>
status === "paid" ? "Success" : status === "failed" ? "Failure" : "Pending";
export class AcmePaymentAdapter extends BasePaymentAdapter {
readonly identifier = "acme";
private readonly config: AcmePayConfig;
constructor(options: { clientFactory: ClientFactory; config: AcmePayConfig }) {
super(options.clientFactory);
this.config = options.config;
}
protected async getPaymentMethodsFor(
_source: Cart | Order,
_device: "WEB" | "IOS" | "ANDROID",
locale: string,
): Promise<PaymentMethod[]> {
return [{ id: "card", name: { [locale]: "Card" } }];
}
protected async createTransactionFor(
req: TransactionRequest,
_target: Cart | Order,
payment: Payment,
): Promise<TransactionResponse> {
if (req.paymentMethod !== "card") {
throw new PaymentError(`Payment method ${req.paymentMethod} not allowed`);
}
const acmePayment = await this.acme<AcmePayment>("/payments", {
method: "POST",
body: JSON.stringify({
amount: req.amount,
reference: req.paymentId,
returnUrl: req.successUrl,
webhookUrl: `${this.config.serverUrl}/webhook`,
}),
});
await this.addPaymentTransaction(payment, {
interfaceId: acmePayment.id,
state: "Pending",
transactionCode: acmePayment.status,
transactionStatus: acmePayment.status,
});
return {
status: "OK",
payload: { redirectUrl: acmePayment.checkoutUrl, method: "GET" },
};
}
override getRoutes(): (app: FastifyInstance) => void {
return (app) => {
app.post<{ Body: { id: string } }>("/webhook", async (request, reply) => {
const { id } = request.body;
if (!/^[\w-]+$/.test(id)) {
return reply.status(400).send();
}
const { body } = await this.ctApi
.payments()
.get({ queryArgs: { where: `interfaceId="${id}"` } })
.execute();
const payment = body.results[0];
if (!payment) {
return reply.status(404).send();
}
// The webhook only says that something changed, so ask Acme what.
const acmePayment = await this.acme<AcmePayment>(`/payments/${id}`);
const state = toTransactionState(acmePayment.status);
await this.updatePayment(payment, { state, interfaceCode: acmePayment.status });
const target = await this.getTargetByPaymentId(payment.id);
if (target) {
await this.updateTarget(target.targetId, target.targetType, state);
}
return reply.status(200).send();
});
};
}
private async acme<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`https://api.acme-pay.test/v1${path}`, {
...init,
headers: {
authorization: `Bearer ${this.config.apiKey}`,
"content-type": "application/json",
},
});
if (!response.ok) {
throw new PaymentError(`Acme Pay answered ${response.status}`);
}
return (await response.json()) as T;
}
}

This is the shape of the Mollie adapter:

  • Record the provider’s id as the payment’s interfaceId. A webhook names only the provider’s payment, and this is how you find it again.
  • Trust the provider, not the webhook body. Mollie’s webhook carries only an id, so its handler fetches the status from the Mollie API. Where a provider signs its webhooks instead, as Adyen and Stripe do, verify the signature.
  • The webhook is the authoritative update. The shopper may never come back through the redirect.
  • Give methods lowercase ids. Checkout passes the chosen method to createTransaction in lowercase.
  • Throw when the provider refuses. Throw a PaymentError, as the base class does. Checkout logs it and fails the payment.

Two methods are optional. inFlightWindowMs(payment) says how long a started transaction counts as in flight before it counts as abandoned. Add validateApplePaySession when the provider supports Apple Pay.

Registering the adapter, running it in a service of its own, and the storefront side are in Add a payment provider. Your identifier is the provider’s name there.

Set up the in-memory commercetools from the testing guide. Keep the MSW server in a module of its own, and import it in the setup file instead of creating it there. A test can then add the provider’s API to it:

import { ctMock } from "@evolve-framework/commercetools/testing";
import { setupServer } from "msw/node";
export const server = setupServer(...ctMock.getHandlers());

@evolve-framework/commercetools/testing has the scenarios every payment adapter has to pass, as describePaymentAdapterContract. It hosts your adapter in PaymentProviderModule, as a payment service does, and calls its routes the way a remote checkout does, so requests and responses are checked against the checkout’s schemas. You tell it how the provider answers while a payment is pending and once it is paid, and what the provider’s webhook looks like:

import {
clientTestFactory,
describePaymentAdapterContract,
} from "@evolve-framework/commercetools/testing";
import { HttpResponse, http } from "msw";
import { server } from "../testing/msw.ts";
import { AcmePaymentAdapter } from "./acme-payment-adapter.ts";
const acme = "https://api.acme-pay.test/v1";
const acmePayment = { id: "acme_1", checkoutUrl: "https://acme-pay.test/acme_1" };
const acmeAnswers = (status: "open" | "paid") => [
http.post(`${acme}/payments`, () =>
HttpResponse.json({ ...acmePayment, status: "open" }),
),
http.get(`${acme}/payments/:id`, () =>
HttpResponse.json({ ...acmePayment, status }),
),
];
describePaymentAdapterContract("AcmePaymentAdapter", () => ({
adapter: new AcmePaymentAdapter({
clientFactory: clientTestFactory,
config: { apiKey: "test", serverUrl: "http://localhost" },
}),
server,
paymentMethod: "card",
vendor: {
pending: () => acmeAnswers("open"),
paid: () => acmeAnswers("paid"),
},
webhook: (interfaceId) => ({ url: "/webhook", payload: { id: interfaceId } }),
}));

The contract checks that the adapter:

  • offers its methods in the shape a checkout reads;
  • starts a transaction, and records the provider’s id so the payment can be found back by it;
  • settles the payment once the provider’s webhook reports it paid;
  • refuses a request the checkout schema does not accept.

It creates the order and the payment, and the payment states, itself. For a provider that signs its webhooks, return the signed body and headers from webhook. Add your own tests for a declined payment, a method you do not offer, and a webhook for a payment you do not know. The Mollie adapter runs the same contract, with a webhook that posts a form body.

A CMS has no adapter interface to implement. Its contract is the content part of the GraphQL schema: the page, contentPage, catalogPage, pages, siteLayout, and contentSnippet queries, the storeContentPreview mutation, and the block types. A CMS package is a module that serves that contract from one CMS. The storefront queries the same fields whichever package is behind them, so it only needs a rich text renderer for your CMS.

Start from the package closest to your CMS:

Copy When your CMS
@evolve-framework/storyblok Has one delivery API for published and draft content, and a visual editor that sends unsaved changes.
@evolve-framework/contentful Has separate delivery and preview APIs.
@evolve-framework/payload Runs in your own service, on your own database.

Generate your resolver types from the contract, as each package’s codegen.ts does, and do not change it in your package. A type that differs from the other CMS packages breaks the storefront when a project switches.

import { resolveGraphQLSchema } from "@evolve-framework/schemas";
const schema = resolveGraphQLSchema(["content.ts"]);

The package then provides:

  • Clients for published and draft content. Every query takes version (PUBLISHED or DRAFT) and a previewId. Contentful keeps a delivery and a preview client; Storyblok asks one client for the draft version.
  • Content blocks. Each shipped package has its own defineContentBlock (Storyblok): one CMS type with its GraphQL typename, its mapper, the groups and slots that say where it may go, and the content model. A MapperRegistry maps a container’s children by their CMS type, so a project can replace one block. Copy both for your CMS. The built-in block types are in @evolve-framework/schemas/graphql/content/*.
  • A webhook. A route in getHttpConfig() that clears your caches and publishes a content-modified-event with contentType (page or layout) and the changed paths, through a messagePublisher option. The event’s schema is @evolve-framework/schemas/messages/events/content-modified.schema.json. See Publish content changes.
  • storeContentPreview. If the editor sends unsaved changes, store them and return an id that the queries accept as previewId, as Storyblok does. If the CMS serves drafts itself, throw a GraphQL error with the code NOT_SUPPORTED, as Payload does.
  • Rich text. RichText.content is the CMS’s own document as a JSON string. renderer has a value for each shipped CMS only: CONTENTFUL, STORYBLOK, and PAYLOAD. In the storefront, register your renderer as the RichText block. @evolve-storefront/feature-content-blocks/rich-text/storyblok is one to copy.
  • Catalog pages, optionally. Handle product-category-created and product-category-updated in getInternalEventConfig() to create a catalog page for a new category. See React to events.

Test the resolvers against a mocked CMS API. The testing entry points of the Storyblok and Contentful packages each pair a mocked API with a test executor.

The framework defines more ports. Each has shipped implementations to copy from:

Contract Import from Implemented by Test with
DocumentSource: documents for the search index, from a commerce engine @evolve-framework/search/indexing CommercetoolsCatalogSource, MedusaProductDocumentSource InMemoryIndexAdapter from @evolve-framework/search/testing
MessagePublisher and MessageHandler: a queue or event bus @evolve-framework/core/messaging SqsPublisher, EventBridgePublisher, ServiceBusPublisher, EventGridPublisher, PubSubPublisher, RedisPublisher A test that publish rejects when the provider refuses, as the AWS adapter’s publish contract test does. See React to events.
StorageClient: file storage, used by the sitemap writer @evolve-framework/core/storage AwsS3StorageClient, AzureBlobServiceStorageClient, GoogleCloudStorageClient, LocalStorageClient A fake of the vendor SDK
ConfigSource: a store configuration is read from @evolve-framework/config awsParameterStoreSource, azureAppConfigSource, gcpSecretManagerSource, gcpParameterManagerSource Build it with createIndexedSource; test a config class with memorySource from @evolve-framework/config/testing
SimulateHandlers and CleanupHandlers: test data actions for a commerce engine @evolve-framework/core/test-support commercetoolsTestSupport The request and response schemas in @evolve-framework/core/test-support/contract. See Testing.