SFRA controllers handle requests in a way that will feel familiar to anyone who has used Express or Connect: route registration, middleware chains, request and response objects. The familiar shape masks B2C Commerce specifics that matter for production code. The extension patterns (append, prepend, replace) are particular to SFRA, not standard middleware. Getting them wrong produces controllers that look composable but actually break when a downstream cartridge tries to extend them.
Sapota's Salesforce team has shipped controllers across SFRA sites of varying sizes. The patterns below are what we apply during code review on B2C Commerce engagements.
The basic controller shape
A simple SFRA controller (cartridges/app_storefront_base/cartridge/controllers/Account.js):
'use strict';
var server = require('server');
server.get('Login', server.middleware.https, function (req, res, next) {
res.render('account/login');
next();
});
server.post(
'Authenticate',
server.middleware.https,
function (req, res, next) {
var formInfo = res.getViewData();
// ... auth logic ...
res.json({ success: true });
next();
}
);
module.exports = server.exports();
Three things to internalize:
1. server.get and server.post register routes. The first argument is the route name (URL path segment). The platform builds the full URL as /Account-Login from the controller name plus route name.
2. Middleware is a function with (req, res, next) signature. Built-in middleware like server.middleware.https enforces HTTPS. Custom middleware composes additional logic. next() calls the next middleware in the chain.
3. res.render returns a template, res.json returns JSON, res.redirect returns a redirect. The response is set up across middleware; the platform sends it after the last next().
The extension patterns
SFRA's killer feature is the ability for downstream cartridges to extend base controllers without modifying the base. Three methods on server:
server.append. Add behavior after the existing route's middleware chain. Used to enrich the response data or perform side effects after the base logic.
server.prepend. Add behavior before the existing route's middleware chain. Used to set up state or check preconditions before the base logic runs.
server.replace. Replace the existing route entirely. Used when the new behavior is fundamentally different from the base.
A typical custom cartridge extending base account login:
'use strict';
var server = require('server');
server.extend(module.superModule);
server.append('Login', function (req, res, next) {
// Add a custom analytics event after base login renders.
var viewData = res.getViewData();
viewData.customAnalyticsFlag = true;
res.setViewData(viewData);
next();
});
module.exports = server.exports();
The pattern: require server, extend module.superModule (which the platform sets to the same-named controller from the cartridge below in the path), append, prepend, or replace as needed.
server.replace is the heavy hammer. Use it only when the base behavior is genuinely wrong for the use case. Most customizations are append or prepend; they preserve the base logic and add to it.
Middleware composition
Middleware functions can be chained on a single route:
server.get(
'Show',
server.middleware.https,
csrfMiddleware.validateRequest,
cache.applyShortPromotionSensitiveCache,
function (req, res, next) {
// Main controller logic.
res.render('product/productDetails', {
product: ProductModel.get(req.querystring.pid)
});
next();
}
);
Each middleware function runs in order. Any function can:
- Set state on
req (e.g., req.currentCustomer)
- Modify the response (e.g., set cookies, add headers)
- Short-circuit the chain by NOT calling
next()
- Throw an error to skip to the error handler
Built-in middleware to know:
server.middleware.https enforces HTTPS; redirects HTTP to HTTPS.
server.middleware.include declares the controller as an include-only target (cannot be hit directly via URL).
csrfProtection.validateAjax checks CSRF token on AJAX requests.
cache.applyDefaultCache and applyShortPromotionSensitiveCache set caching headers.
ViewData and the model pattern
Controllers populate viewData and the template renders it. The flow:
server.get('Show', function (req, res, next) {
var ProductFactory = require('*/cartridge/scripts/factories/product');
var productModel = ProductFactory.get({ pid: req.querystring.pid });
res.render('product/productDetails', {
product: productModel,
metaTitle: productModel.name,
canonicalUrl: URLUtils.url('Product-Show', 'pid', productModel.id).abs()
});
next();
});
The res.render call passes the data shape the template expects. The model handles shaping the underlying API objects (product, customer, cart) into template-friendly structures.
Two patterns that matter:
1. Model in factory. The factory pattern (scripts/factories/product) wraps the platform's dw.catalog.ProductMgr.getProduct(pid) and returns a clean model. The controller delegates; the model isolates the API surface from the template.
2. ViewData immutable in the route, mutable in extensions. The base controller sets viewData. Extensions in downstream cartridges read it via res.getViewData(), mutate it, and write it back via res.setViewData(viewData). This pattern allows multiple extensions to compose without conflicts.
Error handling
Controllers can throw or return errors. The platform handles them:
server.get('Show', function (req, res, next) {
try {
var productModel = ProductFactory.get({ pid: req.querystring.pid });
if (!productModel) {
res.setStatusCode(404);
res.render('error/notFound');
return next();
}
res.render('product/productDetails', { product: productModel });
} catch (e) {
var logger = require('dw/system/Logger');
logger.error('Product show error: {0}', e.message);
res.setStatusCode(500);
res.render('error/serverError');
}
next();
});
Three principles:
1. Log every caught exception. With context. Errors silently swallowed produce production incidents with no audit trail.
2. Return specific status codes. 404 for not-found, 400 for validation, 500 for server error. Generic 200 with an error page hurts caching and analytics.
3. Render error templates, not raw stack traces. Users see a branded error page. Stack traces go to logs.
Sapota's controller conventions
The patterns that hold up:
Thin controllers, fat models. Controller responsibilities: route registration, middleware composition, data passing to templates. Business logic lives in models or services. Controllers over 100 lines warrant refactor.
Resource keys for error messages. Same as forms: no hardcoded strings.
Test coverage with the SFCC unit test framework or Mocha against mocked dw modules. Controllers can be tested by mocking req, res, and the dw API objects.
Append over replace. Almost every customization should be append or prepend. Replace is a code review red flag that requires justification.
Documentation of why each middleware is present. A controller with 6 middleware functions in the chain needs a comment explaining why each one is there. Future maintainers (and the original author 6 months later) will thank you.
Common controller mistakes
Five patterns Sapota has seen on engagements:
1. Business logic inline in controller. A 200-line controller computing prices, applying promotions, querying inventory. Move to models and services. Controller orchestrates; models compute.
2. Replace instead of append. Replacing the base Login controller entirely to add one analytics call. The replace loses every base improvement Salesforce ships in future releases. Use append.
3. No error handling. A controller that lets exceptions bubble to the framework's default handler. Generic 500 page, no logs, no diagnosis. Wrap try/catch, log, render specific error templates.
4. Mutating viewData directly without setViewData. A custom append modifies viewData.product.name. The mutation does not persist across the chain. Always setViewData after mutation.
5. Caching headers misapplied. A controller with cache.applyDefaultCache on a customer-specific page. The cached response leaks across users. Use applyShortPromotionSensitiveCache or no cache on customer-personalized content.
What good controller architecture looks like
A B2C Commerce SFRA site with healthy controllers:
- Thin controllers (typically under 100 lines).
- Models and services do business logic.
- Append/prepend for customizations; replace only with justification.
- Middleware composition documented inline.
- Error handling specific to the failure type.
- Caching directives matched to content sensitivity.
- Test coverage including extension chains.
Sapota's Salesforce team applies these patterns as code review gating criteria on every B2C Commerce engagement. The SFRA controller model is one of the parts of B2C Commerce most easily over-customized; the right discipline keeps the codebase upgradeable.
Building or refactoring SFRA controllers in B2C Commerce? Sapota's Salesforce team, certified on B2C Commerce Developer (Comm-Dev-101), handles controller architecture, middleware composition, and extension strategy on production engagements. Get in touch ->
See our full platform services for the stack we cover.