Skip to content

Add a field

Sometimes a type the framework owns needs one more field. This guide takes one field from commercetools, through the GraphQL schema, to the storefront.

The example is a B2B cost center on the cart: the company code a buyer books a purchase on. Neither commercetools nor the framework’s Cart type has one. The paths are those of the reference sites, where carts live in the backend/purchase service. The storefront half is in Extend a feature’s data.

commercetools allows a resource exactly one custom type. Your project already provisions one for carts, because CartModule writes its own fields into it, such as the delivery remarks. So you do not create a type: you add a field to that one.

The reference sites keep their custom types in Terraform. Add the field to the order type, which commercetools uses for carts and orders alike:

resource "commercetools_type" "evolve_order" {
key = "evolve-order"
# ...the fields already there
field {
name = "costCenter"
label = {
en-GB = "Cost center"
nl-NL = "Kostenplaats"
}
type {
name = "String"
}
required = false
}
}
  • Keep required = false: existing carts have no value, and a consumer’s cart never gets one.
  • The service already names this type in CT_CUSTOM_TYPES.order in src/config.ts. CartModule attaches it to every cart it creates and checks at startup that it exists, so the configuration does not change.
  • Adding a field updates the type in place. Renaming a field or changing its type does not: commercetools has no update action for either.

Write the schema in a module of your own. extend type Cart adds the field to the Cart that CartModule declares:

import { type DocumentNode, parse } from "graphql";
export const typeDefs: DocumentNode = parse(/* GraphQL */ `
extend type Cart {
"The cost center the company books this purchase on."
costCenter: String
}
type CartSetCostCenterResponse {
"The cost center now on the cart"
costCenter: String
"The cart's version after the write, for the next pinned write"
version: String!
}
extend type Mutation {
"Record the cost center for a company cart. Pass null to clear it."
cartSetCostCenter(
id: ID!
costCenter: String
version: String
): CartSetCostCenterResponse! @requiresSession
}
`);
  • The service merges these definitions with those of the other modules, so the subgraph serves one Cart with your field on it.
  • Cart is a federation entity owned by the purchase subgraph. Put the module in that service: there the resolver gets the whole cart, where another subgraph would get only its id.
  • @requiresSession makes the gateway refuse the call without a session, as for the framework’s own cart mutations.
  • The mutation returns what changed, not the Cart: the framework does not export the mapper that builds a Cart, so a project mutation cannot return one. The storefront refetches the cart after the call.
import type { Cart as CtCart } from "@commercetools/platform-sdk";
import { assertActiveSession } from "@evolve-framework/core/service/graphql";
import type {
CartResolvers,
MutationResolvers,
} from "#src/generated/types.ts";
const COST_CENTER_FIELD = "costCenter";
const readCostCenter = (cart: CtCart | undefined): string | undefined => {
const value = cart?.custom?.fields?.[COST_CENTER_FIELD];
return typeof value === "string" ? value : undefined;
};
export const cartCostCenterResolver: CartResolvers["costCenter"] = (parent) =>
readCostCenter(parent.__instance as CtCart | undefined);
export const cartSetCostCenterMutation: MutationResolvers["cartSetCostCenter"] =
async (_parent, args, context) => {
assertActiveSession(context);
// The cost center belongs to the company, so commercetools decides through
// the associate endpoint whether this buyer may set it.
const cart = context.clientContext
.getAssociateClient()
.carts()
.withId({ ID: args.id });
const version = args.version
? Number(args.version)
: (await cart.get().execute()).body.version;
const updated = await cart
.post({
body: {
version,
actions: [
{
action: "setCustomField",
name: COST_CENTER_FIELD,
value: args.costCenter?.trim() || undefined,
},
],
},
})
.execute()
.then((response) => response.body);
return {
costCenter: readCostCenter(updated),
version: String(updated.version),
};
};

Reading costs no request. The cart mapper keeps the commercetools cart on __instance, and every Cart field resolver receives it as parent. The reference sites’ price tiers on ProductVariant read their data the same way.

Writing goes through the caller’s client. getAssociateClient() on context.clientContext acts as the signed-in buyer within their business unit, so commercetools checks their role. The framework writes cartSetPurchaseOrderNumber the same way. For a consumer’s cart, write through (await context.clientContext.getClient()).inStoreKeyWithStoreKeyValue({ storeKey: context.clientContext.storeKey }).me() instead.

Errors. setCustomField without a value removes the field, which is how null clears it. A commercetools error, such as a stale version, reaches the client masked as INTERNAL_SERVER_ERROR. The framework’s cart mutations return those as cartErrors; do the same if your storefront acts on them.

import { AbstractModule } from "@evolve-framework/core";
import {
cartCostCenterResolver,
cartSetCostCenterMutation,
} from "#src/cost-center/resolvers.ts";
import { typeDefs } from "#src/cost-center/schema.ts";
import type { Resolvers } from "#src/generated/types.ts";
export class CostCenterModule extends AbstractModule {
resolvers: Resolvers = {
Cart: { costCenter: cartCostCenterResolver },
Mutation: { cartSetCostCenter: cartSetCostCenterMutation },
};
typedefs = typeDefs;
}

Add new CostCenterModule() to the list in createModule() in src/module.ts, after CartModule. Resolvers are merged per type, so Cart.costCenter joins the Cart resolvers of CartModule. See Add your own module for the merge rules.

The resolvers import their types from src/generated/types.ts. Run pnpm codegen from the repository root. It runs three steps in order:

  1. backend/purchase/codegen.ts reads the schema from createModule().getGraphQLSchema() and writes the resolver types and schema.generated.graphql.
  2. backend/graphql-gateway composes the subgraphs into supergraph.generated.graphql.
  3. frontend/site generates its types from that supergraph, so its documents can ask for Cart.costCenter.

If codegen does not see the field, check that createModule() composes your module.

A company often books most purchases on one cost center. A commercetools API extension can copy it from the business unit onto every new cart, including carts created outside the storefront.

Add a defaultCostCenter field to commercetools_type.evolve_business_unit the same way as above. Then write the extension:

import type { Cart, CartUpdateAction } from "@commercetools/platform-sdk";
import type { ApiExtensionContext } from "@evolve-framework/commercetools/api-extension";
export const copyDefaultCostCenter = async (
cart: Cart,
{ client }: ApiExtensionContext<CartUpdateAction>,
): Promise<CartUpdateAction[]> => {
if (!cart.businessUnit) {
return [];
}
const unit = await client
.businessUnits()
.withKey({ key: cart.businessUnit.key })
.get()
.execute()
.then((response) => response.body);
const costCenter = unit.custom?.fields.defaultCostCenter;
return typeof costCenter === "string"
? [{ action: "setCustomField", name: "costCenter", value: costCenter }]
: [];
};

Register it on cart Create in src/extensions/module.ts, after attachOrderType, so the cart has its custom type before the field is written. In the B2B reference site:

cart: {
Create: [countCartCreated, attachOrderType, copyDefaultCostCenter, reverseCharge],
Update: [applyDefaultShippingMethod, reverseCharge],
},

The reference sites already register cart Create as a trigger, so no new trigger is needed. See Register the triggers.

Set up the commercetools mock as in Testing. The mock enforces associate roles, so the test makes the buyer an associate of the unit that owns the cart:

import {
associateRoleDraftFactory,
businessUnitDraftFactory,
cartDraftFactory,
customerDraftFactory,
} from "@evolve-framework/commercetools/factories";
import {
ctMockClient,
mockGraphQLContext,
} from "@evolve-framework/commercetools/testing";
import { expect, it } from "vitest";
import type { ContextValue } from "#src/context.ts";
import {
cartCostCenterResolver,
cartSetCostCenterMutation,
} from "#src/cost-center/resolvers.ts";
// The generated resolver types also allow an object form, so call them as plain functions.
const readCostCenter = cartCostCenterResolver as unknown as (
parent: unknown,
) => string | undefined;
const setCostCenter = cartSetCostCenterMutation as unknown as (
parent: unknown,
args: { id: string; costCenter?: string | null; version?: string },
context: ContextValue,
) => Promise<{ costCenter?: string; version: string }>;
it("reads the cost center off the cart", () => {
const parent = { __instance: { custom: { fields: { costCenter: "CC-4100" } } } };
expect(readCostCenter(parent)).toBe("CC-4100");
});
it("records the cost center on a company cart", async () => {
const customer = await customerDraftFactory.create();
const role = await associateRoleDraftFactory.create();
const businessUnit = await businessUnitDraftFactory.create({
associates: [
{
customer: { typeId: "customer", id: customer.id },
associateRoleAssignments: [
{ associateRole: { typeId: "associate-role", key: role.key } },
],
},
],
});
const cart = await cartDraftFactory.create({
customerId: customer.id,
businessUnit: { typeId: "business-unit", key: businessUnit.key },
});
const context = await mockGraphQLContext({
customer,
businessUnit,
clientFactory: ctMockClient,
});
const result = await setCostCenter(
{},
{ id: cart.id, costCenter: "CC-4100" },
context as unknown as ContextValue,
);
expect(result.costCenter).toBe("CC-4100");
});

mockGraphQLContext builds the client context for that customer and business unit. The default role from associateRoleDraftFactory has every permission.

Test the extension by calling it directly, with copyDefaultCostCenter(cart, createTestExtensionContext<CartUpdateAction>()). createTestExtensionContext reads through clientTestFactory, which the setup configures.

The storefront reads costCenter with the cart and sends it with cartSetCostCenter, without forking the cart feature. See Extend a feature’s data.

Carts and orders share the custom type, and commercetools copies a cart’s custom fields to the order created from it. To show the cost center in the order history, extend Order the same way: the order mapper also keeps the commercetools order on __instance.