Author, configure, test, and publish a schema extension.
This page covers the schema extension authoring model: schema and resolver mechanics, the sandbox, configuration, local development, and publishing. For a step-by-step first extension, see Getting started. It assumes knowledge of the federation concepts from GraphQL federation, such as subgraphs, entities, keys, and the directives. The Commerce Integration Layer extension examples repository provides ready-to-edit templates for each pattern below.
Schema mechanics
typeDefs is a Federation v2 SDL string. Begin it by importing the federation spec and the directives you use:extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@requires", "@external"])
Directives
| Directive | Use it to | Notes |
|---|---|---|
@link | Import the federation spec and directives | Always the first line of typeDefs |
@key(fields: "id") | Mark a type as an entity you're attaching to | Re-declare the key field NON-@external; add your new fields alongside |
@external | Reference a non-key field the Commerce Integration Layer owns | For @requires only; never put it on the key field |
@requires(fields: "…") | Pull Commerce Integration Layer data into your resolver | The planner resolves it onto the resolver's parent |
@override(from: "integration-layer") | Take over an existing field | Only when the field's result types are its own; re-declare referenced types. The field must exist in the core subgraph |
Add new fields rather than redefine existing ones. If your field only needs a Commerce Integration Layer field as input, reference it with
@external and @requires instead of resolving it yourself.A brand-new field or type
Nothing here is shared with the Commerce Integration Layer, so it always composes.
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3")
type Query {
serverTime: ServerTime!
}
type ServerTime { iso: String! epochMillis: Float! timezone: String! }
export const resolvers = {
Query: {
serverTime: (_parent, _args, ctx) => {
const ms = ctx.now();
return { iso: new Date(ms).toISOString(), epochMillis: ms, timezone: "UTC" };
},
},
};
A field on an entity
The Commerce Integration Layer exposes its top-level resources as federation entities keyed by
id. Attach a field to one by re-declaring the type with its key and your new field. The key field is declared normally (not @external).extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"])
type Product @key(fields: "id") {
id: ID!
loyaltyPoints(price: Float!): Int!
}
export const resolvers = {
Product: {
// parent is the entity stub the Commerce Integration Layer resolved: { id }
loyaltyPoints: (_product, { price }) => Math.floor(price),
},
};
The same shape works for the other entities of
type <Entity> @key(fields: "id") { id: ID! <newField> }. See the entity catalog for the full list of extensible types.A field computed from Commerce Integration Layer data
When your field depends on data the Commerce Integration Layer owns, mark those (non-key) fields
@external and name them in @requires. The planner fetches them and passes them to your resolver, so you read the data without owning it. The read stays inside the trust boundary and is batched server-side.extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@requires", "@external"])
type Money { amount: String! @external }
type Product @key(fields: "id") {
id: ID! # the key: NON-@external
price: Money @external
discountedPrice(percentOff: Int!): String @requires(fields: "price { amount }")
}
export const resolvers = {
Product: {
// parent carries { id } plus the @requires fields: { price: { amount } }
discountedPrice: (product, { percentOff }) => {
const amount = product.price?.amount;
if (amount == null) return null;
const pct = Math.min(100, Math.max(0, percentOff));
return ((Number(amount) * (100 - pct)) / 100).toFixed(2);
},
},
};
A field backed by an external service
Use the global
fetch, or any fetch-based SDK. Outbound calls are restricted to a host allowlist configured for the Project. Read credentials from ctx.config rather than hard-coding them, and make the field nullable so an outage degrades to null instead of taking down the whole product.When a field returns an entity such as a
Product, return just its id and let the router fill in the rest from the Commerce Integration Layer. Your resolver decides which entities to return and the Commerce Integration Layer stays the source of truth for their contents.Product.id is an opaque Relay global id, it is never the raw commercetools id. When you need the commercetools id (for example, to key an external index), pull it in with _ctId: ID! @external and @requires(fields: "_ctId"). Don't parse or construct the opaque id yourself.Resolver mechanics
A resolver is a function at
resolvers[Type][field] with the standard GraphQL signature (parent, args, ctx):parentis the representation the Commerce Integration Layer resolved, which is the{ id }on an entity field, plus anything you pulled in with@requires.argsis the field's arguments, typed as you declared them in the SDL.ctxcarries the host's capabilities.ctx.now()returns the current epoch milliseconds, andctx.configis the per-project configuration map.
To fail a field on purpose, throw a
GraphQLError (imported from graphql, the one package the host provides). For anything that can fail transiently, prefer a nullable field and return null on failure so the rest of the response survives.Extension context (ctx)
ctx provides the following capabilities:| Member | Type | What it is |
|---|---|---|
ctx.now() | () => number | The current time as epoch milliseconds. |
ctx.config | Record<string, string> | The Project's extension configuration. Secret entries arrive decrypted, as plain strings. |
In a Managed API Extension handler,
ctx is { config } with the same semantics: one bundle, one shared configuration.The following capabilities are excluded from
ctx:| You might look for | Why it isn't on ctx | Use instead |
|---|---|---|
| A commercetools client, or Project credentials | The sandbox has no ambient authority. Extension code does not handle tokens. | Pull the data into your resolver with @requires on core-resolved fields (including hidden helper fields such as _ctId). |
| The shopper's session: identity, locale, currency, country | Not exposed to extension code. Resolvers only access parent, args, and @requires data. | Design fields so presentment-dependent values (such as prices) come from core-resolved @requires fields, which are already session-scoped. |
Environment variables (process.env) | No Node process exists in the sandbox. | ctx.config. |
| An HTTP client | fetch is a sandbox global, not a ctx member. | The global fetch, restricted to allowlisted hosts. |
To access session-dependent data (e.g., price, availability), use
@requires to pull in core-resolved fields rather than recomputing them. The core subgraph resolves these fields within the session context.Runtime constraints
Resolvers run in a restricted sandbox, not a full Node process. esbuild bundles
extension.ts and its imports into one self-contained module.- Available: standard web-platform globals such as
fetch(allowlisted),setTimeout/clearTimeout,Date,Math,URL/URLSearchParams,TextEncoder/TextDecoder,btoa/atob,structuredClone,Intl,AbortController, plus the ECMAScript intrinsics. Most fetch-based SDKs work unmodified. - Off limits:
process/process.env,Buffer,fs,child_process, raw sockets, Node built-ins (node:*),eval, andnew Function. Configuration comes fromctx.config, not the environment. - Network: only through
fetch, only to allowlisted hosts.
validate catches most of these statically before you push.The runtime also bounds what a bundle can contain and what a resolver can consume, such as schema size, resolver time, result size, and outbound calls. For the values, see Limits.
Configuration
Most extensions need per-project settings, such as an API key or a hostname. Do not hard-code them as the bundle is committed source and identical across Projects. Store settings as extension configuration and read them from
ctx.config:const apiKey = ctx.config.MY_API_KEY;
if (!apiKey) return null; // degrade gracefully when unset
Each entry is
{ key, value, secret? }. A secret entry is encrypted at rest, never returned by a read, and never written into your bundle; the host decrypts it only when resolving a request. Either way it appears in ctx.config as a plain string.Set configuration from the Merchant Center Commerce Integration Layer application, or from the CLI:
commercetools integration-layer config set MY_SERVICE_HOST api.example.com
commercetools integration-layer config set MY_API_KEY secret-value --secret
commercetools integration-layer config list # secret values are masked
Configuration changes take effect on their own, you do not republish the bundle to pick up new values. For the full
config command reference, see Environment configuration and secrets in Configure and deploy.Allowlist outbound hosts
If your resolver calls an external service through
fetch, the host must be on the Project's HTTP allowlist. Manage it with the CLI; for the full set of commands, see Outbound HTTP domain allowlist in Configure and deploy.Develop locally
commercetools integration-layer extension serve runs your extension as a live GraphQL server with GraphiQL and hot reload, calling your resolvers with the same ctx they get in production.commercetools integration-layer extension serve # standalone subgraph at :4000/graphql
commercetools integration-layer extension serve --compose # + the full merged schema at /composed
commercetools integration-layer extension serve --gateway # /graphql proxies through a federated gateway to the deployed edge
--compose and --gateway reach the real Commerce Integration Layer. If you are not using the default edge, set INTEGRATION_LAYER_URL. Standalone mode does not require a login.Locally,
ctx.config comes from EXTENSION_CONFIG_* in a project .env file or the shell; editing .env hot-reloads configuration without restarting. When logged in and the Commerce Integration Layer is reachable, fetch is gated by the project HTTP allowlist. Without login or when offline, fetch is unrestricted locally.To run operations against the deployed router under a real session bearer (and optionally compose in your local extension before pushing), use
commercetools integration-layer explore instead.Validate and publish
Both
validate and push run the bundle through local and remote checks:- Static analysis: rejects code that won't run in the sandbox (
process, Node imports,eval). - Shape and coherence: a non-empty
typeDefs, aresolversobject, and a resolver for every field the SDL declares. - Composition: composes your extension with your Project's live subgraph, surfacing collisions. A field you add that the core subgraph already serves is rejected unless you take it over with
@override(from: "integration-layer"), and every@overridemust name"integration-layer"and a field the core subgraph resolves. - Breaking-change detection: against your currently published schema.
commercetools integration-layer extension push
A failure stops the push with a specific message. Override the remote checks (3 and 4) for a deliberate, coordinated breaking change with
--force; the local checks always fail hard. --force only skips the checks at push time: the composition check runs again when the bundle goes live, and a bundle that fails it is not served, so your Project keeps running the previous one. Each Project holds one bundle, so a push replaces the previous one.Every push records your version-control revision with the bundle, so you can tell which of your commits a Project is running. It appears in
extension status, the Merchant Center panel, every extension log line, and over GraphQL:{ _extensionBundle { version sourceRevision } }