Skip to content

New: AI agent integration via Model Context Protocol (MCP).Learn more

Customization and extensibility

The framework provides sensible defaults for every commerce domain. When your project needs different behavior, you can override individual resolvers, extend schemas with your own modules, or register custom mappers for CMS content types.

The simplest approach: compose framework modules without modification. This is the right choice when the default resolvers and schemas match your requirements.

import { QuoteModule } from "@evolve-framework/commercetools";
import { CompositeModule } from "@evolve-framework/core";
export const createModule = (): CompositeModule => {
return new CompositeModule([new QuoteModule()]);
};

When you need to change how a specific operation works, subclass the framework module and replace the resolver entry:

import { QuoteModule } from "@evolve-framework/commercetools";
import { customQuoteRequestCreate } from "./resolvers/quote-request-create.ts";
export class ProjectQuoteModule extends QuoteModule {
constructor() {
super();
this.resolvers = {
...this.resolvers,
Mutation: {
...this.resolvers.Mutation,
quoteRequestCreate: customQuoteRequestCreate,
},
};
}
}
// Compose with your override instead of the default:
const module = new CompositeModule([new ProjectQuoteModule()]);

This replaces only the quoteRequestCreate mutation. All other quote resolvers keep their default behavior, and the schema is unchanged.

Alternatively, compose a small module after the framework module: CompositeModule merges resolvers per GraphQL type name, and the later module in the array wins for each field. Note that a module only contributes when it has type definitions — an override module must carry typedefs for the fields it re-resolves, and those definitions must match the framework’s exactly or the merge throws at startup. For plain resolver swaps, subclassing is usually the cleaner option.

For functionality that does not exist in the framework, create your own module by extending AbstractModule:

import { AbstractModule } from "@evolve-framework/core";
import { gql } from "graphql-tag";
import { loyaltyResolver } from "./resolvers/loyalty.ts";
export class LoyaltyModule extends AbstractModule {
typedefs = gql`
type LoyaltyAccount {
points: Int!
tier: String!
}
extend type Customer {
loyalty: LoyaltyAccount
}
`;
resolvers = {
Customer: {
loyalty: loyaltyResolver,
},
};
}

Compose it alongside framework modules:

const module = new CompositeModule([
new CustomerModule({ config: customerConfig }),
new LoyaltyModule(),
]);

CompositeModule merges all type definitions and resolvers, so your extend type Customer works seamlessly with the framework’s customer schema. If your module needs an SDK client or other side-effecty setup, put it in init() — constructors must stay cheap so codegen tooling can instantiate the module without runtime config.

A custom module can also expose HTTP routes by implementing getHttpConfig(); the composite mounts them on the service’s Fastify instance alongside the module-provided routes such as CMS webhooks.

Modules take typed options through their constructor — each declares exactly what it needs, from a clientFactory for most commercetools modules to a full config object:

new CustomerModule({
config: {
CTP_PROJECT_KEY: config.CTP_PROJECT_KEY,
CTP_AUTH_URL: config.CTP_AUTH_URL,
CTP_USER_CLIENT_ID: config.CTP_USER_CLIENT_ID,
CTP_USER_CLIENT_SECRET: config.CTP_USER_CLIENT_SECRET,
},
});

Because the options are constructor arguments, misconfiguration is a type error at the composition site rather than a runtime failure in a resolver.

Custom CMS content types: the mapper registry

Section titled “Custom CMS content types: the mapper registry”

The CMS packages map CMS entries onto the shared content schema (blocks, column entries, links). The mapping is driven by a MapperRegistry keyed by content type id, and this registry is the primary customization point for CMS services: you rarely touch resolvers, you register mappers.

ContentfulModule builds the registry during init(): it installs the default mappers for the built-in content types, then invokes your optional mappers callback. Registering a mapper for an already-known content type overrides the default without throwing.

import type { ColumnEntryMapper } from "@evolve-framework/contentful";
import { ContentfulModule } from "@evolve-framework/contentful";
import { NoopPublisher } from "@evolve-framework/core/messaging";
import type { Entry } from "contentful";
import type { TypePageTitleSkeleton } from "#src/cftypes/index.ts";
const mapPageTitle: ColumnEntryMapper = (context, entry) => {
const data = entry as Entry<
TypePageTitleSkeleton,
"WITHOUT_UNRESOLVABLE_LINKS",
string
>;
return {
__typename: "PageTitle",
id: data.sys.id,
title: data.fields.title.toUpperCase(),
};
};
new ContentfulModule({
config: {
spaceId: config.CONTENTFUL_SPACE_ID,
environment: config.CONTENTFUL_ENVIRONMENT,
accessToken: config.CONTENTFUL_ACCESS_TOKEN,
previewToken: config.CONTENTFUL_PREVIEW_TOKEN,
managementToken: config.CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN,
},
messagePublisher: new NoopPublisher(),
mappers: (registry) => {
registry.registerColumnEntry("pageTitle", mapPageTitle);
},
});

The registry has three registration methods, one per mapper kind:

  • registerBlock(contentTypeId, mapper): top-level page blocks (defaults include hero, productRow, uspList, contentTeaserRow)
  • registerColumnEntry(contentTypeId, mapper): entries inside column blocks (defaults include richText, faq, image, pageTitle)
  • registerLink(contentTypeId, mapper): link-like entries (defaults include pageContent, pageCatalog, resourceLink)

Entries with no registered mapper are logged and skipped, so an unknown content type degrades to a missing block rather than an error.

For an entirely new content type, register a mapper and extend the content schema: compose a small AbstractModule alongside ContentfulModule that adds your GraphQL type (for example a new Block union member), then return that __typename from your mapper.

The framework ships with its own test suite covering default behavior. Your project tests should focus on what you own:

  • Custom resolvers and mappers: test any resolver or mapper you override or add
  • Module composition: verify that your module combination produces the expected schema (composition errors such as type conflicts surface at init())
  • REST endpoints, event handlers, and jobs: test project-specific business logic

DomainService itself is test-friendly: after init() (or createDomainService()), use service.fastify.inject(...) to make requests without binding a port.

The @evolve-framework/commercetools/testing export provides createTestServer() and createTestExecutor() for GraphQL integration tests, plus ctMock and ctMockClient for mocking commercetools API calls. The @evolve-framework/commercetools/factories export provides fishery-based factories for generating realistic test data (cart drafts, customer drafts, categories, and more) instead of writing mock data by hand.