Custom Events and Side-by-Side Extensions: Commerce to BTP Kyma End to End
A complete worked pattern for event-driven extensibility: defining a custom commerce event, exporting it to SAP BTP Kyma runtime, and consuming it in a serverless function that calls back through the Integration API and OCC.
The integration options guide in this series makes the strategic argument: business logic that does not have to live inside SAP Commerce should live beside it, connected through events and APIs. This guide is the tactical companion: one complete, working pattern, from a custom event class in a commerce extension to a serverless function in SAP BTP, Kyma runtime that reacts to it and calls back into commerce for the data it needs.
The worked example: customers submit product reviews, and the business wants sentiment analysis on each one, with follow-up actions (a service ticket for angry reviews, a marketing interaction for delighted ones). There is no out-of-the-box event for review submission, no reason to run a text-analytics stack inside commerce, and no need for the storefront to wait on any of it. In other words: the perfect side-by-side case. The commerce-side event mechanics shown here (event beans, EventConfiguration, the export machinery) apply to any consumer, Kyma or otherwise.
The Anatomy#
Commerce events are plain Java classes extending AbstractEvent. Making one available externally takes three steps: define the event bean, register it for export to a destination, and publish it from business logic. Around the event, this example adds two supporting pieces: a unique key on the CustomerReview type (events carry references, not payloads, so the consumer needs a way to fetch the full data) and an Integration Object exposing reviews through OData for exactly that fetch.
The flow at runtime:
storefront review submission
-> CustomerReviewService creates review (with unique code)
-> ProductReviewSubmittedEvent published (carries reviewcode)
-> event exported to Kyma (product.reviewsubmitted)
-> function triggers, fetches review via Integration API OData
-> fetches customer via OCC
-> sentiment analysis, follow-up actions
Carrying only the key in the event is deliberate design, not laziness: the event stays tiny, the consumer always reads current data (not a stale payload), and access control happens at the API where it belongs.
Commerce Side#
Extension setup#
A dedicated extension keeps the event machinery out of your core business extensions:
ant extgen -Dtemplate=yempty -Dname=acmeevents
Register it in localextensions.xml and declare the dependencies in extensioninfo.xml:
<requires-extension name="commercefacades"/>
<requires-extension name="kymaintegrationservices"/>
kymaintegrationservices brings the event export machinery and destination handling for Kyma-style consumers.
Give the source type a real key#
CustomerReview ships without a unique business key, which blocks both clean event references and Integration Object modeling. Extend the type:
<itemtype code="CustomerReview" autocreate="false" generate="false">
<attributes>
<attribute type="java.lang.String" qualifier="code">
<description>Unique id for the customer review</description>
<persistence type="property"/>
<modifiers read="true" write="true" search="true" initial="true" optional="false" unique="true"/>
</attribute>
</attributes>
</itemtype>
and populate it by overriding the review service (aliased over the default, per standard platform practice):
public class AcmeCustomerReviewService extends DefaultCustomerReviewService {
@Override
public CustomerReviewModel createCustomerReview(final Double rating, final String headline,
final String comment, final UserModel user, final ProductModel product) {
final CustomerReviewModel review = getModelService().create(CustomerReviewModel.class);
review.setUser(user);
review.setProduct(product);
review.setRating(rating);
review.setHeadline(headline);
review.setComment(comment);
review.setCode(UUID.randomUUID().toString());
getModelService().save(review);
return review;
}
}
<bean id="acmeCustomerReviewService"
class="com.acme.events.service.impl.AcmeCustomerReviewService"
parent="defaultCustomerReviewService"/>
<alias alias="customerReviewService" name="acmeCustomerReviewService"/>
This "add a business key first" step recurs in almost every event-plus-API design; budget for it whenever the source type was never meant to be addressed externally.
Define and publish the event#
The event class is generated from *-beans.xml, so the payload definition is declarative:
<bean class="com.acme.events.ProductReviewSubmittedEvent" type="event">
<property name="reviewcode" type="String"/>
</bean>
ant all generates the class; publishing is two lines wherever the business moment happens (facade or service):
final ProductReviewSubmittedEvent event = new ProductReviewSubmittedEvent();
event.setReviewcode(review.getCode());
eventService.publishEvent(event);
Remember from the integration options guide: new event types are code, so this part rides the build; everything after this point is runtime configuration.
Register the export#
EventConfiguration maps the Java event onto an exported event name and destination, and EventPropertyConfiguration documents the payload. Keep it in ImpEx so environments stay consistent:
$destination_target = Default_Template
INSERT_UPDATE EventConfiguration; eventClass[unique=true]; destinationTarget(id)[unique=true, default=$destination_target]; version[unique=true, default=1]; exportFlag; priority(code); exportName; mappingType(code)[default=GENERIC]; description; extensionName
; com.acme.events.ProductReviewSubmittedEvent ;;; true ; MEDIUM ; product.reviewsubmitted ;; "Product review submitted" ; acmeevents
INSERT_UPDATE EventPropertyConfiguration; eventConfiguration(eventClass, destinationTarget(id[default=$destination_target]), version[default=1])[unique=true]; propertyName[unique=true]; propertyMapping; title; description; required[default=true]; type[default='string']
; com.acme.events.ProductReviewSubmittedEvent ; reviewcode ; "event.reviewcode" ; "Review Code" ; "UUID of the submitted review"
The exportName (product.reviewsubmitted) is the contract identifier consumers subscribe to; version it like the API surface it is.
Expose the data: the Integration Object#
The consumer needs to turn reviewcode into a full review. An inbound Integration Object generates the OData service (the full attribute mapping enumerates every exposed field per item type; trimmed here to the load-bearing rows):
INSERT_UPDATE IntegrationObject; code[unique=true]; integrationType(code)
; InboundCustomerReview ; INBOUND
INSERT_UPDATE IntegrationObjectItem; integrationObject(code)[unique=true]; code[unique=true]; type(code)
; InboundCustomerReview ; CustomerReview ; CustomerReview
; InboundCustomerReview ; User ; User
; InboundCustomerReview ; Product ; Product
; InboundCustomerReview ; CatalogVersion ; CatalogVersion
; InboundCustomerReview ; Catalog ; Catalog
INSERT_UPDATE IntegrationObjectItemAttribute; integrationObjectItem(integrationObject(code), code)[unique=true]; attributeName[unique=true]; attributeDescriptor(enclosingType(code), qualifier); returnIntegrationObjectItem(integrationObject(code), code); unique[default=false]
; InboundCustomerReview:CustomerReview ; code ; CustomerReview:code ; ; true
; InboundCustomerReview:CustomerReview ; rating ; CustomerReview:rating ; ;
; InboundCustomerReview:CustomerReview ; headline ; CustomerReview:headline; ;
; InboundCustomerReview:CustomerReview ; comment ; CustomerReview:comment ; ;
; InboundCustomerReview:CustomerReview ; user ; CustomerReview:user ; InboundCustomerReview:User ;
; InboundCustomerReview:CustomerReview ; product ; CustomerReview:product ; InboundCustomerReview:Product ;
; InboundCustomerReview:User ; uid ; User:uid ; ; true
; InboundCustomerReview:Product ; code ; Product:code ; ; true
; InboundCustomerReview:Product ; catalogVersion ; Product:catalogVersion ; InboundCustomerReview:CatalogVersion ; true
One design lesson visible even in the trimmed version: every referenced type in the object graph (user, product, catalog version) must be modeled, and each needs its unique key attributes marked. Model the minimum graph the consumer needs; the original CX Works example exposed the entire Address type with twenty attributes, which is twenty attributes of PII surface nobody asked for. Expose less.
Secure it like any Integration API: an Inbound Channel Configuration (Basic or OAuth2), access rights scoped to read-only on this object, and a dedicated integration user.
Kyma Side#
With the commerce application connected to the Kyma runtime (destination configured commerce-side, application bound to a namespace), the exported event and the commerce APIs appear as consumable services in the namespace. The function subscribes to product.reviewsubmitted and works the callback chain. Modernized skeleton (the original used the retired request library; current Kyma Node.js runtimes have fetch):
const SERVICE_URL = process.env.SERVICE_URL; // Integration API base (InboundCustomerReview)
const GATEWAY_URL = process.env.GATEWAY_URL; // OCC base via the gateway binding
const BASE_SITE = process.env.BASE_SITE;
const AUTH = 'Basic ' + Buffer.from(
`${process.env.SERVICE_UID}:${process.env.SERVICE_PW}`).toString('base64');
module.exports = {
main: async function (event, context) {
const reviewcode = event.data.reviewcode;
// 1. full review via Integration API (OData)
const review = await getJson(
`${SERVICE_URL}/CustomerReviews('${reviewcode}')`, { Authorization: AUTH });
// 2. customer profile via OCC
const uid = review.d.user.uid ?? await resolveDeferred(review.d.user, AUTH);
const customer = await getJson(
`${GATEWAY_URL}/${BASE_SITE}/users/${encodeURIComponent(uid)}?fields=FULL`);
// 3. sentiment + follow-up (ticketing / marketing) go here
const sentiment = await analyzeSentiment(review.d.comment);
if (sentiment.score < 0) {
await createServiceTicket(uid, reviewcode, review.d.comment);
}
}
};
async function getJson(url, headers = {}) {
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return res.json();
}
Function configuration: an event trigger on product.reviewsubmitted, a service binding to the OCC service exposed by the connected commerce application, and environment variables for the Integration API URL and credentials. Kyma's tooling around this (module structure, UI, eventing implementation) has evolved repeatedly since the original 2011-era write-up; the shape that has stayed stable is exactly what this guide leans on: subscribe to the exported event, call back over OData and OCC, keep credentials in the runtime's secret machinery rather than in code.
Design Rules This Pattern Encodes#
- Events carry keys, APIs carry data. Slim events, fresh reads, centralized authorization.
- The storefront never waits. Review submission returns to the customer at commerce speed; sentiment analysis happens on Kyma's clock. A sentiment outage costs you enrichment, not checkout.
- Delivery is not guaranteed. This rides the same export machinery as webhooks: monitor the dead letter queue, and design consumers so a missed event is an annoyance (one unanalyzed review), not corruption. If the business case cannot tolerate loss, this is the wrong channel; use OutboundSync-style replication and reconcile.
- Version the contract.
exportNameplusEventConfiguration.versionplus the Integration Object shape form an API. Additive changes bump versions; consumers migrate deliberately. - Test the seam with curls, not clicks. The OData endpoint (
.../CustomerReviews('<code>')) and the OCC user endpoint are independently verifiable before any function exists; get them returning the right JSON first, and function debugging shrinks to function logic.
The review-sentiment example is deliberately mundane, because the pattern is the point: every "commerce should react when X happens" requirement decomposes the same way, and each one you route through this seam instead of an in-app extension is upgrade risk you declined to buy.