Skip to content

Add your own module

A module is one slice of a service: its part of the GraphQL schema, the resolvers behind it, the data loaders those resolvers use, and any HTTP routes. The commerce engine, CMS, and payment packages are all modules. When you need something Evolve does not provide, you write a module of your own and compose it next to theirs.

Check the GraphQL reference first: if the type already exists, you want to change a module’s behavior or add a field instead.

This guide builds a store locator for the service that serves your catalog: a storeLocations query backed by an external API, a loader that batches lookups by id, an HTTP route, and a test.

The example uses graphql-tag for the schema, dataloader for batching, and zod for validating input. Add the ones your service does not have yet:

pnpm add graphql-tag dataloader zod

The module talks to the store locator through a small client. It is your own code, not part of Evolve:

export type StoreLocation = {
id: string;
name: string;
city: string;
};
export class StoreApi {
private readonly url: string;
private readonly apiKey: string;
constructor(url: string, apiKey: string) {
this.url = url;
this.apiKey = apiKey;
}
search(storeKey: string, postalCode?: string): Promise<StoreLocation[]> {
const params = new URLSearchParams({ store: storeKey });
if (postalCode) {
params.set("near", postalCode);
}
return this.get(`/locations?${params}`);
}
getByIds(storeKey: string, ids: readonly string[]): Promise<StoreLocation[]> {
const params = new URLSearchParams({ store: storeKey, ids: ids.join(",") });
return this.get(`/locations?${params}`);
}
private async get<T>(path: string): Promise<T> {
const response = await fetch(`${this.url}${path}`, {
headers: { authorization: `Bearer ${this.apiKey}` },
});
if (!response.ok) {
throw new Error(`Store locator responded with ${response.status}`);
}
return (await response.json()) as T;
}
}

Extend AbstractModule and override what your module provides:

import { AbstractModule, type HttpConfig } from "@evolve-framework/core";
import {
readStoreContextFromRequest,
validateZodSchema,
} from "@evolve-framework/core/service/graphql";
import { defineDataLoaders } from "@evolve-framework/core/utils";
import DataLoader from "dataloader";
import { gql } from "graphql-tag";
import { z } from "zod";
import { StoreApi, type StoreLocation } from "./store-api.ts";
export type StoreLocatorModuleOptions = {
url: string;
apiKey: string;
};
type StoreLocatorContext = {
request: Request;
};
type ResolverContext = StoreLocatorContext & {
loaders: {
storeLocationById: DataLoader<string, StoreLocation | null>;
};
};
const searchArgs = z.object({
postalCode: z.string().trim().min(4).max(10),
});
export class StoreLocatorModule extends AbstractModule<StoreLocatorContext> {
private readonly options: StoreLocatorModuleOptions;
private api!: StoreApi;
protected override typedefs = gql`
type StoreLocation {
id: ID!
name: String!
city: String!
}
type Query {
storeLocation(id: ID!): StoreLocation
storeLocations(postalCode: String!): [StoreLocation!]!
}
`;
protected override resolvers = {
Query: {
storeLocation: (
_parent: unknown,
args: { id: string },
context: ResolverContext,
) => context.loaders.storeLocationById.load(args.id),
storeLocations: (
_parent: unknown,
args: unknown,
context: ResolverContext,
) => {
const { postalCode } = validateZodSchema(searchArgs, args);
const { storeKey } = readStoreContextFromRequest(context.request);
return this.api.search(storeKey, postalCode);
},
},
};
override readonly dataLoaders = defineDataLoaders<StoreLocatorContext>()({
storeLocationById: ({ request }) => {
const { storeKey } = readStoreContextFromRequest(request);
return new DataLoader(async (ids: readonly string[]) => {
const locations = await this.api.getByIds(storeKey, ids);
// DataLoader expects one result per id, in the order it asked.
return ids.map((id) => locations.find((l) => l.id === id) ?? null);
});
},
});
constructor(options: StoreLocatorModuleOptions) {
super();
this.options = options;
}
override init(): void {
this.api = new StoreApi(this.options.url, this.options.apiKey);
}
override getHttpConfig(): HttpConfig {
return {
routes: (app) => {
app.get<{ Params: { storeKey: string } }>(
"/store-locations/:storeKey",
(request) => this.api.search(request.params.storeKey),
);
},
};
}
}

What each part does:

  • typedefs and resolvers: your part of the GraphQL schema. The service merges it with the schemas of the other modules, so type Query here adds two fields to the same Query the engine modules extend.
  • dataLoaders: the loaders your resolvers read from context.loaders. Declare them with defineDataLoaders; the type argument is what the loaders need from the request context. The application builds them per request, and each loader only on first use, so a request that never asks for a store location costs nothing.
  • init(): setup that needs configuration or the network, such as building an API client. The application calls it once, before the service serves anything. Keep the constructor cheap and free of side effects: codegen constructs your modules to read their schema, without configuration or network access.
  • close(): not needed here. Implement it to release what init() opened, such as a connection. The application calls it when it stops, after the HTTP server has closed.
  • getHttpConfig(): HTTP routes next to the GraphQL API, registered on the service’s Fastify server. Use it for webhooks and feeds. It can also return Fastify plugins scoped to these routes.

A module can also handle internal events, such as a category being created, by overriding getInternalEventConfig(). See React to events.

Your service already composes the engine’s modules in a CompositeModule. Add yours to the list:

import { CompositeModule } from "@evolve-framework/core";
import type { ContextValue } from "@evolve-framework/medusa";
import { CartModule, CatalogModule } from "@evolve-framework/medusa/modules";
import { StoreLocatorModule } from "./store-locator/module.ts";
export const createModule = (): CompositeModule<ContextValue> =>
new CompositeModule<ContextValue>([
new CatalogModule(),
new CartModule(),
new StoreLocatorModule({
url: config.STORE_LOCATOR_URL,
apiKey: config.STORE_LOCATOR_API_KEY,
}),
]);

config is your service’s configuration; add the two settings to it (see Configuration).

Pass your service’s context type to CompositeModule. TypeScript then checks that the context your service builds has what every module’s loaders read, so a missing request fails at compile time instead of on the first request.

When modules meet:

  • Type definitions are merged. Definitions that conflict, such as one field declared with two different types, stop the service from starting.
  • Resolvers are merged per type. If two modules resolve the same field, the one listed later wins.
  • Loader names share one namespace. If two modules declare a loader with the same name, every GraphQL request fails, so give yours a specific name.

Nothing changes in how your service starts. It hands the composition to createApplication:

import { createApplication } from "@evolve-framework/core";
import { useClientContext } from "@evolve-framework/core/service/graphql";
import { useFederatedToken } from "@labdigital/federated-token-yoga";
const app = await createApplication({
name: config.COMPONENT_NAME,
module: createModule(),
graphql: {
context: createContext,
plugins: [useFederatedToken(), useClientContext()],
},
http: {
address: { host: config.HTTP_HOST, port: config.HTTP_PORT },
},
});
await app.start();

createApplication constructs an Application and initializes it: it runs init() on every module, then builds the GraphQL endpoint at /graphql and the Fastify server with your routes. start() binds the port and stops the application on SIGTERM or SIGINT, which calls each module’s close().

The context function builds the request context. What it returns is what your loaders receive, with the modules’ loaders added on context.loaders.

Resolvers import their helpers from @evolve-framework/core/service/graphql. Each throws a GraphQL error with an extensions.code the storefront can act on:

Helper What it does Code
validateZodSchema(schema, data) Returns the parsed input, or throws if it does not match the schema. BAD_USER_INPUT
createZodGraphqlError(...issues) Returns an error for you to throw, for input you reject after parsing. BAD_USER_INPUT
assertAuthenticated(context) Throws if the customer is not signed in. UNAUTHENTICATED
assertNotAuthenticated(context) Throws if the customer is signed in, for sign-in and sign-up. ALREADY_AUTHENTICATED
assertActiveSession(context) Throws if the request carries no session. SESSION_REQUIRED
throwInternalError(err) Throws err as a server error, keeping its message. INTERNAL_SERVER_ERROR

The three guards read context.clientContext, which the engine package puts on the context. They need its isAuthenticated() and hasToken(); the Medusa client context has no hasToken(), so with Medusa check context.clientContext.isAuthenticated() in your resolver instead.

Any error that is not a GraphQL error, such as the Error the API client throws, is masked: the client gets a generic message with the code INTERNAL_SERVER_ERROR, and the original error is logged.

readStoreContextFromRequest(request) returns the storeKey, locale, and currency the storefront sends with each request, and throws if they are missing.

Initialize an application with only your module and send it a query. Building it runs init() and your loaders the same way the service does, without binding a port:

import { createApplication } from "@evolve-framework/core";
import { describe, expect, it, vi } from "vitest";
import { StoreLocatorModule } from "./module.ts";
import { StoreApi } from "./store-api.ts";
describe("StoreLocatorModule", () => {
it("finds the stores near a postal code", async () => {
vi.spyOn(StoreApi.prototype, "search").mockResolvedValue([
{ id: "ams-1", name: "Amsterdam Centraal", city: "Amsterdam" },
]);
const app = await createApplication({
name: "test",
module: new StoreLocatorModule({
url: "https://stores.test",
apiKey: "test",
}),
graphql: { context: ({ request }) => ({ request }) },
http: { address: { host: "localhost", port: 0 } },
});
const yoga = app.getYogaInstance();
if (!yoga) {
throw new Error("The module has no GraphQL schema");
}
const response = await yoga.fetch("http://localhost/graphql", {
method: "POST",
headers: {
"content-type": "application/json",
"X-StoreContext-Currency": "EUR",
"X-StoreContext-Locale": "nl-NL",
"X-StoreContext-StoreKey": "example",
},
body: JSON.stringify({
query: `{ storeLocations(postalCode: "1012AB") { name } }`,
}),
});
expect(await response.json()).toEqual({
data: { storeLocations: [{ name: "Amsterdam Centraal" }] },
});
});
});

The example uses Vitest. For the HTTP route, call app.fastify.inject() with the method and URL instead.

To test resolvers without an application, createGraphQLTestInstance from @evolve-framework/core/testing builds a GraphQL server from typeDefs and resolvers. It does not add your module’s loaders, so put them on the context yourself.

When an ERP or pricing engine computes your prices, integrate it by replacing the price resolver: your module asks the engine for the price, instead of the catalog reading it from commercetools.

Compose a module after CatalogModule that resolves ProductVariant.price from a loader, batched by SKU like the store locator above, so a product list makes one call to the engine instead of one per variant. See Replace a field resolver for the rules: declare the field with the framework’s type (TaxedPrice), wrap the resolvers in applyFieldResolvers, and read the SKU from __instance.variant.

Replace regularPrice and Product.startingPrice too if your storefront shows them, and put the engine’s prices in your search documents, or product lists and search results keep showing the commercetools price.

The engine is now called on every product request, so it has to answer fast. Cache its answers where its prices allow it.

The resolver changes the price the shopper sees, not the price the cart charges: when a line is added, commercetools selects a price from its own prices again. To make the cart charge the engine’s price, register an API extension. That is an endpoint in your service that commercetools calls before it saves a cart, so your code can change the cart on the way in.

On cart Create and Update, ask the engine for each line’s price and return a setLineItemPrice action with an externalPrice. commercetools then keeps that price instead of selecting its own. commercetools waits less than two seconds for the extension, so give the engine call the extension’s signal.