Creating a new domain service
Every backend service in Evolve follows the same structure: config.ts
declares typed environment configuration, module.ts composes the framework
modules the service exposes, server.ts boots them as a DomainService, and
index.ts is a thin entry point. All heavy lifting — the GraphQL server,
HTTP routes, process lifecycle — comes from the published
@evolve-framework/* packages. This guide walks through creating a service
from scratch, using the real services (quotes-commercetools is the smallest
complete example) as reference.
1. Scaffold the service
Section titled “1. Scaffold the service”Create a new directory under backend/services/:
Directorybackend/services/loyalty/
Directorysrc/
- index.ts
- config.ts
- module.ts
- server.ts
- context.ts
Directorymodules/
- loyalty.ts
- run.ts
Directoryterraform/
- …
- package.json
Framework packages are published to the private registry and pinned centrally
in pnpm-workspace.yaml under catalog:, so dependencies use the catalog:
protocol:
{ "dependencies": { "@evolve-framework/core": "catalog:", "@evolve-framework/commercetools": "catalog:", "@labdigital/enviconf": "catalog:", "graphql": "catalog:", "graphql-tag": "catalog:", "graphql-yoga": "catalog:" }, "scripts": { "dev": "node --env-file=../../../.env --watch ./run.ts", "codegen": "graphql-codegen", "build": "node ./build.ts", "start": "node ./dist/server/index.mjs" }}2. Declare the configuration
Section titled “2. Declare the configuration”Configuration is a typed enviconf
class. Extend CommercetoolsConfig when the service talks to commercetools
(it brings the CTP_* fields), or BaseConfig otherwise:
import { CommercetoolsConfig } from "@evolve-framework/commercetools";import { type EnviConfig, envfield } from "@labdigital/enviconf";
class Config extends CommercetoolsConfig { readonly COMPONENT_NAME = "loyalty";
readonly HTTP_HOST = "localhost";
readonly HTTP_PORT = 4008;
readonly REDIS_URL: string | undefined;
config(): EnviConfig { return { ...super.config(), COMPONENT_NAME: envfield.string(), HTTP_HOST: envfield.string(), HTTP_PORT: envfield.number(), REDIS_URL: envfield.string({ optional: true }), }; }}export const config = new Config();
export const loadConfig = async (): Promise<void> => { config.load({ prefix: "LOYALTY_" });};3. Define and compose modules
Section titled “3. Define and compose modules”A module bundles a GraphQL schema, its resolvers, and optional HTTP routes.
Most services only instantiate modules that ship with the framework
(QuoteModule, CatalogModule, ContentfulModule, …), but you can write
your own by extending AbstractModule:
import { AbstractModule } from "@evolve-framework/core";import { gql } from "graphql-tag";
export class LoyaltyModule extends AbstractModule { typedefs = gql` type LoyaltyAccount { points: Int! tier: String! }
extend type Customer { loyalty: LoyaltyAccount } `;
resolvers = { Customer: { loyalty: loyaltyResolver, }, };}module.ts composes everything the service exposes into a single subgraph
via CompositeModule. This is also the file where deploy-time choices live —
adding or removing a module here changes what the service serves:
import { CompositeModule } from "@evolve-framework/core";import { LoyaltyModule } from "./modules/loyalty.ts";
export const createModule = (): CompositeModule => { return new CompositeModule([new LoyaltyModule()]);};Modules can also expose REST routes and Fastify plugins through
getHttpConfig() — see the account service’s auth endpoints or the payment
provider modules for examples. Everything a module returns from
getGraphQLConfig() and getHttpConfig() is mounted automatically by the
DomainService.
4. Wire the service files
Section titled “4. Wire the service files”Server — createDomainService takes the module and boots the GraphQL
(Yoga) and HTTP (Fastify) servers around it. Process lifecycle — signal
handling and graceful shutdown — is managed internally, and module init()/
close() hooks run at the right moments:
import { createDomainService, type DomainService,} from "@evolve-framework/core";import { useClientContext } from "@evolve-framework/core/service/graphql";import { config, loadConfig } from "./config.ts";import { createContext } from "./context.ts";import { createModule } from "./module.ts";
export const createApp = async (): Promise<DomainService> => { await loadConfig(); 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();};Keeping createApp separate from startServer lets tests boot the service
without binding a port and use service.fastify.inject(...).
Entry point is a thin wrapper that both exports and calls startServer
(the export is what the monolith’s worker threads invoke):
import { startServer } from "./server.ts";
export { startServer } from "./server.ts";
await startServer();Local development entry initializes observability first:
import { initObservability } from "@evolve-framework/core/observability";
initObservability();
await import("./src/index.ts");Request context builds the per-request GraphQL context. For commercetools-backed services the standard shape wires the store context and federated client context:
import { ClientContext, RemoteClientContextLoader, StoreContext,} from "@evolve-framework/commercetools";import { logger } from "@evolve-framework/core/observability/logging";import { readStoreContextFromRequest, type ServerContext,} from "@evolve-framework/core/service/graphql";import type { YogaInitialContext } from "graphql-yoga";import { clientFactory } from "./commercetools.ts";import { config } from "./config.ts";
export const createContext = async ( serverContext: ServerContext & YogaInitialContext,) => { const { request } = serverContext;
const commercetoolsClient = clientFactory.getSystemRequestBuilder(); const storeContext = new StoreContext( readStoreContextFromRequest(request), commercetoolsClient, ); const loader = new RemoteClientContextLoader( config.ACCOUNT_SERVICE_ENDPOINT, clientFactory, ); const clientContext = new ClientContext(storeContext, loader);
return { ...serverContext, log: logger, storeContext, clientContext, globalScopedClient: () => commercetoolsClient, };};See backend/services/quotes-commercetools/src/context.ts for the complete
version including data loaders and the federated token.
5. Register for local development
Section titled “5. Register for local development”The monolith boots every service module in its own worker thread during
pnpm dev. Add the service in backend/services/monolith/src/index.ts — both
the side-effect import at the top and the moduleNames array — plus a
workspace:* dependency in the monolith’s package.json:
import "@evolve-platform/loyalty";// ...const moduleNames = [ // ... "@evolve-platform/loyalty",];Then recompose the local supergraph so the Hive Router picks up the new
subgraph. Add the service to the composition task in
backend/services/graphql-gateway/Taskfile.yaml and run:
task -d backend/services/graphql-gateway supergraphIn production the router polls the supergraph from the Hive CDN; the new
subgraph joins it through the hive_schema_publish Terraform resource below.
6. Add Terraform
Section titled “6. Add Terraform”Each service has a terraform/ directory with per-cloud subdirectories. All
three clouds follow the same lifecycle (schema check, deploy, schema publish)
but use different compute primitives.
Directory structure
Section titled “Directory structure”Directoryterraform/
Directoryaws/
- main.tf ECS service + Hive schema check/publish
- locals.tf Service name, image, env_vars
- variables.tf Inputs from Mach Composer
- data.tf SSM parameter lookups
- commercetools.tf CT client credentials (Secrets Manager)
- schema.generated.graphql Generated schema (used by Hive)
- outputs.tf
- versions.tf
Directoryazure/
- main.tf Container Apps module + Hive lifecycle
- locals.tf Service name, image, env_vars
- variables.tf Inputs from Mach Composer
- data.tf Resource group, ACR, Redis lookups
- secrets.tf Key Vault + role assignments
- roles.tf User Assigned Identity + RBAC
- commercetools.tf CT client credentials (Key Vault)
- schema.generated.graphql Generated schema (used by Hive)
- outputs.tf
- versions.tf
Directorygcp/
- main.tf Cloud Run module + Hive lifecycle
- locals.tf Service name, image, env_vars
- variables.tf Inputs from Mach Composer
- data.tf google_client_config lookup
- commercetools.tf CT client credentials (Secret Manager)
- schema.generated.graphql Generated schema (used by Hive)
- outputs.tf
- versions.tf
The Azure pattern (primary example)
Section titled “The Azure pattern (primary example)”main.tf: every service follows a three-phase lifecycle:
- Schema check:
hive_schema_checkvalidates the GraphQL schema against the registry before deployment - Deploy: the compute module (Container Apps on Azure) deploys the container image
- Schema publish:
hive_schema_publishregisters the live endpoint after the service is up
resource "hive_schema_check" "graphql_schema_check" { service = local.service_name commit = var.component_version schema = file("${path.module}/schema.generated.graphql") context_id = "${local.service_name}/${var.component_version}"}
module "service" { source = "evolve-platform/app-container/azurerm" version = "0.2.2" tags = local.tags
name = "${var.azure.resource_prefix}-${local.service_name}" cpu = var.variables.cpu memory = var.variables.memory min_replicas = var.variables.min_replicas max_replicas = var.variables.max_replicas
container_app_environment_id = data.azurerm_container_app_environment.primary.id resource_group_name = data.azurerm_resource_group.primary.name image = local.image identity_id = azurerm_user_assigned_identity.app.id env_vars = local.env_vars
secrets = [ { secret_id = module.commercetools_server_token.secret_id secret_name = module.commercetools_server_token.secret_name env_name = "CTP_CLIENT_SECRET" } ]
healthcheck = { path = "/healthcheck" }
depends_on = [ azurerm_role_assignment.app_acrpull, hive_schema_check.graphql_schema_check, ]}
resource "hive_schema_publish" "graphql_schema_publish" { service = local.service_name commit = var.component_version url = local.service_graphql_endpoint schema = file("${path.module}/schema.generated.graphql")
depends_on = [module.service]}locals.tf: defines the service name, container image, and all
environment variables. Secrets are never placed in env_vars; they go through
Key Vault references in the secrets block instead:
locals { service_name = "loyalty" image = "${local.container_registry_name}.azurecr.io/${local.service_name}:${var.component_version}"
env_vars = { NODE_ENV = "production" SERVICE_NAME = local.service_name SITE = var.site # ... service-specific env vars }}secrets.tf: creates a per-service Key Vault and assigns access roles
for the deploy identity and the app identity:
module "keyvault" { source = "evolve-platform/key-vault/azurerm" version = "0.1.1" tags = local.tags
name = "${var.azure.resource_prefix}-${local.service_name_short}" tenant_id = data.azurerm_client_config.current.tenant_id resource_group_name = data.azurerm_resource_group.primary.name location = data.azurerm_resource_group.primary.location}
# The deploy identity gets Key Vault Administrator to manage secrets during CI.# The app identity gets Key Vault Secrets User for read-only runtime access.resource "azurerm_role_assignment" "keyvault_app" { scope = module.keyvault.key_vault_id role_definition_name = "Key Vault Secrets User" principal_id = azurerm_user_assigned_identity.app.principal_id}roles.tf: creates a User Assigned Identity and grants it acrpull on
the container registry plus any data-plane access (e.g. Redis Data Owner):
resource "azurerm_user_assigned_identity" "app" { name = "${var.azure.resource_prefix}-${local.service_name}-uai" resource_group_name = data.azurerm_resource_group.primary.name location = data.azurerm_resource_group.primary.location tags = local.tags}
resource "azurerm_role_assignment" "app_acrpull" { scope = data.azurerm_container_registry.acr.id role_definition_name = "acrpull" principal_id = azurerm_user_assigned_identity.app.principal_id}AWS and GCP alternatives
Section titled “AWS and GCP alternatives”The AWS and GCP directories follow the same structure but use different compute modules:
| Cloud | Module | Compute |
|---|---|---|
| Azure | evolve-platform/app-container/azurerm |
Container Apps |
| AWS | evolve-platform/ecs-service/aws |
ECS Fargate |
| GCP | evolve-platform/cloud-run-service/google |
Cloud Run |
Secret management also differs per cloud: Azure uses Key Vault, AWS uses
Secrets Manager (referenced via CTP_CREDENTIALS_SECRET_NAME), and GCP uses
Secret Manager with Cloud Run secret mounts.
Reference an existing service’s terraform directories for the full per-cloud
boilerplate: backend/services/order-commercetools/terraform/.
Register in Mach Composer
Section titled “Register in Mach Composer”Add the component to the site configuration YAML. This registers the Terraform module with Mach Composer and wires up variables and secrets:
First, register the component. Azure configs use a shared _components.yaml
file (referenced via $ref), while AWS and GCP define components inline:
# config/azure/_components.yaml (or inline in aws/demo.yaml)- name: loyalty source: ../../backend/services/loyalty/terraform/azure/ version: "$LATEST" branch: "main" integrations: - azure - commercetools - sentry - hiveThen add the site-level config that passes variables and secrets:
# In the site's components list- name: loyalty variables: account_service_endpoint: ${component.account-commercetools.endpoint} secrets: hive_token: ${var.secrets.hive.api_token}The integrations list tells Mach Composer to inject the relevant provider
configuration (e.g. commercetools injects ct_project_key and related
variables). The ${component.*} syntax references outputs from other
components. Note that output names differ per cloud: Azure uses .endpoint,
AWS uses .service_endpoint, and GCP uses .url.
Further reading
Section titled “Further reading”- Framework architecture for the
module system and
DomainServicedesign - Customization for overriding resolvers and extending schemas

