# Write schema extensions

**Author, configure, test, and publish a schema extension.**

This page covers the [schema extension](/commerce-integration-layer/schema-extensions.md) authoring model: schema and resolver mechanics, the sandbox, configuration, local development, and publishing. For a step-by-step first extension, see [Getting started](/commerce-integration-layer/development/get-started.md). It assumes knowledge of the federation concepts from [GraphQL federation](/commerce-integration-layer/graphql-federation.md), such as subgraphs, entities, keys, and the directives. The [Commerce Integration Layer extension examples](https://github.com/commercetools/commerce-integration-layer-extensions) 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:

```graphql
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.

```graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3")

type Query {
  serverTime: ServerTime!
}
type ServerTime { iso: String!  epochMillis: Float!  timezone: String! }
```

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

```graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"])

type Product @key(fields: "id") {
  id: ID!
  loyaltyPoints(price: Float!): Int!
}
```

```ts
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](/commerce-integration-layer/schema-extensions.md#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.

```graphql
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 }")
}
```

```ts
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`](/commerce-integration-layer/development/writing-schema-extensions.md#configuration) 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:

| Member | Type | What it is |
| --- | --- | --- |
| `ctx.now()` | `() => number` | The current time as epoch milliseconds. |
| `ctx.config` | `Record<string, string>` | The Project's [extension configuration](/commerce-integration-layer/development/writing-schema-extensions.md#configuration). Secret entries arrive decrypted, as plain strings. |

In a [Managed API Extension](/commerce-integration-layer/development/writing-managed-api-extensions.md) 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`, 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](/commerce-integration-layer/limits.md#schema-extensions).

## 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`:

```ts
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:

```bash
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](/commerce-integration-layer/development/configure-and-deploy.md#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](/commerce-integration-layer/development/configure-and-deploy.md#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.

```bash
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](/commerce-integration-layer/development/writing-schema-extensions.md#allowlist-outbound-hosts). 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`](/commerce-integration-layer/development/get-started.md#explore-your-projects-api) 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.

```bash
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:

```graphql
{ _extensionBundle { version sourceRevision } }
```

## Related pages

- [Area overview page with navigation](/commerce-integration-layer.md)
- [Previous page: Get started](/commerce-integration-layer/development/get-started.md)
- [Next page: Write Managed API Extensions](/commerce-integration-layer/development/writing-managed-api-extensions.md)
- [Search documentation and API specs](/search.md)