Skip to content

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

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.

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"
}
}

Configuration is a typed enviconf class. Extend CommercetoolsConfig when the service talks to commercetools (it brings the CTP_* fields), or BaseConfig otherwise:

src/config.ts
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_" });
};

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:

src/modules/loyalty.ts
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:

src/module.ts
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.

ServercreateDomainService 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:

src/server.ts
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):

src/index.ts
import { startServer } from "./server.ts";
export { startServer } from "./server.ts";
await startServer();

Local development entry initializes observability first:

run.ts
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:

src/context.ts
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.

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:

Terminal window
task -d backend/services/graphql-gateway supergraph

In production the router polls the supergraph from the Hive CDN; the new subgraph joins it through the hive_schema_publish Terraform resource below.

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.

  • 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

main.tf: every service follows a three-phase lifecycle:

  1. Schema check: hive_schema_check validates the GraphQL schema against the registry before deployment
  2. Deploy: the compute module (Container Apps on Azure) deploys the container image
  3. Schema publish: hive_schema_publish registers 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
}

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

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
- hive

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