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.
Install what the module uses
Section titled “Install what the module uses”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 zodWrite the API client
Section titled “Write the API client”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; }}Write the module
Section titled “Write the module”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:
typedefsandresolvers: your part of the GraphQL schema. The service merges it with the schemas of the other modules, sotype Queryhere adds two fields to the sameQuerythe engine modules extend.dataLoaders: the loaders your resolvers read fromcontext.loaders. Declare them withdefineDataLoaders; 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 whatinit()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 Fastifypluginsscoped to these routes.
A module can also handle internal events, such as a category being created, by
overriding getInternalEventConfig(). See
React to events.
Compose it with the engine’s modules
Section titled “Compose it with the engine’s modules”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.
Run it with the application
Section titled “Run it with the application”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.
Use the GraphQL helpers
Section titled “Use the GraphQL helpers”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.
Test the module
Section titled “Test the module”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.
Example: an external pricing engine
Section titled “Example: an external pricing engine”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.
Replace the price on the product
Section titled “Replace the price on the product”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.
Charge the same price in the cart
Section titled “Charge the same price in the cart”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.

