Skip to content

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

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.

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), the DomainService runtime, 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/commercetools is the largest; @evolve-framework/contentful, @evolve-framework/storyblok, the payment provider packages, and @evolve-framework/sfcc follow the same pattern on the same core and schemas.

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 consuming getGraphQLConfig() and getHttpConfig(). close() releases those resources on shutdown.

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).

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.

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() and close() 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.

@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 from @evolve-framework/core composes a module with its HTTP and GraphQL surface and runs it. The lifecycle has three steps:

  1. new DomainService(options) — cheap, no side effects
  2. await service.init() — runs module.init(), then builds the GraphQL and Fastify instances. Idempotent.
  3. 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 logging
  • module: the (composite) module providing schema, resolvers, and routes
  • graphql.context: builds the per-request resolver context; required whenever the module provides a GraphQL config
  • graphql.plugins: GraphQL Yoga plugins — useClientContext() (from @evolve-framework/core/service/graphql) reads the access token from the request and loads the client context
  • http.address: host and port to bind
  • http.plugins: server-wide Fastify plugins
  • http.bodyLimit: max request body size, for services accepting large payloads
  • http.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.

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.

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.

Beyond the module system, @evolve-framework/core provides:

  • Cache (@evolve-framework/core/cache): a Redis-backed cache handler; call cache.configure(config.REDIS_URL) at startup and cache.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 the cloud-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).

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

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