Description
Build and publish extensions for the commercetools Commerce Integration Layer — the project-scoped commercetools BFF. Covers the two extension types (a schema extension, i.e. an extension subgraph of GraphQL typeDefs + resolvers that shapes what clients read through the Experience API; and a Managed API Extension that approves, modifies, or blocks a commercetools write), a decision tree for where custom logic belongs (incl. porting pre-request BFF logic), and the integration-layer CLI flow to init, test, validate, and push an extension bundle. Use when building, publishing, or debugging a Commerce Integration Layer extension, or asking how to add custom logic to a Commerce Integration Layer project — even if the user just says 'integration layer' or 'CIL'. Not for building commercetools Connect connectors (see commercetools-connect), the hosted Checkout (see commercetools-checkout), or storefront clients that consume the Experience API (see commercetools-storefront).
Installation
In any Claude Code session, run the following commands one at a time:
-
Add the commercetools AI marketplace:
/plugin marketplace add commercetools/commercetools-ai-plugins -
Install the commercetools AI plugin (Skills + Subagents + MCP):
/plugin install commercetools@commercetools
If you've updated the plugin or installed it in another window and need the current session to pick up the latest version:
/reload-plugins
commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.Instructions Included
commercetools Commerce Integration Layer
The Commerce Integration Layer documentation is the source of truth: Get started, Choose an extension type, Write schema extensions, Write Managed API Extensions, Configure and deploy, and, when replacing a hand-built BFF, Migrate an existing BFF. Thecommerce-integration-layer-extensionsrepository holds runnable example extensions to start from.
Where the logic must run
| Moment | Mechanism |
|---|---|
| A client reads data | Schema extension |
| Before commercetools processes a write (deriving or enriching the input) | Usually still a Managed API Extension. See the write path |
| Within a write, before it is persisted (approve, modify, block) | Managed API Extension |
| After a write is persisted | commercetools Subscription, outside the bundle |
Decision tree
Work top-down and stop at the first step that fits:
- Can built-in commercetools features model it? Check data modeling and native features first. The requirement often disappears, and nothing is cheaper to run.
- Is it about what a client reads? Use a schema extension.
- Does it change how a commercetools write behaves? Start with a Managed API
Extension, even if the old BFF did it before the request. If an API Extension cannot
solve it, reach out to commercetools support with the use case. As a final step,
override the existing mutation with
@override. Read the write path before going past the API Extension. - Is it a write to an external service provider? Add a custom mutation on the extension subgraph. This is the best way to go for that case.
addLineItem, adding
a Shopping List. A handler for one update action leaves the other paths open.Schema extension (an extension subgraph)
typeDefs + resolvers that federate into the Experience API. Use it to add a query,
type, or field on an entity (e.g. Product); to compute a value from data the Commerce Integration Layer owns
or from fields your extensions expose (via @requires); to call an external service and
return the result in the graph; or to shape and enrich what a storefront reads. For a
write to an external service provider, a custom mutation on your subgraph is the best way
to go. To change a commercetools write, start with a Managed API Extension instead.Managed API Extension
Building an extension
commerce-integration-layer-extensions repo holds standalone example extensions, one per pattern:# Install the CLI and the integration-layer topic (once), then log in
npm install -g @commercetools/cli
commercetools plugins install @commercetools/cli-topic-integration-layer
commercetools auth login --project-key <your-project-key>
# Start a real workspace (extensions merge into the one bundle a Project deploys)
commercetools integration-layer init
# Or start from the template closest to what you want, then edit src/extension.ts
pnpm test # unit tests for resolvers and handlers; offline
pnpm dev # local gateway over the merged bundle + deployed Experience API; hot-reloads
pnpm validate # sandbox, shape, composition, and breaking-change checks; no upload
pnpm push # validates, replaces the Project's bundle, waits until it is running
typeDefs + resolvers), Managed API Extensions
(apiExtensions), or both.Workflow
- Confirm the mechanism. Walk the decision tree with the user's actual requirement. Don't skip its first step (native modeling), because a data-model change often beats any extension. For anything past a Managed API Extension, read the write path.
- Read before writing. Open the Commerce Integration Layer documentation for the chosen type
(schema extensions
or Managed API Extensions),
then the closest example in the
commerce-integration-layer-extensionsrepo. - Start from a template or
integration-layer init. Editsrc/extension.ts; keep one concern per extension so it merges cleanly into the Project bundle. - Test the merged bundle, validate, then push. Follow
verify and push: unit tests, handlers fired offline,
the composed schema, and real writes for every entry point of the business action.
Then
pnpm validate, andpnpm push, which only counts as done once it reports the new revision as running.
References
Testing, validating, and pushing a Commerce Integration Layer bundle
integration-layer init workspace run with --all, which merges the typeDefs, resolvers, and apiExtensions of every package under extensions/* into one subgraph and one artifact. Work through the layers below in order. Each one catches something the previous one cannot, and the first three need no deployment.| Layer | Command (from the workspace root) | Catches | Needs |
|---|---|---|---|
| 1. Unit tests | pnpm test | Resolver and handler logic, config handling | Nothing |
| 2. Handlers, offline | invoke-api-extension --all | Which handlers fire for a write, and their verdict | Nothing |
| 3. Schema, composed | pnpm dev | Merged schema against the deployed Experience API, @requires, entity fields, @override | Login |
| 4. Real writes | serve-api-extension --all | commercetools calling your handlers on real writes, condition triggers | Login with manage_project, a tunnel, a sandbox Project |
| 5. Publish gate | pnpm validate | Sandbox violations, shape, composition, breaking changes | Login |
| 6. Push and confirm | pnpm push, then explore --deployed | The revision actually loads and serves | Login |
--help lists every flag. The CLI commands below are shortened: each one runs as commercetools integration-layer extension <command>.1. Unit-test resolvers and handlers
now() and config). init scaffolds a Vitest file next to each src/extension.ts, and pnpm test at the root runs every package's suite with no login and no network.ExtensionInput. The handler receives the resource as it will be persisted, after the request's update actions are applied, not the update actions themselves. Assert on the resulting cart or order, and cover Create and Update:import { describe, expect, it } from "vitest";
import type { ExtensionInput } from "@commercetools/platform-sdk";
import { apiExtensions } from "./extension.js";
const ctx = (config: Record<string, string> = {}) => ({ now: () => Date.now(), config });
const { handler } = apiExtensions.find((e) => e.key === "cart-sku-blocker")!;
const cartWith = (action: "Create" | "Update", skus: string[]) =>
({
action,
resource: {
typeId: "cart",
id: "cart-1",
obj: { id: "cart-1", lineItems: skus.map((sku, i) => ({ id: `li-${i}`, quantity: 1, variant: { sku } })) },
},
}) as unknown as ExtensionInput;
describe("cart-sku-blocker", () => {
it("approves a cart without blocked SKUs", () => {
expect(handler(cartWith("Create", ["OK-1"]), ctx())).toEqual({});
});
it.each(["Create", "Update"] as const)("blocks a blocked SKU on %s", (action) => {
expect(handler(cartWith(action, ["BLOCKED-SKU"]), ctx())).toMatchObject({
errors: [expect.objectContaining({ code: "InvalidInput" })],
});
});
it("reads the blocked list from config", () => {
expect(handler(cartWith("Create", ["X"]), ctx({ BLOCKED_SKU: "X,Y" }))).toHaveProperty("errors");
});
});
lineItems holds for every entry point that adds a line item (cart creation, addLineItem, adding a Shopping List) without knowing which one was used. Prefer that over inspecting the request.process, Buffer, and node:* imports exist. The sandbox withholds them, so a passing unit test does not prove the bundle loads. Step 5 catches that.2. Fire the handlers at the merged bundle, offline
invoke-api-extension runs the bundle's handlers in-process against a JSON ExtensionInput and prints each handler's verdict: approve, modify with the actions, or block with the errors. With --all it runs the merged bundle and reports every package's handlers for the same write.# Scaffold realistic payloads once, edit them, and keep them in the repo as fixtures
commercetools integration-layer extension create-api-extension-input --resource-type cart --action Create --out payloads/cart-create.json
commercetools integration-layer extension create-api-extension-input --resource-type cart --action Update --out payloads/cart-update.json
commercetools integration-layer extension invoke-api-extension --all --input payloads/cart-create.json
commercetools integration-layer extension invoke-api-extension --all --input payloads/cart-update.json --config MAX_LINE_QUANTITY=10
resourceTypeId and actions match the payload, and the rest are reported as skipped. Check the skipped list: a handler that should have fired but was skipped has the wrong trigger. Local matching uses resourceTypeId and actions only, so test a commercetools condition with real writes in step 4.ctx.config comes from EXTENSION_CONFIG_<KEY> variables in the shell or a .env file, and --config KEY=VALUE overrides a single key.3. Query the merged schema through a gateway
pnpm dev runs serve --all: a local federated gateway over your merged extension subgraph and the Project's deployed Experience API. This is the production topology in miniature, so query core fields and your fields together in one operation:{ product(id: "…") { name loyaltyPoints(price: 1000) } }
name resolves upstream and loyaltyPoints locally. The server hot-reloads on every save of an extension or the .env. The server exposes three routes:/graphqlis the gateway./_extensionis the raw merged subgraph./composedshows the merged schema.
If an edit does not compose, the log lists the collisions and the server keeps serving the last good schema, so watch the log, not only the query result.
- Fields that use
@requiresor@overrideonly show their real behavior in the composed graph. Test them here, not in a standalone server. - External services: when you are logged in, resolver
fetchgoes through the same Project host allowlist as production. Add the provider's host withallowlist add <host>before testing. Without a login,fetchis unrestricted locally, so an allowlist gap only shows up once you log in. - Customer-scoped fields:
commercetools integration-layer explore --as <email>, run from an extension directory, composes your working tree against the Project and runs operations as that customer. - One extension in isolation:
pnpm --filter @extensions/<name> devserves just that subgraph, offline. Exercise entity fields through the_entitiesquery.
4. Run real writes through your local handlers
serve-api-extension serves the merged handlers over HTTP and registers a commercetools API Extension pointing at a public tunnel, so real writes in the Project call the code on your machine. Attach a debugger, and edits hot-reload.ngrok http 4000 # or any HTTPS tunnel
commercetools integration-layer extension serve-api-extension --all --public-url https://<tunnel-host>
manage_project login. The command refuses to start if the Project already has an API Extension on the same resource and action, which can include the one your deployed bundle registered. It deletes what it registered on exit, and --cleanup removes leftovers after a crash.This is the only layer where commercetools itself validates the request before your handler runs. Use it to:
- send every entry point of the business action through the API: a cart created with
lineItems,addLineItem, adding a Shopping List; - confirm that a
conditiontriggers on the writes it should, and only those; - confirm the case from the write path: a value the request needs to pass validation has to be on the request itself, because your handler never sees a request that fails validation.
5. Validate the merged bundle
pnpm validate runs the publish gate against the logged-in Project without uploading anything:| Check | Where | Bypass with --force |
|---|---|---|
Static analysis: no withheld globals (process, node:*, eval, …) | Local | Never |
Shape: non-empty typeDefs, a resolver for every declared field, well-formed apiExtensions | Local | Never |
| Composition against the Project's live core subgraph | Remote | Yes |
| Breaking changes against the currently published schema | Remote | Yes |
commercetools integration-layer schema fetch prints the core subgraph SDL your bundle composes against, which helps when a composition error comes from a change on the core side.6. Push and confirm the revision is running
pnpm push builds, validates, and uploads the merged bundle, replacing the Project's current one. A push stores the bundle. The runtime then loads it, and each revision moves from pending to running (it serves) or failed (with a reason). push waits for that verdict by default and exits non-zero on failed. Keep the wait, and raise --wait-timeout instead of using --no-wait if loading takes long.- A failed revision drops out of service. The Project keeps serving the newest revision that did not fail. When a pushed change does not appear, check whether the push reported
running. extension statusshows the stored bundle (version, size, upload time, recorded source revision), not whether it loaded. The push's wait is where the load verdict is reported.- Rolling back means pushing the previous code again. There is no unpublish command, and the recorded source revision (from git, or
EXTENSION_SOURCE_REVISIONin CI) tells you which code is deployed. --forceskips only the remote checks, for a coordinated breaking change. Through a package script, forward it explicitly:pnpm push -- --force.- Configuration set with
config set <KEY> <VALUE>(add--secretfor credentials) takes effect without a new push.
running, check the live edge:commercetools integration-layer explore --deployed # the composed schema the router actually serves
commercetools integration-layer extension invoke-api-extension --deployed --input payloads/cart-create.json # the deployed handlers; nothing is persisted
manage_project client and push with the wait on:EXTENSION_SOURCE_REVISION="$BUILD_ID" \
commercetools integration-layer extension push --all --wait-timeout 300
The write path in the Commerce Integration Layer
Changing commercetools write behavior
1. Start with a Managed API Extension
Two conditions put a requirement beyond an API Extension:
- The field is immutable once set. An extension firing after the fact cannot change it, so the value must be right on the incoming draft or action.
- The request must be valid on arrival. commercetools validates a request before any extension runs, so a value the request needs in order to pass validation has to be on it already.
addLineItem itself, so it must be present on the way in.If neither condition holds, build a Managed API Extension and stop here.
2. Reach out to commercetools support
If an API Extension cannot solve it, contact commercetools support with the use case before building anything else. Describe the business requirement, not only the field you want to set: the right answer is often a modeling option or feature that makes custom logic unnecessary.
3. Final step: override the existing mutation with @override
@override directive. Clients keep calling the same mutation, and your resolver runs in its place: resolve the missing input, then perform the commercetools write.It is the final step because your resolver now owns the whole operation:
- It must keep up with the core mutation. Fields and behavior the core mutation gains later are not inherited automatically. Re-check the override when you update the bundle.
- It only covers traffic through the Experience API. Writes from the Merchant Center or direct API clients do not pass through it, unlike an API Extension.
Writes to an external service provider
Scope the logic to the business action, not the update action
lineItems in the draft, addLineItem, adding a Shopping List to a Cart, a gift line item added by a Cart Discount. A handler that only recognizes addLineItem leaves the other paths open, and the rule it was meant to hold silently lapses the day a client uses one of them. This matters most for an @override, which covers only the one mutation it replaces.Before you ship, list every entry point for the business action and check that each one is covered.