- Overview
- Step-by-Step Guide
- Variations by Field Type
- Quick Reference: File Locations
- Behavioral Extensions via Hooks
The commercetools-sync-java library supports specific fields for each resource type. For a full list of supported fields, see the Supported Resources and Fields document.
If the field you need is not listed, you can add native support by following the steps in this guide. All changes are made within the existing library files — no external dependencies are needed.
This guide walks through a concrete example: adding support for a hypothetical unsupported field on CartDiscounts. CartDiscounts is used here because it has a clean, straightforward structure. The same pattern applies to all resource types.
Open the {Resource}UpdateActionUtils.java file for the resource you want to extend. For CartDiscounts, this is:
src/main/java/com/commercetools/sync/cartdiscounts/utils/CartDiscountUpdateActionUtils.java
Add a new static method that compares the field's old and new values and returns an Optional<{Resource}UpdateAction>. Use the buildUpdateAction() helper from CommonTypeUpdateActionUtils for the comparison.
Example — adding a store field to CartDiscounts:
import static com.commercetools.sync.commons.utils.CommonTypeUpdateActionUtils.buildUpdateAction;
/**
* Compares the store values of a {@link CartDiscount} and a {@link CartDiscountDraft} and returns
* an {@link CartDiscountUpdateAction} as a result in an {@link java.util.Optional}. If both the
* {@link CartDiscount} and the {@link CartDiscountDraft} have the same store, then no update action
* is needed and hence an empty {@link java.util.Optional} is returned.
*
* @param oldCartDiscount the cart discount which should be updated.
* @param newCartDiscount the cart discount draft where we get the new store.
* @return A filled optional with the update action or an empty optional if the stores are identical.
*/
@Nonnull
public static Optional<CartDiscountUpdateAction> buildSetStoresUpdateAction(
@Nonnull final CartDiscount oldCartDiscount,
@Nonnull final CartDiscountDraft newCartDiscount) {
return buildUpdateAction(
oldCartDiscount.getStores(),
newCartDiscount.getStores(),
() -> CartDiscountSetStoresActionBuilder.of()
.stores(newCartDiscount.getStores())
.build());
}Key points:
- The method name follows the pattern
build{ActionName}UpdateAction. - It returns
Optional<{Resource}UpdateAction>— empty if the values are equal, filled if they differ. - The
buildUpdateAction()helper compares usingObjects.equals()and only invokes the supplier when values differ. - The update action is built using the SDK's builder:
{ActionType}ActionBuilder.of().{field}(value).build().
Open the {Resource}SyncUtils.java file. For CartDiscounts:
src/main/java/com/commercetools/sync/cartdiscounts/utils/CartDiscountSyncUtils.java
Add a call to your new builder method inside the buildActions() method, within the filterEmptyOptionals() call:
final List<CartDiscountUpdateAction> updateActions =
filterEmptyOptionals(
CartDiscountUpdateActionUtils.buildChangeValueUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildChangeCartPredicateUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildChangeTargetUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildChangeIsActiveUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildChangeNameUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildSetDescriptionUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildChangeSortOrderUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildChangeRequiresDiscountCodeUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildSetValidDatesUpdateAction(oldCartDiscount, newCartDiscount),
CartDiscountUpdateActionUtils.buildChangeStackingModeUpdateAction(oldCartDiscount, newCartDiscount),
// New field:
CartDiscountUpdateActionUtils.buildSetStoresUpdateAction(oldCartDiscount, newCartDiscount));That single line is all that's needed to wire the new field into the sync process.
Open the {Resource}UpdateActionUtilsTest.java file. For CartDiscounts:
src/test/java/com/commercetools/sync/cartdiscounts/utils/CartDiscountUpdateActionUtilsTest.java
Write tests covering these cases:
Test 1 — Different values should generate an update action:
@Test
void buildSetStoresUpdateAction_WithDifferentStores_ShouldBuildUpdateAction() {
final CartDiscount oldCartDiscount = mock(CartDiscount.class);
final List<StoreKeyReference> oldStores = List.of(
StoreKeyReferenceBuilder.of().key("store-1").build());
when(oldCartDiscount.getStores()).thenReturn(oldStores);
final CartDiscountDraft newCartDiscountDraft = mock(CartDiscountDraft.class);
final List<StoreResourceIdentifier> newStores = List.of(
StoreResourceIdentifierBuilder.of().key("store-2").build());
when(newCartDiscountDraft.getStores()).thenReturn(newStores);
final Optional<CartDiscountUpdateAction> result =
CartDiscountUpdateActionUtils.buildSetStoresUpdateAction(
oldCartDiscount, newCartDiscountDraft);
assertThat(result).isPresent();
}Test 2 — Same values should return empty Optional:
@Test
void buildSetStoresUpdateAction_WithSameStores_ShouldNotBuildUpdateAction() {
final CartDiscount oldCartDiscount = mock(CartDiscount.class);
final List<StoreKeyReference> stores = List.of(
StoreKeyReferenceBuilder.of().key("store-1").build());
when(oldCartDiscount.getStores()).thenReturn(stores);
final CartDiscountDraft newCartDiscountDraft = mock(CartDiscountDraft.class);
when(newCartDiscountDraft.getStores()).thenReturn(stores);
final Optional<CartDiscountUpdateAction> result =
CartDiscountUpdateActionUtils.buildSetStoresUpdateAction(
oldCartDiscount, newCartDiscountDraft);
assertThat(result).isNotPresent();
}Test 3 — Null handling:
@Test
void buildSetStoresUpdateAction_WithBothNull_ShouldNotBuildUpdateAction() {
final CartDiscount oldCartDiscount = mock(CartDiscount.class);
when(oldCartDiscount.getStores()).thenReturn(null);
final CartDiscountDraft newCartDiscountDraft = mock(CartDiscountDraft.class);
when(newCartDiscountDraft.getStores()).thenReturn(null);
final Optional<CartDiscountUpdateAction> result =
CartDiscountUpdateActionUtils.buildSetStoresUpdateAction(
oldCartDiscount, newCartDiscountDraft);
assertThat(result).isNotPresent();
}Run the tests with:
./gradlew test --tests "*CartDiscountUpdateActionUtilsTest"Add the new field to the resource's table in docs/SUPPORTED_RESOURCES.md.
Finally, format the code:
./gradlew spotlessApplyAnd run the full check to make sure everything passes:
./gradlew checkString, Boolean, LocalizedString, enum, and date fields all use the same buildUpdateAction() helper shown above. This covers the majority of cases.
For fields with default values (e.g., isActive defaults to true), handle null by substituting the default before comparing:
final Boolean isActive = ofNullable(newCartDiscount.getIsActive()).orElse(true);
return buildUpdateAction(
oldCartDiscount.getIsActive(),
isActive,
() -> CartDiscountChangeIsActiveActionBuilder.of().isActive(isActive).build());Adding a reference field (e.g., a store, customer, or parent category) requires additional steps beyond what simple fields need. The old resource holds a KeyReference (with a key), while the new draft holds a ResourceIdentifier (with a key or id). You must handle this asymmetry in the update action builder and also wire up reference resolution.
The following example is based on PR #1238, which added the store field to ShoppingLists.
In {Resource}UpdateActionUtils.java, extract comparable values (typically keys) from both the old reference and the new resource identifier, then use buildUpdateAction() on those extracted values:
@Nonnull
public static Optional<ShoppingListUpdateAction> buildSetStoreUpdateAction(
@Nonnull final ShoppingList oldShoppingList,
@Nonnull final ShoppingListDraft newShoppingList) {
final String oldStoreKey =
oldShoppingList.getStore() != null ? oldShoppingList.getStore().getKey() : null;
final String newStoreKey =
newShoppingList.getStore() != null && newShoppingList.getStore().getKey() != null
? newShoppingList.getStore().getKey()
: (newShoppingList.getStore() != null ? newShoppingList.getStore().getId() : null);
return buildUpdateAction(
oldStoreKey,
newStoreKey,
() -> ShoppingListSetStoreActionBuilder.of().store(newShoppingList.getStore()).build());
}Then register it in {Resource}SyncUtils.java and write unit tests (Steps 2 and 3 above), covering: different keys, same keys, null old, null new, and both null.
When syncing from an external source, references are provided by key. When syncing from a commercetools project, they may be provided by ID. The {Resource}ReferenceResolver handles converting keys to IDs so the API can process them.
In {Resource}ReferenceResolver.java, add a resolve method and chain it in resolveReferences():
File: src/main/java/com/commercetools/sync/shoppinglists/helpers/ShoppingListReferenceResolver.java
// Chain the new resolver in the resolveReferences() method:
@Override
public CompletionStage<ShoppingListDraft> resolveReferences(
@Nonnull final ShoppingListDraft shoppingListDraft) {
return resolveCustomerReference(ShoppingListDraftBuilder.of(shoppingListDraft))
.thenCompose(this::resolveStoreReference) // <-- add this line
.thenCompose(this::resolveCustomTypeReference)
.thenCompose(this::resolveLineItemReferences)
.thenCompose(this::resolveTextLineItemReferences)
.thenApply(ShoppingListDraftBuilder::build);
}
// Add the resolve method:
@Nonnull
protected CompletionStage<ShoppingListDraftBuilder> resolveStoreReference(
@Nonnull final ShoppingListDraftBuilder draftBuilder) {
final StoreResourceIdentifier storeResourceIdentifier = draftBuilder.getStore();
if (storeResourceIdentifier != null && storeResourceIdentifier.getId() == null) {
try {
final String storeKey = getKeyFromResourceIdentifier(storeResourceIdentifier);
return completedFuture(
draftBuilder.store(StoreResourceIdentifierBuilder.of().key(storeKey).build()));
} catch (ReferenceResolutionException referenceResolutionException) {
return exceptionallyCompletedFuture(
new ReferenceResolutionException(
format(FAILED_TO_RESOLVE_STORE_REFERENCE,
draftBuilder.getKey(),
referenceResolutionException.getMessage())));
}
} else if (storeResourceIdentifier != null && storeResourceIdentifier.getId() != null) {
return completedFuture(
draftBuilder.store(
StoreResourceIdentifierBuilder.of().id(storeResourceIdentifier.getId()).build()));
}
return completedFuture(draftBuilder);
}When syncing from a commercetools project, resources are fetched as full objects and must be converted to drafts. The {Resource}ReferenceResolutionUtils handles this mapping.
In {Resource}ReferenceResolutionUtils.java, add a mapping method and call it from mapTo{Resource}Draft():
File: src/main/java/com/commercetools/sync/shoppinglists/utils/ShoppingListReferenceResolutionUtils.java
// In the mapToShoppingListDraft() method, add the store mapping:
return ShoppingListDraftBuilder.of()
.name(shoppingList.getName())
.key(shoppingList.getKey())
.customer(customerResourceIdentifierWithKey)
.store(mapToStoreResourceIdentifier(shoppingList)) // <-- add this line
// ... other fields ...
.build();
// Add the mapping method:
@Nullable
private static StoreResourceIdentifier mapToStoreResourceIdentifier(
@Nonnull final ShoppingList shoppingList) {
if (shoppingList.getStore() != null) {
return StoreResourceIdentifierBuilder.of().key(shoppingList.getStore().getKey()).build();
}
return null;
}Write tests for the reference resolution utils in {Resource}ReferenceResolutionUtilsTest.java:
@Test
void mapToShoppingListDrafts_WithStoreReference_ShouldReturnDraftsWithStoreKey() {
final ShoppingList mockShoppingList = mock(ShoppingList.class);
when(mockShoppingList.getName()).thenReturn(ofEnglish("name"));
when(mockShoppingList.getKey()).thenReturn("key");
final StoreKeyReference storeKeyReference =
StoreKeyReferenceBuilder.of().key("store-key").build();
when(mockShoppingList.getStore()).thenReturn(storeKeyReference);
// ... mock other fields as null ...
final List<ShoppingListDraft> drafts =
ShoppingListReferenceResolutionUtils.mapToShoppingListDrafts(
singletonList(mockShoppingList), referenceIdToKeyCache);
assertThat(drafts).hasSize(1);
assertThat(drafts.get(0).getStore()).isNotNull();
assertThat(drafts.get(0).getStore().getKey()).isEqualTo("store-key");
}
@Test
void mapToShoppingListDrafts_WithNullStore_ShouldReturnDraftsWithNullStore() {
final ShoppingList mockShoppingList = mock(ShoppingList.class);
when(mockShoppingList.getName()).thenReturn(ofEnglish("name"));
when(mockShoppingList.getKey()).thenReturn("key");
when(mockShoppingList.getStore()).thenReturn(null);
// ... mock other fields as null ...
final List<ShoppingListDraft> drafts =
ShoppingListReferenceResolutionUtils.mapToShoppingListDrafts(
singletonList(mockShoppingList), referenceIdToKeyCache);
assertThat(drafts).hasSize(1);
assertThat(drafts.get(0).getStore()).isNull();
}Products support filtering update actions by group via SyncFilter. When adding a field to Products:
1. Add an enum value to ActionGroup (if an appropriate group doesn't already exist):
File: src/main/java/com/commercetools/sync/products/ActionGroup.java
public enum ActionGroup {
NAME,
DESCRIPTION,
// ... existing values ...
MY_NEW_FIELD // Add your new group here
}2. Wrap the action builder with buildActionIfPassesFilter in ProductSyncUtils.buildActions():
File: src/main/java/com/commercetools/sync/products/utils/ProductSyncUtils.java
buildActionIfPassesFilter(
syncFilter,
ActionGroup.MY_NEW_FIELD,
() -> buildMyNewFieldUpdateAction(oldProduct, newProduct))This ensures the field respects the blacklist/whitelist configuration that users set via ProductSyncOptionsBuilder.syncFilter().
For each resource type, the files you need to modify follow this pattern:
| Resource | ReferenceResolver | ReferenceResolutionUtils |
|---|---|---|
| Products | ProductReferenceResolver.java |
ProductReferenceResolutionUtils.java |
| Categories | CategoryReferenceResolver.java |
CategoryReferenceResolutionUtils.java |
| InventoryEntries | InventoryReferenceResolver.java |
InventoryReferenceResolutionUtils.java |
| CartDiscounts | CartDiscountReferenceResolver.java |
CartDiscountReferenceResolutionUtils.java |
| Customers | CustomerReferenceResolver.java |
CustomerReferenceResolutionUtils.java |
| ShoppingLists | ShoppingListReferenceResolver.java |
ShoppingListReferenceResolutionUtils.java |
All paths are relative to src/main/java/com/commercetools/sync/ (source) and src/test/java/com/commercetools/sync/ (tests).
The beforeUpdateCallback and beforeCreateCallback hooks are designed for customizing sync behavior, not for adding field support. They are called during the sync flow to allow you to intercept and modify requests before they are sent to the commercetools API.
Use hooks when you need to:
- Filter out update actions — e.g., prevent variant removals (see
KeepOtherVariantsSync) - Restrict sync to a subset of data — e.g., only sync a single locale (see
SyncSingleLocale) - Transform or enrich drafts before creation — e.g., set computed fields or conditionally skip creation by returning
null
Do not use hooks to add field support. If a resource field is not being synced, the correct approach is to add native support in the library code as described in this guide.
See the Sync Options documentation for callback signatures, configuration details, and additional examples.