Skip to main content

Architecture

Work in progress

The Evolve Framework is an ongoing, backwards-compatible refactoring. Not all services have been migrated yet, and APIs may still evolve. Everything described here is functional but should be considered pre-release.

The framework organizes shared backend code into three layered packages. Services compose GraphQL modules from these packages and add only project-specific logic.

Three-layer design

  • Core (@evolve-framework/core): the AbstractGraphQLModule base class, DomainService, ProcessManager, cache handler, and shared infrastructure. Vendor-agnostic.
  • Schemas (@evolve-framework/schemas): all GraphQL type definitions, organized by domain (cart, catalog, customer, order, and so on). Vendor-agnostic.
  • Implementation package: the concrete implementation for a specific SAAS integration. Contains resolver implementations, data mappers, dataloaders, and composable GraphQL modules that wire everything together.

The core and schemas layers are vendor-agnostic. Each SAAS integration gets its own implementation package that builds on the shared foundation. The first implementation package is @evolve-framework/commercetools. Additional packages for other integrations (Contentful, Storyblok, Algolia, Shopify, and others) will follow the same pattern, sharing the same core and schemas.

GraphQL module system

Each domain is encapsulated in a module that extends AbstractGraphQLModule. A module bundles the GraphQL type definitions and resolver implementations for its domain.

import { AbstractGraphQLModule } from "@evolve-framework/core";
import typeDefs from "@evolve-framework/schemas/graphql/cart";

export class CartGraphQLModule extends AbstractGraphQLModule {
resolvers: Resolvers = {
Query: {
cart: cartResolver,
},
Mutation: {
cartLineItemsAdd: cartLineItemsAddMutation,
cartLineItemsUpdate: cartLineItemsUpdateMutation,
cartLineItemsRemove: cartLineItemsRemoveMutation,
cartUpdate: cartUpdateMutation,
cartDiscountCodeAdd: cartDiscountCodeAdd,
cartDiscountCodeRemove: cartDiscountCodeRemove,
},
Cart: {
availablePaymentMethods: availablePaymentMethodsResolver,
availableShippingMethods: availableShippingMethodsResolver,
discountCodes: discountCodesResolver,
lineItems: lineItemsResolver,
selectedShippingMethod: selectedShippingMethodResolver,
shippingCosts: shippingCostsResolver,
},
};

typedefs = typeDefs;
}

Module composition

GraphQLCompositeModule composes multiple modules into a single set of type definitions and resolvers. It handles merging schemas (with conflict detection) and merging resolvers by GraphQL type name.

import {
CheckoutGraphQLModule,
CartGraphQLModule,
GraphQLCompositeModule,
} from "@evolve-framework/commercetools";

const module = new GraphQLCompositeModule([
new CheckoutGraphQLModule(),
new CartGraphQLModule(),
]);

const typeDefs = module.getTypedefs(); // Merged schema
const resolvers = module.getResolvers(); // Merged resolvers

Module configuration

Some modules require configuration (for example, which search engine to use or where payment services are located). Pass config through the constructor:

const module = new GraphQLCompositeModule([
new CatalogGraphQLModule({
config: {
searchEngine: "algolia",
algoliaAppId: config.ALGOLIA_APP_ID,
algoliaSearchApiKey: config.ALGOLIA_SEARCH_API_KEY,
},
}),
new CartGraphQLModule({
config: {
paymentServiceEndpoints: config.PAYMENT_SERVICE_ENDPOINTS,
},
}),
]);

GraphQLCompositeModule aggregates all module configs. You retrieve the merged config with module.getConfig() and pass it into the context factory so resolvers can access it as context.config.

Available modules (commercetools)

The @evolve-framework/commercetools package ships with the following modules. Other implementation packages will provide modules for their respective domains.

ModuleDomainKey operations
CustomerGraphQLModuleAuthentication, customer managementLogin, registration, address CRUD, password management
CartGraphQLModuleShopping cartLine item CRUD, discount codes, cart updates
CatalogGraphQLModuleProduct catalogProduct search, categories, product details
CheckoutGraphQLModuleCheckoutCheckout completion
OrderGraphQLModuleOrdersOrder queries, order line items
PaymentGraphQLModulePaymentsPayment creation, Apple Pay validation
BusinessUnitGraphQLModuleB2BBusiness unit management, associates, roles
QuoteGraphQLModuleQuotesQuote requests, quote management
ShoppingListGraphQLModuleShopping listsList CRUD, item management
CommonGraphQLModuleShared typesCommon scalars and base types

Centralized infrastructure

The @evolve-framework/core package provides infrastructure shared by all services regardless of backend:

  • DomainService: the HTTP and GraphQL server that runs your service
  • ProcessManager: process lifecycle with graceful shutdown on SIGTERM/SIGINT
  • Cache: Redis-backed caching with cache.configure(), cache.wrapFn(), and automatic fallback when Redis is unavailable
  • HTTP utilities: traced fetch client, retry with exponential backoff, URL joining
  • Error helpers: assertAuthenticated(), validateZodSchema(), and other utilities for writing resolvers

Each implementation package bundles additional infrastructure specific to its backend. The @evolve-framework/commercetools package provides:

  • DataLoaders: 20+ batching loaders (products, categories, customers, orders, and more) via createDataLoaders()
  • Client context: CommercetoolsClientLoader for direct auth, RemoteClientContextLoader for federated auth
  • Token management: configureTokenManager() for OAuth token lifecycle
  • Client factory: configureClientFactory() for commercetools API clients
  • Mappers: data transformation functions for addresses, customers, products, carts, and other domains
  • Validation: Zod schemas for address validation, phone number parsing, and more
  • Search: Algolia and commercetools native search abstractions
  • Test utilities: shared test server, mock data, and context fixtures

Other implementation packages will provide equivalent infrastructure for their respective integrations.

Test factories

The @evolve-framework/commercetools package also includes 20+ test factories (built on fishery) for commercetools resources: carts, customers, products, categories, orders, payments, business units, and more. Use these in your service tests to generate realistic mock data without boilerplate.

The service pattern

Every migrated service follows the same minimal structure:

FilePurposeWhat varies per service
index.tsEntry point: start the process via ProcessManagerIdentical across services
init.tsConfigure framework infrastructureWhich functions to call (account needs configureTokenManager, catalog does not)
server.tsCompose modules into a DomainServiceWhich modules, plugins, and REST routes to include
context.tsBuild the per-request GraphQL contextDirect auth vs. federated auth

Entry point and lifecycle

Every service uses ProcessManager from @evolve-framework/core as its entry point. It handles startup, graceful shutdown on SIGTERM/SIGINT, and error handling:

// index.ts
import { ProcessManager } from "@evolve-framework/core";
import { cache } from "@evolve-framework/core/cache";

let app: DomainService;

const pm = new ProcessManager({
start: async () => {
await initEnvironment();
app = createApp();
await app.start();
},
stop: async () => {
await app?.stop();
await cache.close();
},
});

await pm.start();

DomainService provides start() and stop() methods to control the HTTP and GraphQL server.

Example: order service

The order service is the simplest case (single module, no cache, federated auth):

// init.ts
import {
configureClientFactory,
} from "@evolve-framework/commercetools";

export const initEnvironment = async () => {
await loadConfig();
await configureClientFactory(config);
};
// server.ts
import {
OrderGraphQLModule,
GraphQLCompositeModule,
} from "@evolve-framework/commercetools";
import { DomainService } from "@evolve-framework/core";

const module = new GraphQLCompositeModule([
new OrderGraphQLModule(),
]);

export const createApp = () =>
new DomainService({
name: config.COMPONENT_NAME,
graphql: {
typeDefs: module.getTypedefs(),
resolvers: module.getResolvers(),
context: newContext,
plugins: [useClientContext()],
},
http: {
address: { host: config.HTTP_HOST, port: config.HTTP_PORT },
},
});

Example: checkout service

The checkout service composes two modules and exposes a service-specific REST endpoint:

// server.ts
import {
CheckoutGraphQLModule,
CartGraphQLModule,
GraphQLCompositeModule,
} from "@evolve-framework/commercetools";
import { DomainService } from "@evolve-framework/core";

const module = new GraphQLCompositeModule([
new CheckoutGraphQLModule(),
new CartGraphQLModule(),
]);

export const createApp = () =>
new DomainService({
name: config.COMPONENT_NAME,
graphql: {
typeDefs: module.getTypedefs(),
resolvers: module.getResolvers(),
context: newContext,
},
http: {
address: { host: config.HTTP_HOST, port: config.HTTP_PORT },
routes: (app) => {
app.post("/api/extension", {
handler: extensionHandler,
});
},
},
});

Plugins

DomainService accepts GraphQL plugins in the graphql.plugins array. The framework and supporting packages provide several:

  • useClientContext(): reads the access token from the request header and loads the client context. Used by every service.
  • useFederatedToken(): manages federated token lifecycle. Only needed in the account service (which issues tokens directly).
  • useRateLimiter(): rate-limits GraphQL operations. Useful for endpoints exposed to unauthenticated traffic.
new DomainService({
name: config.COMPONENT_NAME,
graphql: {
// ...
plugins: [
useRateLimiter({ /* ... */ }),
useFederatedToken(),
useClientContext(),
],
},
});

Most services only need useClientContext(). Add the others when your service requires them.

Context and store context

The context.ts file builds the per-request GraphQL context that resolvers receive. Two key pieces are the store context and the dataloaders.

Store context

StoreContext (from @evolve-framework/core) reads multi-store headers from each request: X-StoreContext-Currency, X-StoreContext-Locale, and X-StoreContext-StoreKey. The GraphQL gateway sets these headers based on the incoming request.

import { readStoreContextFromRequest } from "@evolve-framework/core";

const storeContext = readStoreContextFromRequest(request);

Dataloaders

There are two patterns for initializing dataloaders, depending on the service's authentication model:

Using initContextValue (most services): the framework initializes all dataloaders using the global-scoped client. Use this when the service delegates authentication to the account service.

import {
initContextValue,
RemoteClientContextLoader,
} from "@evolve-framework/commercetools";

export const newContext = async ({ request }) => {
const storeContext = readStoreContextFromRequest(request);
const loader = new RemoteClientContextLoader(
config.ACCOUNT_SERVICE_ENDPOINT,
clientFactory,
);
const clientContext = new ClientContext(storeContext, loader);

const context = { storeContext, clientContext };
initContextValue(context);
return context;
};

Manual dataloader creation (account service): the service creates dataloaders explicitly because it manages authentication directly.

import {
createDataLoaders,
CommercetoolsClientLoader,
} from "@evolve-framework/commercetools";

export const newContext = async ({ request }) => {
const storeContext = readStoreContextFromRequest(request);
const loader = new CommercetoolsClientLoader(clientFactory);
const clientContext = new ClientContext(storeContext, loader);

return {
storeContext,
clientContext,
loaders: createDataLoaders(/* ... */),
};
};

Cache

The @evolve-framework/core/cache export provides a Redis-backed cache singleton. Configure it during initialization and close it on shutdown:

import { cache } from "@evolve-framework/core/cache";

// In init.ts
await cache.configure(config.REDIS_URL);

// In your code
const result = await cache.wrapFn({
key: "my-cache-key",
fn: () => fetchExpensiveData(),
ttl: 60, // seconds
});

// In stop handler
await cache.close();

The cache degrades gracefully: if Redis is unavailable, it falls back to a no-op store so the service continues to function without caching.

Request flow

What moved where

CategoryPreviously in each serviceNew location
Resolverssrc/resolvers/Implementation package
Schema definitionsinline GraphQL strings@evolve-framework/schemas
DataLoaderssrc/dataloaders/Implementation package
Mapperssrc/mappers/Implementation package
Core logicsrc/core/@evolve-framework/core
ValidationZod schemas, error typesImplementation package
Test utilitiessrc/testing/Implementation package
Typessrc/types.tsImplementation package

What stays in your service

Not everything moves into the framework. Your service still owns:

  • Service configuration: init.ts with environment-specific setup
  • Context wiring: context.ts with your auth pattern
  • REST endpoints: API extension handlers, webhooks, custom routes
  • Event handlers: service-specific event processing
  • Jobs: scheduled tasks and background workers
  • Project-specific resolvers: any custom or overridden resolvers