Architecture
The framework organizes shared backend code into layered packages. Services
compose modules from these packages into a DomainService and add only
project-specific logic.
Package layering
Section titled “Package layering”graph TD;
impl["Implementation packages<br/>commercetools, contentful, storyblok, sfcc, ..."]
schemas["@evolve-framework/schemas<br/>GraphQL type definitions for all domains"]
core["@evolve-framework/core<br/>Module system, DomainService, cache, messaging"]
impl --> schemas
impl --> core
schemas --> core
- Core (
@evolve-framework/core): the module system (Module,AbstractModule,CompositeModule), theDomainServiceruntime, cache, messaging, storage, HTTP client, and observability. Vendor-agnostic. - Schemas (
@evolve-framework/schemas): GraphQL type definitions, organized by domain (cart, catalog, customer, order, and so on). Vendor-agnostic. - Implementation packages: the concrete implementation for a specific
SAAS integration — resolvers, data mappers, dataloaders, and the modules
that wire everything together.
@evolve-framework/commercetoolsis the largest;@evolve-framework/contentful,@evolve-framework/storyblok, the payment provider packages, and@evolve-framework/sfccfollow the same pattern on the same core and schemas.
The module system
Section titled “The module system”A module encapsulates one domain: its GraphQL type definitions and
resolvers, and optionally HTTP routes (webhooks, REST endpoints). The
contract is the Module interface from @evolve-framework/core:
export interface Module { getGraphQLConfig(): GraphQLConfig | undefined; getGraphQLSchema(): string | undefined; getHttpConfig(): HttpConfig | undefined; init?(): Promise<void> | void; close?(): Promise<void> | void;}
export type GraphQLConfig = { resolvers: GraphQLResolverMap<unknown>; typeDefs: DocumentNode;};
export type HttpConfig = { routes: (app: FastifyInstance) => void; plugins?: Array<[FastifyPluginCallback, FastifyPluginOptions]>;};Two lifecycle rules matter:
- Constructors stay cheap and side-effect-free. Codegen tooling
instantiates modules and calls
getGraphQLSchema()without runtime config or network access. init()is the place for side-effecty setup — building SDK clients, registering module-scoped singletons.DomainService.init()calls it once before consuminggetGraphQLConfig()andgetHttpConfig().close()releases those resources on shutdown.
AbstractModule
Section titled “AbstractModule”AbstractModule is the standard base class: assign typedefs (a
DocumentNode) and resolvers, and the interface methods are implemented
for you. This is the real QuoteModule from
@evolve-framework/commercetools:
import { AbstractModule } from "@evolve-framework/core";import getTypeDefs from "@evolve-framework/schemas/graphql/quote";
export class QuoteModule extends AbstractModule { resolvers: Resolvers = { Mutation: { quoteRequestCreate: quoteRequestCreateMutation, }, BusinessUnit: { quoteRequest: quoteRequestResolver, quoteRequests: quoteRequestsResolver, quote: quoteResolver, quotes: quotesResolver, }, Quote: quoteFieldResolvers, };
typedefs = getTypeDefs();}Type definitions come from @evolve-framework/schemas/graphql/<domain>,
which exports a getTypeDefs() function per domain (some accept options,
for example getTypeDefs({ includeB2B: true }) for the customer domain).
Module configuration
Section titled “Module configuration”Modules that need configuration take a typed options object in their
constructor. There is no generic config bag — each module declares
exactly what it needs:
import { CatalogModule, CustomerModule,} from "@evolve-framework/commercetools";
new CatalogModule({ clientFactory });
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, },});Modules with routes implement getHttpConfig(). The ContentfulModule,
for example, registers its change-notification webhook this way, so the
route is mounted automatically when the module is composed into a service.
CompositeModule
Section titled “CompositeModule”Why composition is the framework’s central design choice — and how it lets you pick a service topology from one backend service to one per domain — is covered in Composition & deployment topologies.
CompositeModule composes multiple modules into one. It implements
Module itself, so a composite is what you hand to DomainService:
import { QuoteModule } from "@evolve-framework/commercetools";import { CompositeModule } from "@evolve-framework/core";
export const createModule = (): CompositeModule => { return new CompositeModule([new QuoteModule()]);};Composition semantics:
- Type definitions are merged with conflict detection
(
throwOnConflict): two modules defining the same type differently is a startup error, not a silent override. Merging is federation-aware — it reconciles Federation directives (@external,@extends,@requires,@provides) that would otherwise be invalid when modules designed as separate subgraphs are composed into a single subgraph. - Resolvers are merged per GraphQL type name; when two modules resolve the same field, the later module in the array wins.
- HTTP config is concatenated: every module’s routes and Fastify plugins are registered.
init()andclose()are chained across all child modules, in order.
Entity hydration at module boundaries. When modules that were designed
as separate Federation subgraphs run inside one composite, one module may
hand another an entity stub — for example the CMS module returning
{ __typename: "ProductVariant", sku } for the catalog module to enrich.
A real gateway would call the owning subgraph’s __resolveReference;
inside a composite there is no gateway, so CompositeModule wraps each
entity type’s field resolvers to pipe the parent through the owning
module’s __resolveReference first. The hydrated parent is cached
per-request, so __resolveReference runs at most once per stub. Owning
modules must keep __resolveReference idempotent: if the parent is
already hydrated, return it as-is.
Available modules
Section titled “Available modules”@evolve-framework/commercetools ships the following modules:
| Module | Domain |
|---|---|
AuthModule |
Sessions and authentication — login, logout, guest sessions, password reset, token REST endpoints |
CustomerModule |
Customer profile — customer queries, address CRUD, account updates |
CartModule |
Shopping cart — line items, discount codes, cart updates |
CatalogModule |
Product catalog — products, categories, product search (Algolia or commercetools) |
CheckoutModule |
Checkout completion |
OrderModule |
Order history and details |
PaymentModule |
Payment creation and payment methods |
BusinessUnitModule |
B2B business units, associates, and roles |
QuoteModule |
B2B quotes and quote requests |
ShoppingListModule |
Shopping list CRUD |
The CMS packages ship ContentfulModule and StoryblokModule, the payment
provider packages ship one module each (for example MolliePaymentModule),
and @evolve-framework/sfcc mirrors the commercetools module set for
Salesforce Commerce Cloud.
DomainService
Section titled “DomainService”DomainService from @evolve-framework/core composes a module with its
HTTP and GraphQL surface and runs it. The lifecycle has three steps:
new DomainService(options)— cheap, no side effectsawait service.init()— runsmodule.init(), then builds the GraphQL and Fastify instances. Idempotent.await service.start()— binds the port and installs signal handlers for graceful shutdown
createDomainService(options) is the common one-liner that does steps 1
and 2 together. This is the real quotes service:
import { createDomainService, type DomainService,} from "@evolve-framework/core";import { useClientContext } from "@evolve-framework/core/service/graphql";import { initClientFactory } from "#src/commercetools.ts";import { config, loadConfig } from "#src/config.ts";import { createContext } from "./context.ts";import { createModule } from "./module.ts";
export const createApp = async (): Promise<DomainService> => { await loadConfig(); await initClientFactory(); return createDomainService({ name: config.COMPONENT_NAME, module: createModule(), graphql: { context: createContext, plugins: [useClientContext()], }, http: { address: { host: config.HTTP_HOST, port: config.HTTP_PORT, }, }, });};
export const startServer = async (): Promise<void> => { const service = await createApp(); await service.start();};The options:
name: service name, used for identification and loggingmodule: the (composite) module providing schema, resolvers, and routesgraphql.context: builds the per-request resolver context; required whenever the module provides a GraphQL configgraphql.plugins: GraphQL Yoga plugins —useClientContext()(from@evolve-framework/core/service/graphql) reads the access token from the request and loads the client contexthttp.address: host and port to bindhttp.plugins: server-wide Fastify pluginshttp.bodyLimit: max request body size, for services accepting large payloadshttp.factory: escape hatch that replaces the default Fastify setup entirely — used only by highly custom services such as the GraphQL gateway
After init(), tests can use service.fastify.inject(...) to make
requests without binding a port.
Anatomy of a service
Section titled “Anatomy of a service”With the framework owning resolvers and schemas, a migrated service is a thin composition layer. The quotes service, for example:
| File | Purpose |
|---|---|
config.ts |
Load and validate environment configuration |
module.ts |
createModule(): compose framework modules into a CompositeModule |
context.ts |
Build the per-request GraphQL context (auth, dataloaders, store context) |
server.ts |
createApp(): wire config, module, and context into createDomainService |
index.ts |
Entry point: await startServer() |
The CMS service is even smaller — the framework’s initContextValue (from
@evolve-framework/contentful/graphql/context) serves directly as the
context factory, so the service is little more than configuration plus a
CompositeModule around ContentfulModule.
Context
Section titled “Context”The graphql.context function receives the server context for each
request and returns the value resolvers see. For commercetools services
this typically combines the store context (read from the
X-StoreContext-* headers set by the gateway) with a client context that
determines how the service authenticates against commercetools:
import { ClientContext, RemoteClientContextLoader, StoreContext,} from "@evolve-framework/commercetools";import { readStoreContextFromRequest } from "@evolve-framework/core/service/graphql";Most services use RemoteClientContextLoader, which delegates token
handling to the account service; the account service itself manages tokens
directly. initContextValue from @evolve-framework/commercetools
registers the context so the framework’s dataloaders can attach to it.
Shared infrastructure
Section titled “Shared infrastructure”Beyond the module system, @evolve-framework/core provides:
- Cache (
@evolve-framework/core/cache): a Redis-backed cache handler; callcache.configure(config.REDIS_URL)at startup andcache.close()on shutdown - Resolver utilities (
@evolve-framework/core/service/graphql):assertAuthenticated(),validateZodSchema(), error helpers - Observability (
@evolve-framework/core/observability): structured logging and OpenTelemetry wiring - Messaging and storage (
@evolve-framework/core/messaging,@evolve-framework/core/storage): publisher and storage abstractions, with concrete bindings in thecloud-adapter-*packages
@evolve-framework/commercetools adds its own infrastructure: batching
dataloaders via createDataLoaders(), client context loaders, mappers,
Algolia and commercetools-native search, test utilities
(@evolve-framework/commercetools/testing), and fishery-based test data
factories (@evolve-framework/commercetools/factories).
Request flow
Section titled “Request flow”sequenceDiagram
participant Client
participant Gateway as GraphQL Gateway
participant Service as Domain Service
participant Module as Module
participant Backend as SAAS Backend
Client->>Gateway: GraphQL query
Gateway->>Service: Federated subquery
Service->>Service: Build context (graphql.context)
Service->>Module: Execute resolver
Module->>Module: DataLoader batching
Module->>Backend: API call
Backend-->>Module: Response
Module->>Module: Mapper transforms data
Module-->>Service: Resolved data
Service-->>Gateway: Subquery result
Gateway-->>Client: Merged response
What stays in your service
Section titled “What stays in your service”Not everything moves into the framework. Your service still owns:
- Configuration: environment loading and validation in
config.ts - Composition: which modules to compose, with which options
- Context wiring: your auth pattern in
context.ts - REST endpoints: project-specific routes beyond what modules register
- Event handlers and jobs: service-specific event processing and scheduled work
- Project-specific modules: any resolvers, schema extensions, or mappers you add — see Customization and extensibility

