Switch commerce engine
Each commerce engine is one package of modules, and all three serve the same
GraphQL contract from @evolve-framework/schemas. Switching engines means
replacing that package in every backend service that composes it: the config
class, the client factory, the request context, and the modules. The gateway
and storefront keep working against the same schema, with the exceptions
listed under What the storefront notices.
| Engine | Package | Config class |
|---|---|---|
| commercetools | @evolve-framework/commercetools |
CommercetoolsConfig |
| Medusa | @evolve-framework/medusa |
MedusaConfig |
| Salesforce Commerce Cloud | @evolve-framework/sfcc |
CommerceCloudConfig |
There is no evolve add engine command. The steps below are the whole switch.
Moving your data (products, categories, prices, customers, orders) from one
engine to the other is a separate project, and this guide does not cover it.
See what changes
Section titled “See what changes”Start by checking what your project uses against what the new engine serves.
- Per operation. Each page in the GraphQL reference has a “Served by” list that says whether each engine resolves the operation, answers it with a stub, or has no resolver for it.
- Per module. Each engine module’s reference page links the same module in
the other engines, for example
CartModule.
Where the engines differ, at the operation level:
| Operation | commercetools | Medusa | SFCC |
|---|---|---|---|
createGuestSession |
Yes | No resolver | No resolver |
refreshToken |
Yes | No resolver | Yes |
customerPasswordUpdate |
Yes | No resolver | Stub |
customerPasswordReset, customerPasswordResetRequest |
Yes | Yes | Stub |
businessUnit, businessUnitSwitch, businessUnitAssociateInvite, businessUnitAssociateRemove, businessUnitAssociateRolesUpdate |
Yes | Yes | Stub |
businessUnitAddressCreate, businessUnitAddressUpdate, businessUnitAddressDelete, businessUnitDefaultAddressUpdate, businessUnitAssociateActivate |
Yes | No resolver | No resolver |
businessUnitAssociateDeactivate |
Yes | Yes | No resolver |
cartSetDeliveryRemarks |
Yes | No resolver | No resolver |
cartSetPurchaseOrderNumber |
Yes | Not in schema | Not in schema |
cartCreateNamed, cartRename, cartSetCurrent, cartDelete |
Yes, with MultipleCartModule |
Not in schema | Not in schema |
orderRevise, orderCancel |
Yes | Not in schema | Not in schema |
customerCreate, customerAddressCreate, customerAddressUpdate, customerAddressDelete, customerInformationUpdate |
Yes | Yes | Stub |
customerDelete, customerEmailUpdate |
Yes | No resolver | Stub |
customerDefaultAddressUpdate, customerPersonalDetailsUpdate |
Yes | Yes | No resolver |
customerNotificationPreferencesUpdate |
Yes | No resolver | No resolver |
orderByPublicId |
Yes | Yes | No resolver |
orderApprovalRuleCreate, orderApprovalRuleUpdate, orderApprovalRuleDelete, orderApprovalStatusUpdate |
Yes | Yes | Not in schema |
quoteRequestCreate, quoteRequestCancel |
Yes | Yes | No resolver |
applePayPaymentValidate |
Yes | Always an error | Always an error |
shoppingListCreate, shoppingListUpdate, shoppingListDelete, shoppingListLineItemsAdd, shoppingListLineItemsUpdate, shoppingListLineItemsRemove |
Yes | Yes | Stub |
Customer.invoices, BusinessUnit.invoices |
Yes, with InvoiceModule |
Not in schema | Not in schema |
- Stub: the operation is in the schema and answers “Not implemented yet”.
SFCC has 27 of them: the operations above, plus five
BusinessUnitfields (such asassociatesandbillingAddress) andBusinessUnitAssociate.roles. - No resolver: the operation is in the schema, but nothing answers it. A
mutation fails with a GraphQL error, because its result is non-null. A
nullable query, such as
orderByPublicIdon SFCC, returnsnull. - Not in schema: no module of that engine composes it, so a document that selects it fails validation.
Everything else, from product and productSearch to cart, orderPlace, and
paymentCreate, all three engines resolve. One query, addressValidate, has no
resolver on any engine.
Swap the engine, module by module
Section titled “Swap the engine, module by module”Do this in every backend service that composes engine modules. In the
reference sites those are backend/discover (catalog) and
backend/purchase (everything from the cart on).
-
Prepare the engine. Each engine needs something before the modules can serve the contract.
- commercetools: an API client (
CTP_CLIENT_ID) and, for customer sessions, a user API client (CTP_USER_CLIENT_ID); the custom types yourcustomTypeskeys name; and the payment states listed in Add a payment provider. - Medusa: a publishable key per store, Medusa’s Translation Module
enabled (
featureFlags: { translation: true }), and the Evolve Medusa plugins@evolve-framework/medusa-plugin-slug,-company,-quote,-approval,-wishlist,-customer-lookup, and-events. The business unit and shopping list modules fail on their first call without their plugin, not at startup. The package holds no admin credentials: what the Store API does not expose comes from a plugin. See the package README. - SFCC: a Shopper API client for one site (
SFCC_SITE_ID).
- commercetools: an API client (
-
Replace the package. Remove the old engine package and its add-ons (the
commercetools-*payment packages run only on commercetools), and install the new one:pnpm remove @evolve-framework/commercetoolspnpm add @evolve-framework/medusa -
Load the config and build the client factory. Each engine reads its own variables. Extend the class with your service’s own variables as described in Configuration.
import {ClientFactory,CommercetoolsConfig,getClientOptions,getTokenManagerOptions,TokenManager,} from "@evolve-framework/commercetools";export const config = new CommercetoolsConfig();export let clientFactory!: ClientFactory;export const tokenManager = new TokenManager();export const initEngine = async (): Promise<void> => {await config.load();clientFactory = ClientFactory.create(await getClientOptions(config));tokenManager.configure(await getTokenManagerOptions(config));};CTP_PROJECT_KEY,CTP_API_URL,CTP_AUTH_URL,CTP_CLIENT_ID,CTP_CLIENT_SECRET,CTP_SCOPES, and for theTokenManagerCTP_USER_CLIENT_ID,CTP_USER_CLIENT_SECRET, and optionallyCTP_USER_SCOPES.import { ClientFactory, getClientOptions, MedusaConfig } from "@evolve-framework/medusa";export const config = new MedusaConfig();export const clientFactory = new ClientFactory();export const initEngine = async (): Promise<void> => {await config.load();clientFactory.configure(getClientOptions(config));};MEDUSA_API_URL=https://medusa.example.comMEDUSA_STORES='{"my-store":{"publishableKey":"pk_..."}}'The keys of
MEDUSA_STORESare your store keys. Regions and locales are not configured: the package reads them from Medusa and caches them.MEDUSA_REQUEST_TIMEOUTis optional and defaults to10000milliseconds.import { ClientFactory, CommerceCloudConfig, getClientOptions } from "@evolve-framework/sfcc";export const config = new CommerceCloudConfig();export const clientFactory = new ClientFactory();export const initEngine = async (): Promise<void> => {await config.load();clientFactory.configure(await getClientOptions(config));};SFCC_CLIENT_ID,SFCC_CLIENT_SECRET,SFCC_SITE_ID,SFCC_SHORT_CODE, andSFCC_ORG_ID. The last one is read into the propertySFCC_ORGANIZATION_ID. -
Rewrite the request context. The headers a service receives stay the same, but each engine has its own
StoreContextandClientContext. Authentication and sessions explains how each engine models a session.The commercetools context factory is the one in Authentication and sessions: a
StoreContextaround the system client, and aClientContextwith aCommercetoolsClientLoaderthat holds theTokenManager.import { logger } from "@evolve-framework/core/observability";import { readStoreContextFromRequest } from "@evolve-framework/core/service/graphql";import { ClientContext, type ContextValue, StoreContext } from "@evolve-framework/medusa";import { createDataLoaders } from "@evolve-framework/medusa/dataloaders";import type { YogaInitialContext } from "graphql-yoga";import { clientFactory } from "./engine.ts";export const createContext = async ({ request }: YogaInitialContext): Promise<ContextValue> => {// Rejects an unknown store or an unserved locale before any resolver runs.const storeContext = await StoreContext.resolve(readStoreContextFromRequest(request), clientFactory);const clientContext = new ClientContext(storeContext, clientFactory);const ip = request.headers.get("X-Client-IP") ?? "";return {log: logger,storeContext,clientContext,federatedToken: clientContext.token,loaders: createDataLoaders(storeContext),globalScopedClient: () => storeContext.client,request: Object.assign(request, { ip, ipAddress: ip }),config: {},};};The Medusa modules declare no loaders, so the context builds them itself.
import { logger } from "@evolve-framework/core/observability";import { readStoreContextFromRequest } from "@evolve-framework/core/service/graphql";import {ClientContext,ClientContextLoader,type RequestContextValue,StoreContext,} from "@evolve-framework/sfcc";import type { YogaInitialContext } from "graphql-yoga";import { clientFactory } from "./engine.ts";export const createContext = async ({ request }: YogaInitialContext): Promise<RequestContextValue> => {const systemClient = await clientFactory.getSystemRequestBuilder();const storeContext = new StoreContext(readStoreContextFromRequest(request), systemClient);const clientContext = new ClientContext(storeContext, new ClientContextLoader("", clientFactory));const ip = request.headers.get("X-Client-IP") ?? "";return {log: logger,storeContext,clientContext,clientFactory,federatedToken: clientContext.token,globalScopedClient: () => systemClient,request: Object.assign(request, { ip, ipAddress: ip }),config: {},};};The SFCC loaders read
clientFactoryfrom the context.The plugins stay as they are:
useFederatedToken(), thenuseClientContext(). -
Swap the modules. A module that exists in more than one package has the same name in each, but takes different options. The commercetools modules take the client factory, your custom type keys, and the payment registry; the Medusa modules take almost nothing.
Module commercetools Medusa SFCC AuthModule{ config, clientFactory }none { config, clientFactory }BusinessUnitModule{ roleCapabilities? }none none (mostly stubs) CatalogModule{ clientFactory, search?, variantDimensions? }{ search?, specifications? }{ clientFactory }CartModule{ clientFactory, customTypes, payments? }none none CheckoutModule{ clientFactory, payments?, orderCreationStrategy?, quotes?, approvals?, revision? }none none CustomerModule{ config, customTypes }none none OrderModule{ clientFactory, payments?, trackingUrls? }none none OrderApprovalModulenone none Not available PaymentModule{ clientFactory, customTypes, payments?, routePrefix? }none none QuoteModule,ShoppingListModulenone none none InvoiceModule,MultipleCartModule,ApiExtensionModule,PaymentProviderModuleYes Not available Not available The commercetools modules import from
@evolve-framework/commercetools, the others from@evolve-framework/medusa/modulesand@evolve-framework/sfcc/modules. Type theCompositeModulewith the engine’s context:CommercetoolsLoaderContext,ContextValuefrom@evolve-framework/medusa, orSfccLoaderContext.Some modules cannot be left out, or the subgraph does not compose:
BusinessUnitModule, even in a B2C service: the customer, order, and shopping list types of every engine referenceBusinessUnit.- On Medusa,
ShoppingListModulenext toCustomerModule, andBusinessUnitModulenext toQuoteModuleandOrderApprovalModule.
-
Move payments. The payment packages (
commercetools-adyen,-stripe,-mollie,-paynl,-buckaroo,-pay-on-account) andPaymentRegistryexist only for commercetools. On the other engines, the engine takes the payment:- Medusa offers the providers of Medusa’s own payment module that are
linked to the cart’s region.
orderPlacecompletes only a cart whose payment Medusa has authorized, sopaymentCreateon the cart comes first. A checkout that places the order before paying needs to change. - SFCC takes the money through its own server-side payment integration.
paymentCreaterecords the chosen method on the basket or order and sends the shopper nowhere. SFCC keeps no invoices.
- Medusa offers the providers of Medusa’s own payment module that are
linked to the cart’s region.
-
Move search. Each engine answers
productSearchitself when you pass no search provider. To answer it from a search vendor instead:- commercetools: pass
search: new DocumentSearchProvider({ adapter })toCatalogModule, and index withCommercetoolsCatalogSourcefrom@evolve-framework/commercetools/catalog. - Medusa: pass
search: new ProductSearchProvider({ adapter })from@evolve-framework/medusa/search, and index withMedusaProductDocumentSource. Medusa’s own search returns no facet counts. - SFCC:
CatalogModuletakes no search provider, so search stays with SFCC.
The query adapters from
search-algoliaandsearch-meilisearchwork with both providers. See Add search. - commercetools: pass
-
Reconnect events. Only the commercetools package turns engine events into the internal events of React to events, with
createSubscriptionHandler. CMS catalog pages and search indexing that follow those events stop when you leave commercetools. On Medusa,medusa-plugin-eventsforwards an event type and an id to an HTTP route that you write in a module of your own. SFCC publishes no events. -
Port your own code. Anything that talks to the engine directly is engine-specific: resolvers that call
clientContext.getClient(), the reference sites’extensionsmodules, commercetools API extensions, custom types, and theconfiguration/commercetoolsTerraform. Theevolve simulateandevolve cleanupcommands come from the commercetools package and leave with it. The Medusa client context has nohasToken(), soassertActiveSessiondoes not work there; see Add your own module. -
Regenerate and run. Regenerate the subgraph schemas, recompose the supergraph, and run the storefront’s codegen. In the reference sites,
pnpm codegenat the root runs all three.
What the storefront notices
Section titled “What the storefront notices”The storefront talks to the gateway, and the gateway carries one session format for every engine, so most of the storefront does not change. Three things do.
Sessions start over. The federated token holds each engine’s session under
its own name: commercetools, medusa, or commercecloud. A shopper’s
commercetools session means nothing to Medusa, so after the switch every
shopper is a guest until they sign in again, and open carts stay behind in the
old engine. The reference sites’ gateway config names the engine session the
gateway takes the token subject from with FEDERATED_TOKEN_SUBJECT_KEY, which
defaults to commercetools. Set it to the new engine’s name.
Some documents no longer validate. The storefront’s codegen validates every
feature package’s documents against the supergraph. With the documents of
feature-cart 0.12 and feature-order 0.14, these fail:
| Selected field | Where | Fails on |
|---|---|---|
Cart.purchaseOrderNumber |
feature-cart’s cart fragment, which GetCart and the cart mutations select |
Medusa, SFCC |
cartSetPurchaseOrderNumber |
CartSetPurchaseOrderNumber |
Medusa, SFCC |
orderRevise, orderCancel |
OrderRevise, OrderCancel in feature-order |
Medusa, SFCC |
OrderPlaceResponse.approval |
CartOrderPlace, which places every order |
SFCC |
Order.approval |
feature-order’s order detail fragment |
SFCC |
Cart.name, Cart.owner, cartCreateNamed, and the other named-cart fields |
GetCarts and the named-cart mutations |
Any engine without MultipleCartModule |
The reference sites already filter out whole operations that the supergraph
cannot serve, in frontend/site/codegen/feature-documents.cjs. Add
CartSetPurchaseOrderNumber, OrderRevise, and OrderCancel to its
unreachableOperations.
A field inside a shared fragment cannot be filtered that way. Until the engine
composes it, declare it in a module of your own in the service that owns the
cart, and answer null:
import { AbstractModule } from "@evolve-framework/core";import { gql } from "graphql-tag";
export class ContractGapsModule extends AbstractModule { protected override typedefs = gql` extend type Cart { purchaseOrderNumber: String } `;
protected override resolvers = { Cart: { purchaseOrderNumber: () => null }, };}On SFCC, the two approval fields need the same treatment. Their types are
in @evolve-framework/schemas/graphql/order-approval.
Stubs and missing resolvers fail at run time. A document that validates can still fail when it runs: a stub answers “Not implemented yet”, and a mutation without a resolver fails with a GraphQL error. Hide the matching features, such as account editing and shopping lists on SFCC, before you go live. The table under See what changes lists them.

