Change a module's behavior
The Evolve modules are packages you upgrade. If you copy one into your project to change a rule, you stop receiving its fixes. Each module offers extension points instead. Use the lightest one that covers your rule.
Pick an extension point
Section titled “Pick an extension point”| Extension point | Use it when | What you own on upgrade |
|---|---|---|
| Module option | The module already has a setting for the rule. | Nothing. A renamed option fails the type check. |
| API extension | The rule must hold for every write to a cart, order, customer, or payment, whoever makes it. commercetools only. | Your extension and its triggers. |
| Payment adapter wrapper | A provider must offer fewer methods. commercetools only. | The wrapper. |
| Field resolver | One GraphQL field must answer differently. | The answer of that field, and what it reads from the engine. |
| Data loader | Every resolver that reads one resource must get it from somewhere else. | Every read of that resource. |
To run code after something has happened, such as sending a placed order to an ERP, you do not change a module. See React to events. To add a capability that no module has, see Add your own module.
Set a module option
Section titled “Set a module option”Options are the module’s public API. Each module’s reference page lists them,
for example OrderModule.
| Module | Option | What it decides |
|---|---|---|
commercetools CheckoutModule |
orderCreationStrategy, quotes, approvals, revision |
Whether the order is created before or after payment, and which checkout flows exist |
commercetools OrderModule |
trackingUrls |
The track and trace link of a delivery, per carrier |
commercetools BusinessUnitModule |
roleCapabilities |
What an associate role may do, for a role whose commercetools permissions map to no capability |
commercetools CatalogModule |
variantDimensions, search |
Which attributes variants differ on, and where search is answered from |
Medusa CatalogModule |
specifications, search |
The brand attribute, which specifications lead, and where search is answered from |
| CMS modules | blocks, mappers |
How content blocks map to GraphQL. See Change a built-in block. |
The B2B reference site links each delivery to its carrier:
new OrderModule({ clientFactory, payments, trackingUrls: { PostNL: "https://jouw.postnl.nl/track-and-trace/{trackingId}", UPS: "https://www.ups.com/track?tracknum={trackingId}", },}),Some options depend on each other. CheckoutModule refuses to start with
orderCreationStrategy: "after-payment" together with quotes (on by
default) or approvals: there is no order to approve or to convert a quote
into until the money has moved. This is on purpose: the module refuses rather
than quietly skipping approvals. When the strategy comes from config, a wrong
value in one environment stops that service from starting, with an error that
names the conflict, instead of shipping a checkout without approvals.
Values that differ per environment
Section titled “Values that differ per environment”Put the value in your service’s config class, and pass it to the option:
class Config extends CommercetoolsConfig { readonly ORDER_CREATION_STRATEGY: "before-payment" | "after-payment" = "before-payment";
override config(): EnviConfig { return { ...super.config(), ORDER_CREATION_STRATEGY: envfield.string(), }; }}new CheckoutModule({ clientFactory, payments, orderCreationStrategy: config.ORDER_CREATION_STRATEGY, quotes: false }),The initializer is the default. To reject a value when the service starts,
pass a validator to envfield.string() and throw from it. See
@evolve-framework/config for the field types
and the sources a value is read from.
Add a commercetools API extension
Section titled “Add a commercetools API extension”commercetools calls an API extension before it saves a cart, order, customer, or payment. The extension returns update actions, which commercetools applies in the same write, or it refuses the write. The rule holds for every caller: the storefront, the Merchant Center, and any integration that uses the commercetools API. Medusa and SFCC have no equivalent.
Compose the extensions
Section titled “Compose the extensions”Serve them with
ApiExtensionModule,
composed in the service that owns your carts and orders. From the B2C
reference site:
import { ApiExtensionModule } from "@evolve-framework/commercetools";import { applyDefaultShippingMethod, copyCustomerDefaultsToCart, setCustomType, setOrderCustomerEmail, setOrderNumber,} from "@evolve-framework/commercetools/api-extension";
const attachOrderType = setCustomType({ key: config.CT_CUSTOM_TYPES.order });
export const createExtensionsModule = () => new ApiExtensionModule({ clientFactory, secret: config.API_EXTENSION_SECRET, path: "/api/extension", extensions: { cart: { Create: [copyCustomerDefaultsToCart, attachOrderType], Update: [applyDefaultShippingMethod], }, order: { Create: [setOrderCustomerEmail, attachOrderType, setOrderNumber()], }, }, });The extensions for one resource and action run in the order you list them, and their actions are concatenated. The ready-made ones (reference):
| Extension | Register on | What it does |
|---|---|---|
applyDefaultShippingMethod |
Cart Update |
Sets the default shipping method on a cart that has an address but no shipping method. |
applyReverseCharge(options) |
Cart Create and Update |
Adds a line that takes the VAT off a company’s cart billed outside the seller’s country. |
copyCustomerDefaultsToCart |
Cart Create |
Copies the customer’s default addresses and email onto a new cart. |
setCustomType({ key, fields }) |
Any Create |
Attaches your custom type to the new resource. |
setOrderCustomerEmail |
Order Create |
Copies the customer’s email onto the order. |
setOrderNumber(options) |
Order Create |
Counts up an order number in a custom object. Takes prefix, suffix, padding, container, and key. |
The cart and order mappers find the applyReverseCharge lines by slug. Put
the slugs you pass to it on context.config, as customLineItemTaxShiftingSlug
and customLineItemTaxExemptionSlug, when you build the request context. The
B2B reference site does this in backend/purchase/src/context.ts.
Write your own
Section titled “Write your own”An extension receives the resource and a context, and returns update actions.
To refuse the write, throw an
ApiExtensionValidationError:
import type { Order, OrderUpdateAction } from "@commercetools/platform-sdk";import { type ApiExtension, ApiExtensionValidationError } from "@evolve-framework/commercetools/api-extension";
export const requireMinimumOrderValue = (minimum: Record<string, number>): ApiExtension<Order, OrderUpdateAction> => (order) => { const { centAmount, currencyCode } = order.totalPrice; const required = minimum[currencyCode]; if (required !== undefined && centAmount < required) { throw new ApiExtensionValidationError(`The minimum order value is ${required / 100} ${currencyCode}.`); } return []; };Register it first: order: { Create: [requireMinimumOrderValue({ EUR: 5000 }), ...] }.
A refusal discards the actions of the extensions before it, but not their side
effects. A setOrderNumber that ran first has already counted up, so the
refused order leaves a gap in your order numbers.
The context gives the extension what it needs:
client: a commercetools request builder, to read other resources.actions: what the extensions before this one returned.signal: aborts before commercetools stops waiting, after 1800 ms unless you passtimeoutto the module. Pass it to your own requests.logger: a logger bound to the resource and the action.
Any other error is an outage, not a refusal: the route answers 500, and
commercetools retries, then fails the write with an error of its own.
Register the triggers
Section titled “Register the triggers”commercetools only calls an extension for a resource and action you register
with it. An extension without a trigger never runs, and nothing reports it.
The reference sites register them in Terraform, in
configuration/purchase/api-extension.tf:
resource "commercetools_api_extension" "main" { # ...key, and the destination with the route and the secret
trigger { resource_type_id = "order" actions = ["Create"] }}getTriggers() on the module lists the triggers your extensions need, so a
test can compare the two. To test an extension, call dispatch() on the
module, or call the extension with createTestExtensionContext(). See
Service tests.
Change which payment methods a provider offers
Section titled “Change which payment methods a provider offers”The payment adapters take credentials and URLs, not rules. To offer fewer
methods, wrap the adapter in a
PaymentAdapter
that filters its list:
import type { PaymentAdapter, PaymentMethod, PaymentMethodArgs } from "@evolve-framework/commercetools/payments";
export const hideMethods = ( adapter: PaymentAdapter, hidden: (method: PaymentMethod, args: PaymentMethodArgs) => boolean,): PaymentAdapter => ({ identifier: adapter.identifier, init: () => adapter.init(), getRoutes: () => adapter.getRoutes(), createTransaction: (request) => adapter.createTransaction(request), inFlightWindowMs: adapter.inFlightWindowMs?.bind(adapter), validateApplePaySession: adapter.validateApplePaySession?.bind(adapter), getPaymentMethods: async (args) => (await adapter.getPaymentMethods(args)).filter((method) => !hidden(method, args)),});Register the wrapped adapter where you build the registry, which is
backend/purchase/src/payments/providers/index.ts in the reference sites:
registry.register( hideMethods(createMollieAdapter(`${base()}/mollie`), (method, args) => method.id === "banktransfer" && !["NL", "BE"].includes(args.country), ),);- A method id is the provider’s own, such as Mollie’s
banktransfer. args.countryis the billing country of the cart or order. A cart without a billing address is listed asNL.- Checkout looks the chosen method up in the same list when it creates the payment, so a hidden method cannot be paid with either.
See Add a payment provider for the registry itself.
Replace a field resolver
Section titled “Replace a field resolver”When two modules resolve the same field, the one listed later in the
CompositeModule wins. So a module composed after the engine module can
answer a field instead of it. This one counts a variant as in stock only
above a safety stock:
import type { ProductVariant } from "@commercetools/platform-sdk";import { AbstractModule } from "@evolve-framework/core";import { applyFieldResolvers } from "@evolve-framework/core/service/graphql";import { parse } from "graphql";
// Units kept back for the shops, so the web store stops selling before the shelf is empty.const SAFETY_STOCK = 5;
type VariantParent = { __instance: { variant: ProductVariant } };
export class SafetyStockModule extends AbstractModule { protected override typedefs = parse(/* GraphQL */ ` extend type ProductVariant { availability: ProductAvailabilityType } `);
protected override resolvers = { ProductVariant: applyFieldResolvers({ availability: ({ __instance }: VariantParent) => { const { availability } = __instance.variant; const channel = Object.values(availability?.channels ?? {})[0]; const quantity = channel?.availableQuantity ?? availability?.availableQuantity ?? 0; return quantity > SAFETY_STOCK ? "IN_STOCK" : "OUT_OF_STOCK"; }, }), };}Compose it after CatalogModule:
new CompositeModule<ContextValue>([new CatalogModule({ clientFactory }), new SafetyStockModule()]).
- Declare the field. A module without type definitions contributes no resolvers. Use the type the framework declares; a different type stops the service from starting.
- Compose it where the type is resolved. That is the service with
CatalogModule, which isdiscoverin the reference sites. - Wrap it in
applyFieldResolvers. A product built from a search index answers some fields itself, and the wrapper lets it. Without the wrapper, your resolver receives a search document as__instance. Search results then keep their ownavailability, so apply the rule where you build the search documents as well.
__instance is the engine’s resource as the engine module mapped it. It is
not part of the GraphQL contract and can change in an upgrade, and your field
no longer receives the framework’s fixes. Check both when you upgrade.
To add a field instead of changing one, see
Add your own module. The B2B reference
site adds ProductVariant.priceTiers that way, in
backend/discover/src/price-tiers/.
Replace a data loader
Section titled “Replace a data loader”Resolvers read resources through named loaders on context.loaders, such as
productProjectionById or customerById. The commercetools names are in
DataLoaders.
A loader you put on the context yourself wins over the module’s loader with the same name. A second module that declares the same name fails every request instead, so replace a loader in your context factory.
Check first whether data can carry the rule. The commercetools catalog reads
which specifications a product page leads with from custom objects in the
highlighted-attributes container, keyed by product type id. Edit those, and
no code changes. If the list lives elsewhere, such as in a PIM, replace the
loader:
import type { CustomObject } from "@commercetools/platform-sdk";import DataLoader from "dataloader";
// Product type id to the specification keys a product page leads with, as exported from the PIM.const highlighted: Record<string, string[]> = { "<product-type-id>": ["weight", "material"],};
const highlightedAttributesLoader = () => new DataLoader(async (productTypeIds: readonly string[]) => productTypeIds.map((id) => (highlighted[id] ? ({ value: highlighted[id] } as CustomObject) : null)), );
// In createContext, next to the rest of the context:const context: ContextValue = { ...serverContext, // ...storeContext, clientContext and the rest loaders: { highlightedAttributesByProductTypeId: highlightedAttributesLoader() },};The context factory runs per request, so each request gets a fresh loader.
This is the heaviest extension point. Every resolver that reads the loader
gets your values, as the engine’s resource type. The resolver here reads only
value, and the cast hides the rest from the type check, so an upgrade that
reads more fails at runtime. The framework describes this override as a step
for services that still assemble their loaders by hand, so prefer any
extension point above it.

