SFCC Cartridge Development
Overview
Build custom cartridges for Salesforce Commerce Cloud (SFCC) using the Storefront Reference Architecture (SFRA), server-side JavaScript controllers, ISML templates, models, and the B2C Commerce Script API. This skill covers cartridge layering and the override mechanism, route handling with server.js, form handling, OCAPI/SCAPI integration, and Job Framework usage for scheduled data processing.
When to Use This Skill
- When building a custom feature cartridge that extends SFRA functionality
- When overriding or extending existing SFRA controllers, templates, or models
- When implementing custom checkout steps or payment integrations on SFCC
- When creating scheduled jobs for data import/export (product feeds, order sync)
- When building OCAPI hooks or SCAPI integrations for headless storefronts
Core Instructions
Set up the cartridge structure and layering
SFCC uses a cartridge path for layering. Cartridges higher in the path override those lower. A custom cartridge extends app_storefront_base:
int_acme_custom/
├── cartridge/
│ ├── controllers/ # Server-side JS controllers
│ ├── models/ # Data model wrappers
│ ├── scripts/ # Business logic helpers
│ ├── templates/
│ │ └── default/ # ISML templates
│ ├── forms/
│ │ └── default/ # Form definitions (XML)
│ ├── static/
│ │ └── default/
│ │ ├── css/
│ │ └── js/
│ └── int_acme_custom.properties # Cartridge metadata
└── package.json
Set the cartridge path in Business Manager:
int_acme_custom:app_storefront_base
int_acme_custom.properties:
## cartridge.properties
demandware.cartridges.int_acme_custom.multipleLanguageStorefront=true
Create a server-side controller
Controllers in SFRA use server.js for route registration:
// controllers/CustomPage.js
'use strict';
var server = require('server');
var cache = require('*/cartridge/scripts/middleware/cache');
var consentTracking = require('*/cartridge/scripts/middleware/consentTracking');
/**
* CustomPage-Show : Renders a custom content page
* @name CustomPage-Show
* @function
* @memberof CustomPage
* @param {middleware} - server.middleware.https
* @param {middleware} - consentTracking.consent
* @param {middleware} - cache.applyDefaultCache
* @param {querystringparameter} - cid : content asset ID
* @param {renders} - isml
* @param {serverfunction} - get
*/
server.get('Show',
server.middleware.https,
consentTracking.consent,
cache.applyDefaultCache,
function (req, res, next) {
var ContentMgr = require('dw/content/ContentMgr');
var ContentModel = require('*/cartridge/models/content');
var contentId = req.querystring.cid;
var apiContent = ContentMgr.getContent(contentId);
if (!apiContent) {
res.setStatusCode(404);
res.render('error/notFound');
return next();
}
var contentModel = new ContentModel(apiContent);
res.render('custom/contentPage', {
content: contentModel,
breadcrumbs: [
{ htmlValue: 'Home', url: '/' },
{ htmlValue: contentModel.name, url: '' }
]
});
next();
}
);
/**
* CustomPage-Submit : Handles form POST submissions
*/
server.post('Submit',
server.middleware.https,
function (req, res, next) {
var Transaction = require('dw/system/Transaction');
var CustomObjectMgr = require('dw/object/CustomObjectMgr');
var form = req.form;
var name = form.name;
var email = form.email;
// Validate input
if (!name || !email) {
res.json({ success: false, error: 'Name and email are required.' });
return next();
}
try {
Transaction.wrap(function () {
var co = CustomObjectMgr.createCustomObject('AcmeSubmissions', email);
co.custom.name = name;
co.custom.submittedAt = new Date();
});
res.json({ success: true, message: 'Submission received.' });
} catch (e) {
var Logger = require('dw/system/Logger');
Logger.error('Submission failed: {0}', e.message);
res.json({ success: false, error: 'An error occurred. Please try again.' });
}
next();
}
);
module.exports = server.exports();
Extend an existing SFRA controller
Use server.extend to add or modify routes on an existing controller:
// controllers/Cart.js — extending app_storefront_base Cart
'use strict';
var server = require('server');
var page = module.superModule; // Reference to the base Cart controller
server.extend(page);
/**
* Cart-Show : Append custom data to the Cart page
*/
server.append('Show', function (req, res, next) {
var viewData = res.getViewData();
// Add custom upsell products to the cart page
var ProductMgr = require('dw/catalog/ProductMgr');
var ArrayList = require('dw/util/ArrayList');
var upsells = new ArrayList();
var basket = require('dw/order/BasketMgr').getCurrentBasket();
if (basket) {
var items = basket.getAllProductLineItems();
for (var i = 0; i < items.length; i++) {
var recommendations = items[i].product.getRecommendations();
for (var j = 0; j < Math.min(recommendations.length, 2); j++) {
upsells.push(recommendations[j].getRecommendedItem());
}
}
}
viewData.upsellProducts = upsells.toArray().slice(0, 4);
res.setViewData(viewData);
next();
});
/**
* Cart-AddCustomItem : New route added to the Cart controller
*/
server.post('AddCustomItem', function (req, res, next) {
var BasketMgr = require('dw/order/BasketMgr');
var Transaction = require('dw/system/Transaction');
var ProductMgr = require('dw/catalog/ProductMgr');
var productId = req.form.pid;
var quantity = parseInt(req.form.quantity, 10) || 1;
var product = ProductMgr.getProduct(productId);
if (!product || !product.isOnline()) {
res.json({ error: true, message: 'Product not available.' });
return next();
}
var basket = BasketMgr.getCurrentOrNewBasket();
Transaction.wrap(function () {
var pli = basket.createProductLineItem(productId, basket.getDefaultShipment());
pli.setQuantityValue(quantity);
});
res.json({ success: true, itemCount: basket.productQuantityTotal });
next();
});
module.exports = server.exports();
Write ISML templates
<--- templates/default/custom/contentPage.isml --->
<isdecorate template="common/layout/page">
<isscript>
var assets = require('*/cartridge/scripts/assets');
assets.addCss('/css/custom/content.css');
assets.addJs('/js/custom/content.js');
</isscript>
<div class="container custom-content-page">
<div class="row">
<div class="col-12">
<nav aria-label="Breadcrumb">
<ol class="breadcrumb">
<isloop items="${pdict.breadcrumbs}" var="crumb" status="loopstatus">
<isif condition="${loopstatus.last}">
<li class="breadcrumb-item active">${crumb.htmlValue}</li>
<iselse/>
<li class="breadcrumb-item">
<a href="${crumb.url}">${crumb.htmlValue}</a>
</li>
</isif>
</isloop>
</ol>
</nav>
<h1>${pdict.content.name}</h1>
<div class="content-body">
<isprint value="${pdict.content.body}" encoding="off"/>
</div>
</div>
</div>
</div>
</isdecorate>
Create a data model wrapper
// models/content.js
'use strict';
var URLUtils = require('dw/web/URLUtils');
/**
* Content model wrapping a dw.content.Content API object
* @param {dw.content.Content} contentObj - Content API object
* @constructor
*/
function ContentModel(contentObj) {
this.id = contentObj.ID;
this.name = contentObj.name || contentObj.ID;
this.body = contentObj.custom.body ? contentObj.custom.body.markup : '';
this.online = contentObj.online;
this.url = URLUtils.url('CustomPage-Show', 'cid', contentObj.ID).toString();
this.pageTitle = contentObj.pageTitle || this.name;
this.pageDescription = contentObj.pageDescription || '';
this.pageKeywords = contentObj.pageKeywords || '';
}
module.exports = ContentModel;
Build a scheduled job for data processing
// scripts/jobs/syncInventory.js
'use strict';
var Status = require('dw/system/Status');
var Logger = require('dw/system/Logger').getLogger('inventory-sync', 'acme');
var HTTPClient = require('dw/net/HTTPClient');
var Transaction = require('dw/system/Transaction');
var ProductInventoryMgr = require('dw/catalog/ProductInventoryMgr');
/**
* Job step: Fetch inventory from external ERP and update SFCC
* @param {dw.util.HashMap} params - Job step parameters
* @returns {dw.system.Status} - Job status
*/
function execute(params) {
var apiUrl = params.get('apiUrl');
var apiKey = params.get('apiKey');
var inventoryListId = params.get('inventoryListId') || 'default';
var httpClient = new HTTPClient();
httpClient.open('GET', apiUrl);
httpClient.setRequestHeader('Authorization', 'Bearer ' + apiKey);
httpClient.setRequestHeader('Accept', 'application/json');
httpClient.setTimeout(30000);
httpClient.send();
if (httpClient.statusCode !== 200) {
Logger.error('ERP API returned status {0}', httpClient.statusCode);
return new Status(Status.ERROR, 'API_ERROR', 'ERP API returned ' + httpClient.statusCode);
}
var inventory = JSON.parse(httpClient.text);
var inventoryList = ProductInventoryMgr.getInventoryList(inventoryListId);
if (!inventoryList) {
return new Status(Status.ERROR, 'LIST_NOT_FOUND', 'Inventory list not found');
}
var updated = 0;
var errors = 0;
inventory.items.forEach(function (item) {
try {
Transaction.wrap(function () {
var record = inventoryList.getRecord(item.sku);
if (!record) {
record = inventoryList.createRecord(item.sku);
}
record.setAllocation(item.quantity);
if (item.inStockDate) {
record.setInStockDate(new Date(item.inStockDate));
}
});
updated++;
} catch (e) {
Logger.error('Failed to update SKU {0}: {1}', item.sku, e.message);
errors++;
}
});
Logger.info('Inventory sync complete: {0} updated, {1} errors', updated, errors);
return new Status(Status.OK, 'SYNC_COMPLETE', updated + ' records updated');
}
module.exports.execute = execute;
Examples
OCAPI hook for order creation
// hooks/order/ocapiHooks.js
'use strict';
var Status = require('dw/system/Status');
var Logger = require('dw/system/Logger').getLogger('ocapi-hooks', 'acme');
/**
* OCAPI after-POST hook for order creation
* Called after a new order is placed via OCAPI
*/
exports.afterPOST = function (order) {
try {
// Send order data to external analytics
var HTTPClient = require('dw/net/HTTPClient');
var httpClient = new HTTPClient();
httpClient.open('POST', 'https://analytics.acme.com/orders');
httpClient.setRequestHeader('Content-Type', 'application/json');
httpClient.send(JSON.stringify({
orderId: order.orderNo,
total: order.totalGrossPrice.value,
currency: order.currencyCode,
itemCount: order.productLineItems.length,
customerEmail: order.customerEmail,
}));
if (httpClient.statusCode !== 200) {
Logger.warn('Analytics push failed for order {0}: HTTP {1}',
order.orderNo, httpClient.statusCode);
}
} catch (e) {
Logger.error('OCAPI hook error: {0}', e.message);
}
return new Status(Status.OK);
};
Register in hooks.json:
{
"hooks": [
{
"name": "dw.ocapi.shop.order.afterPOST",
"script": "./hooks/order/ocapiHooks"
}
]
}
Form definition and server-side validation
<!-- forms/default/contactus.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns="http://www.demandware.com/xml/form/2008-04-19">
<field formid="name" label="form.contactus.name"
type="string" mandatory="true" max-length="100"/>
<field formid="email" label="form.contactus.email"
type="string" mandatory="true" max-length="254"
regexp="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"/>
<field formid="message" label="form.contactus.message"
type="string" mandatory="true" max-length="2000"/>
<action formid="submit" label="form.contactus.submit" valid-form="true"/>
</form>
// controllers/ContactUs.js
'use strict';
var server = require('server');
server.get('Show', function (req, res, next) {
var contactForm = server.forms.getForm('contactus');
contactForm.clear();
res.render('contactus/form', { contactForm: contactForm });
next();
});
server.post('Submit', function (req, res, next) {
var contactForm = server.forms.getForm('contactus');
if (contactForm.valid) {
var Transaction = require('dw/system/Transaction');
var CustomObjectMgr = require('dw/object/CustomObjectMgr');
Transaction.wrap(function () {
var co = CustomObjectMgr.createCustomObject(
'ContactSubmission',
require('dw/util/UUIDUtils').createUUID()
);
co.custom.name = contactForm.name.value;
co.custom.email = contactForm.email.value;
co.custom.message = contactForm.message.value;
});
res.json({ success: true });
} else {
res.json({
success: false,
fields: {
name: contactForm.name.error || null,
email: contactForm.email.error || null,
message: contactForm.message.error || null,
}
});
}
next();
});
module.exports = server.exports();
Best Practices
- Follow the cartridge layering convention -- custom cartridges override base cartridges; use
module.superModule to extend rather than replace controllers
- Use
server.append over server.replace -- appending preserves the original controller logic and other cartridge extensions; replacing breaks the chain
- Wrap all database writes in
Transaction.wrap() -- SFCC requires explicit transactions for all persistent changes; missing transactions cause silent failures
- Use the Script API, not direct database access -- SFCC has no direct SQL access; always use
*Mgr classes (ProductMgr, BasketMgr, OrderMgr) for data operations
- Log with categorized loggers -- use
Logger.getLogger(category, prefix) so log messages can be filtered in Log Center by category
- Never hardcode site-specific values -- use Site Preferences (custom site preferences in Business Manager) for configurable values like API keys and feature flags
- Test with the SFCC sandbox -- always develop and test on a sandbox instance before deploying to staging or production
- Use the SFCC linting rules -- enforce
'use strict' and check for missing next() calls in controllers, which cause request hanging
Common Pitfalls
| Problem |
Solution |
| Controller route not found (404) |
Verify the cartridge is in the cartridge path (Business Manager > Sites > Manage Sites > Cartridges) and the controller file name matches the route |
module.superModule returns null |
Ensure the base cartridge is listed after your custom cartridge in the cartridge path; the order matters |
| Template changes not appearing |
Clear the SFCC template cache in Business Manager (Administration > Sites > Manage Sites > Cache); ISML templates are aggressively cached |
| Custom Object not persisting |
Ensure the operation is inside Transaction.wrap(); check that the Custom Object type is defined in Business Manager (Administration > Site Development > Custom Objects) |
| Job step fails silently |
Return a Status object from every job step; return Status.ERROR on failure so the job framework reports the failure correctly |
ISML <isprint> double-escaping HTML |
Use encoding="off" in <isprint> for trusted HTML content (e.g., CMS body markup); use default encoding for user-generated content |
Related Skills
- @erp-integration
- @ecommerce-caching
- @ecommerce-seo
- @pci-dss-compliance
- @product-data-modeling
1---2name: sfcc-cartridge-development3description: Build SFRA-based Salesforce Commerce Cloud cartridges with controllers, ISML templates, and hooks to customize storefront behavior4---56# SFCC Cartridge Development78## Overview910Build custom cartridges for Salesforce Commerce Cloud (SFCC) using the Storefront Reference Architecture (SFRA), server-side JavaScript controllers, ISML templates, models, and the B2C Commerce Script API. This skill covers cartridge layering and the override mechanism, route handling with `server.js`, form handling, OCAPI/SCAPI integration, and Job Framework usage for scheduled data processing.1112## When to Use This Skill1314- When building a custom feature cartridge that extends SFRA functionality15- When overriding or extending existing SFRA controllers, templates, or models16- When implementing custom checkout steps or payment integrations on SFCC17- When creating scheduled jobs for data import/export (product feeds, order sync)18- When building OCAPI hooks or SCAPI integrations for headless storefronts1920## Core Instructions21221. **Set up the cartridge structure and layering**2324 SFCC uses a cartridge path for layering. Cartridges higher in the path override those lower. A custom cartridge extends `app_storefront_base`:2526 ```27 int_acme_custom/28 ├── cartridge/29 │ ├── controllers/ # Server-side JS controllers30 │ ├── models/ # Data model wrappers31 │ ├── scripts/ # Business logic helpers32 │ ├── templates/33 │ │ └── default/ # ISML templates34 │ ├── forms/35 │ │ └── default/ # Form definitions (XML)36 │ ├── static/37 │ │ └── default/38 │ │ ├── css/39 │ │ └── js/40 │ └── int_acme_custom.properties # Cartridge metadata41 └── package.json42 ```4344 Set the cartridge path in Business Manager:45 ```46 int_acme_custom:app_storefront_base47 ```4849 `int_acme_custom.properties`:50 ```properties51 ## cartridge.properties52 demandware.cartridges.int_acme_custom.multipleLanguageStorefront=true53 ```54552. **Create a server-side controller**5657 Controllers in SFRA use `server.js` for route registration:5859 ```javascript60 // controllers/CustomPage.js61 'use strict';6263 var server = require('server');64 var cache = require('*/cartridge/scripts/middleware/cache');65 var consentTracking = require('*/cartridge/scripts/middleware/consentTracking');6667 /**68 * CustomPage-Show : Renders a custom content page69 * @name CustomPage-Show70 * @function71 * @memberof CustomPage72 * @param {middleware} - server.middleware.https73 * @param {middleware} - consentTracking.consent74 * @param {middleware} - cache.applyDefaultCache75 * @param {querystringparameter} - cid : content asset ID76 * @param {renders} - isml77 * @param {serverfunction} - get78 */79 server.get('Show',80 server.middleware.https,81 consentTracking.consent,82 cache.applyDefaultCache,83 function (req, res, next) {84 var ContentMgr = require('dw/content/ContentMgr');85 var ContentModel = require('*/cartridge/models/content');8687 var contentId = req.querystring.cid;88 var apiContent = ContentMgr.getContent(contentId);8990 if (!apiContent) {91 res.setStatusCode(404);92 res.render('error/notFound');93 return next();94 }9596 var contentModel = new ContentModel(apiContent);9798 res.render('custom/contentPage', {99 content: contentModel,100 breadcrumbs: [101 { htmlValue: 'Home', url: '/' },102 { htmlValue: contentModel.name, url: '' }103 ]104 });105106 next();107 }108 );109110 /**111 * CustomPage-Submit : Handles form POST submissions112 */113 server.post('Submit',114 server.middleware.https,115 function (req, res, next) {116 var Transaction = require('dw/system/Transaction');117 var CustomObjectMgr = require('dw/object/CustomObjectMgr');118119 var form = req.form;120 var name = form.name;121 var email = form.email;122123 // Validate input124 if (!name || !email) {125 res.json({ success: false, error: 'Name and email are required.' });126 return next();127 }128129 try {130 Transaction.wrap(function () {131 var co = CustomObjectMgr.createCustomObject('AcmeSubmissions', email);132 co.custom.name = name;133 co.custom.submittedAt = new Date();134 });135136 res.json({ success: true, message: 'Submission received.' });137 } catch (e) {138 var Logger = require('dw/system/Logger');139 Logger.error('Submission failed: {0}', e.message);140 res.json({ success: false, error: 'An error occurred. Please try again.' });141 }142143 next();144 }145 );146147 module.exports = server.exports();148 ```1491503. **Extend an existing SFRA controller**151152 Use `server.extend` to add or modify routes on an existing controller:153154 ```javascript155 // controllers/Cart.js — extending app_storefront_base Cart156 'use strict';157158 var server = require('server');159 var page = module.superModule; // Reference to the base Cart controller160 server.extend(page);161162 /**163 * Cart-Show : Append custom data to the Cart page164 */165 server.append('Show', function (req, res, next) {166 var viewData = res.getViewData();167168 // Add custom upsell products to the cart page169 var ProductMgr = require('dw/catalog/ProductMgr');170 var ArrayList = require('dw/util/ArrayList');171 var upsells = new ArrayList();172173 var basket = require('dw/order/BasketMgr').getCurrentBasket();174 if (basket) {175 var items = basket.getAllProductLineItems();176 for (var i = 0; i < items.length; i++) {177 var recommendations = items[i].product.getRecommendations();178 for (var j = 0; j < Math.min(recommendations.length, 2); j++) {179 upsells.push(recommendations[j].getRecommendedItem());180 }181 }182 }183184 viewData.upsellProducts = upsells.toArray().slice(0, 4);185 res.setViewData(viewData);186 next();187 });188189 /**190 * Cart-AddCustomItem : New route added to the Cart controller191 */192 server.post('AddCustomItem', function (req, res, next) {193 var BasketMgr = require('dw/order/BasketMgr');194 var Transaction = require('dw/system/Transaction');195 var ProductMgr = require('dw/catalog/ProductMgr');196197 var productId = req.form.pid;198 var quantity = parseInt(req.form.quantity, 10) || 1;199 var product = ProductMgr.getProduct(productId);200201 if (!product || !product.isOnline()) {202 res.json({ error: true, message: 'Product not available.' });203 return next();204 }205206 var basket = BasketMgr.getCurrentOrNewBasket();207 Transaction.wrap(function () {208 var pli = basket.createProductLineItem(productId, basket.getDefaultShipment());209 pli.setQuantityValue(quantity);210 });211212 res.json({ success: true, itemCount: basket.productQuantityTotal });213 next();214 });215216 module.exports = server.exports();217 ```2182194. **Write ISML templates**220221 ```html222 <--- templates/default/custom/contentPage.isml --->223 <isdecorate template="common/layout/page">224225 <isscript>226 var assets = require('*/cartridge/scripts/assets');227 assets.addCss('/css/custom/content.css');228 assets.addJs('/js/custom/content.js');229 </isscript>230231 <div class="container custom-content-page">232 <div class="row">233 <div class="col-12">234 <nav aria-label="Breadcrumb">235 <ol class="breadcrumb">236 <isloop items="${pdict.breadcrumbs}" var="crumb" status="loopstatus">237 <isif condition="${loopstatus.last}">238 <li class="breadcrumb-item active">${crumb.htmlValue}</li>239 <iselse/>240 <li class="breadcrumb-item">241 <a href="${crumb.url}">${crumb.htmlValue}</a>242 </li>243 </isif>244 </isloop>245 </ol>246 </nav>247248 <h1>${pdict.content.name}</h1>249 <div class="content-body">250 <isprint value="${pdict.content.body}" encoding="off"/>251 </div>252 </div>253 </div>254 </div>255256 </isdecorate>257 ```2582595. **Create a data model wrapper**260261 ```javascript262 // models/content.js263 'use strict';264265 var URLUtils = require('dw/web/URLUtils');266267 /**268 * Content model wrapping a dw.content.Content API object269 * @param {dw.content.Content} contentObj - Content API object270 * @constructor271 */272 function ContentModel(contentObj) {273 this.id = contentObj.ID;274 this.name = contentObj.name || contentObj.ID;275 this.body = contentObj.custom.body ? contentObj.custom.body.markup : '';276 this.online = contentObj.online;277 this.url = URLUtils.url('CustomPage-Show', 'cid', contentObj.ID).toString();278 this.pageTitle = contentObj.pageTitle || this.name;279 this.pageDescription = contentObj.pageDescription || '';280 this.pageKeywords = contentObj.pageKeywords || '';281 }282283 module.exports = ContentModel;284 ```2852866. **Build a scheduled job for data processing**287288 ```javascript289 // scripts/jobs/syncInventory.js290 'use strict';291292 var Status = require('dw/system/Status');293 var Logger = require('dw/system/Logger').getLogger('inventory-sync', 'acme');294 var HTTPClient = require('dw/net/HTTPClient');295 var Transaction = require('dw/system/Transaction');296 var ProductInventoryMgr = require('dw/catalog/ProductInventoryMgr');297298 /**299 * Job step: Fetch inventory from external ERP and update SFCC300 * @param {dw.util.HashMap} params - Job step parameters301 * @returns {dw.system.Status} - Job status302 */303 function execute(params) {304 var apiUrl = params.get('apiUrl');305 var apiKey = params.get('apiKey');306 var inventoryListId = params.get('inventoryListId') || 'default';307308 var httpClient = new HTTPClient();309 httpClient.open('GET', apiUrl);310 httpClient.setRequestHeader('Authorization', 'Bearer ' + apiKey);311 httpClient.setRequestHeader('Accept', 'application/json');312 httpClient.setTimeout(30000);313 httpClient.send();314315 if (httpClient.statusCode !== 200) {316 Logger.error('ERP API returned status {0}', httpClient.statusCode);317 return new Status(Status.ERROR, 'API_ERROR', 'ERP API returned ' + httpClient.statusCode);318 }319320 var inventory = JSON.parse(httpClient.text);321 var inventoryList = ProductInventoryMgr.getInventoryList(inventoryListId);322323 if (!inventoryList) {324 return new Status(Status.ERROR, 'LIST_NOT_FOUND', 'Inventory list not found');325 }326327 var updated = 0;328 var errors = 0;329330 inventory.items.forEach(function (item) {331 try {332 Transaction.wrap(function () {333 var record = inventoryList.getRecord(item.sku);334 if (!record) {335 record = inventoryList.createRecord(item.sku);336 }337 record.setAllocation(item.quantity);338 if (item.inStockDate) {339 record.setInStockDate(new Date(item.inStockDate));340 }341 });342 updated++;343 } catch (e) {344 Logger.error('Failed to update SKU {0}: {1}', item.sku, e.message);345 errors++;346 }347 });348349 Logger.info('Inventory sync complete: {0} updated, {1} errors', updated, errors);350 return new Status(Status.OK, 'SYNC_COMPLETE', updated + ' records updated');351 }352353 module.exports.execute = execute;354 ```355356## Examples357358### OCAPI hook for order creation359360```javascript361// hooks/order/ocapiHooks.js362'use strict';363364var Status = require('dw/system/Status');365var Logger = require('dw/system/Logger').getLogger('ocapi-hooks', 'acme');366367/**368 * OCAPI after-POST hook for order creation369 * Called after a new order is placed via OCAPI370 */371exports.afterPOST = function (order) {372 try {373 // Send order data to external analytics374 var HTTPClient = require('dw/net/HTTPClient');375 var httpClient = new HTTPClient();376 httpClient.open('POST', 'https://analytics.acme.com/orders');377 httpClient.setRequestHeader('Content-Type', 'application/json');378 httpClient.send(JSON.stringify({379 orderId: order.orderNo,380 total: order.totalGrossPrice.value,381 currency: order.currencyCode,382 itemCount: order.productLineItems.length,383 customerEmail: order.customerEmail,384 }));385386 if (httpClient.statusCode !== 200) {387 Logger.warn('Analytics push failed for order {0}: HTTP {1}',388 order.orderNo, httpClient.statusCode);389 }390 } catch (e) {391 Logger.error('OCAPI hook error: {0}', e.message);392 }393394 return new Status(Status.OK);395};396```397398Register in `hooks.json`:399```json400{401 "hooks": [402 {403 "name": "dw.ocapi.shop.order.afterPOST",404 "script": "./hooks/order/ocapiHooks"405 }406 ]407}408```409410### Form definition and server-side validation411412```xml413<!-- forms/default/contactus.xml -->414<?xml version="1.0" encoding="UTF-8"?>415<form xmlns="http://www.demandware.com/xml/form/2008-04-19">416 <field formid="name" label="form.contactus.name"417 type="string" mandatory="true" max-length="100"/>418 <field formid="email" label="form.contactus.email"419 type="string" mandatory="true" max-length="254"420 regexp="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"/>421 <field formid="message" label="form.contactus.message"422 type="string" mandatory="true" max-length="2000"/>423 <action formid="submit" label="form.contactus.submit" valid-form="true"/>424</form>425```426427```javascript428// controllers/ContactUs.js429'use strict';430var server = require('server');431432server.get('Show', function (req, res, next) {433 var contactForm = server.forms.getForm('contactus');434 contactForm.clear();435 res.render('contactus/form', { contactForm: contactForm });436 next();437});438439server.post('Submit', function (req, res, next) {440 var contactForm = server.forms.getForm('contactus');441442 if (contactForm.valid) {443 var Transaction = require('dw/system/Transaction');444 var CustomObjectMgr = require('dw/object/CustomObjectMgr');445446 Transaction.wrap(function () {447 var co = CustomObjectMgr.createCustomObject(448 'ContactSubmission',449 require('dw/util/UUIDUtils').createUUID()450 );451 co.custom.name = contactForm.name.value;452 co.custom.email = contactForm.email.value;453 co.custom.message = contactForm.message.value;454 });455456 res.json({ success: true });457 } else {458 res.json({459 success: false,460 fields: {461 name: contactForm.name.error || null,462 email: contactForm.email.error || null,463 message: contactForm.message.error || null,464 }465 });466 }467 next();468});469470module.exports = server.exports();471```472473## Best Practices474475- **Follow the cartridge layering convention** -- custom cartridges override base cartridges; use `module.superModule` to extend rather than replace controllers476- **Use `server.append` over `server.replace`** -- appending preserves the original controller logic and other cartridge extensions; replacing breaks the chain477- **Wrap all database writes in `Transaction.wrap()`** -- SFCC requires explicit transactions for all persistent changes; missing transactions cause silent failures478- **Use the Script API, not direct database access** -- SFCC has no direct SQL access; always use `*Mgr` classes (ProductMgr, BasketMgr, OrderMgr) for data operations479- **Log with categorized loggers** -- use `Logger.getLogger(category, prefix)` so log messages can be filtered in Log Center by category480- **Never hardcode site-specific values** -- use Site Preferences (custom site preferences in Business Manager) for configurable values like API keys and feature flags481- **Test with the SFCC sandbox** -- always develop and test on a sandbox instance before deploying to staging or production482- **Use the SFCC linting rules** -- enforce `'use strict'` and check for missing `next()` calls in controllers, which cause request hanging483484## Common Pitfalls485486| Problem | Solution |487|---------|----------|488| Controller route not found (404) | Verify the cartridge is in the cartridge path (Business Manager > Sites > Manage Sites > Cartridges) and the controller file name matches the route |489| `module.superModule` returns null | Ensure the base cartridge is listed after your custom cartridge in the cartridge path; the order matters |490| Template changes not appearing | Clear the SFCC template cache in Business Manager (Administration > Sites > Manage Sites > Cache); ISML templates are aggressively cached |491| Custom Object not persisting | Ensure the operation is inside `Transaction.wrap()`; check that the Custom Object type is defined in Business Manager (Administration > Site Development > Custom Objects) |492| Job step fails silently | Return a `Status` object from every job step; return `Status.ERROR` on failure so the job framework reports the failure correctly |493| ISML `<isprint>` double-escaping HTML | Use `encoding="off"` in `<isprint>` for trusted HTML content (e.g., CMS body markup); use default encoding for user-generated content |494495## Related Skills496497- @erp-integration498- @ecommerce-caching499- @ecommerce-seo500- @pci-dss-compliance501- @product-data-modeling