Skip to content

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.

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.

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.

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.

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.

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.

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 pass timeout to 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.

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.country is the billing country of the cart or order. A cart without a billing address is listed as NL.
  • 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.

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 is discover in 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 own availability, 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/.

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.