Write schema extensions

Ask about this Page
Copy for AI
View as Markdown

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

DirectiveUse it toNotes
@linkImport the federation spec and directivesAlways the first line of typeDefs
@key(fields: "id")Mark a type as an entity you're attaching toRe-declare the key field NON-@external; add your new fields alongside
@externalReference a non-key field the Commerce Integration Layer ownsFor @requires only; never put it on the key field
@requires(fields: "…")Pull Commerce Integration Layer data into your resolverThe planner resolves it onto the resolver's parent
@override(from: "integration-layer")Take over an existing fieldOnly 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):
  • parent is the representation the Commerce Integration Layer resolved, which is the { id } on an entity field, plus anything you pulled in with @requires.
  • args is the field's arguments, typed as you declared them in the SDL.
  • ctx carries the host's capabilities. ctx.now() returns the current epoch milliseconds, and ctx.config is 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:
MemberTypeWhat it is
ctx.now()() => numberThe current time as epoch milliseconds.
ctx.configRecord<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 forWhy it isn't on ctxUse instead
A commercetools client, or Project credentialsThe 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, countryNot 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 clientfetch 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, and new Function. Configuration comes from ctx.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:
  1. Static analysis: rejects code that won't run in the sandbox (process, Node imports, eval).
  2. Shape and coherence: a non-empty typeDefs, a resolvers object, and a resolver for every field the SDL declares.
  3. 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 @override must name "integration-layer" and a field the core subgraph resolves.
  4. 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 } }