Implicit Runtime Resolution in the Cartridge Model in Salesforce Commerce Cloud
How the Cartridge Path Actually Resolves
When a request hits an SFCC instance, the platform resolves which file to execute by walking the cartridge path left to right, looking for the first match at the requested file path. This happens at the application server level, per request, at runtime.
Concretely, say the cartridge path is:
app_custom_sharkninja : int_payment_adyen : int_analytics : app_storefront_base
A request to /Cart-Show triggers the runtime to look for controllers/Cart.js in this order:
app_custom_sharkninja/cartridge/controllers/Cart.js— found? Use it. Stop.int_payment_adyen/cartridge/controllers/Cart.js— found? Use it. Stop.int_analytics/cartridge/controllers/Cart.js— found? Use it. Stop.app_storefront_base/cartridge/controllers/Cart.js— found? Use it. Stop.
There is no manifest, no import statement, no dependency declaration, and no build step that records which cartridge won. The resolution is the filesystem lookup at request time. The platform doesn’t log which cartridge provided the file unless you enable request-level tracing (which has its own performance cost and isn’t typically on in production).
Why “Implicit” Is the Precise Problem
No static analysis is possible. In a Node.js or Java project, when module A imports module B, that relationship is visible in source code. A tool (compiler, bundler, IDE, linter) can trace the dependency graph, detect circular dependencies, flag missing modules, and tell you exactly which code will execute when a function is called. In the cartridge model, the “import” is the absence of a file in a higher-priority cartridge, causing fallthrough to a lower-priority one. You can’t grep for this. You can’t write a lint rule for it. The dependency is expressed as “this file doesn’t exist here, so the platform will find it somewhere else” — a negative assertion that no static tool can reason about without reconstructing the cartridge path resolution logic externally.
The override is invisible at the call site. When app_storefront_base/controllers/Cart.js calls a helper function via require('*/cartridge/scripts/helpers/cartHelpers'), that * wildcard is SFCC’s module resolution syntax — it means “resolve this through the cartridge path.” The controller author doesn’t know (and can’t know from reading the code) whether cartHelpers will come from app_storefront_base, app_custom_sharkninja, or int_payment_adyen. The behavior depends entirely on which cartridges are in the path and in what order, which is configuration state in Business Manager — not in the codebase. A developer reading the controller in their IDE sees a require statement that resolves to a conceptual path, not an actual file. They must mentally (or manually) walk the cartridge path to know which implementation they’re calling.
Template resolution compounds the problem. ISML templates use <isinclude template="components/header/pageHeader"/> — again, resolved via cartridge path at render time. A controller in one cartridge can render a template that physically lives in a different cartridge, which itself includes a decorator from a third cartridge. The rendering chain crosses cartridge boundaries invisibly. When the page renders incorrectly, you’re reverse-engineering a call chain that was never declared anywhere — it emerged from the cartridge path ordering and the filesystem layout across multiple directories.
The module.superModule pattern makes it worse. SFRA introduced module.superModule as a way to extend a controller rather than fully replacing it. The pattern looks like:
// In app_custom_sharkninja/cartridge/controllers/Cart.js
var base = module.superModule;
server.extend(base);
server.append('Show', function (req, res, next) {
// Custom logic layered on top of the base Cart-Show
var viewData = res.getViewData();
viewData.customAttribute = 'something';
res.setViewData(viewData);
next();
});
module.exports = server.exports();
This looks clean in isolation. The problem is that module.superModule resolves to “the next cartridge in the path that has this file.” If int_payment_adyen also has a Cart.js that uses module.superModule, you now have a chain: app_custom_sharkninja → int_payment_adyen → app_storefront_base, where each layer appends or prepends behavior to the route handler. The execution order of the append/prepend hooks depends on the cartridge path order. Reorder the path — which might happen because a different integration requires a specific position — and the execution sequence of these hooks changes silently. There’s no test that catches this without exercising the full request path end-to-end on an instance configured with the exact production cartridge path.
Business Manager configuration as invisible coupling. Beyond code, cartridges depend on Business Manager site preferences, custom object definitions, service configurations, and content slot configurations. These are mutable state in the BM database, not declared in cartridge code. A vendor cartridge’s README might say “create a site preference called AdyenMerchantAccount with value X” — but that preference lives in BM, not in the cartridge. If someone changes it, no version control system records the change. If a different cartridge reads the same preference (or one with a naming collision), there’s no encapsulation. The cartridge model provides no scoping mechanism for configuration — everything is global, everything is mutable, and the only record of who depends on what is documentation that may or may not exist.
What “Compile-Time Visible” Looks Like by Contrast
In a typed service architecture, dependencies are explicit at every level:
// OrderService depends on InventoryService — declared in code, visible to compiler
import { InventoryClient } from '@sharkninja/inventory-client';
export class OrderService {
constructor(private inventory: InventoryClient) {} // injected, testable, mockable
async placeOrder(cart: Cart): Promise<Order> {
const reserved = await this.inventory.reserve(cart.lineItems);
if (!reserved.success) {
throw new InsufficientInventoryError(reserved.failures);
}
// ...
}
}
The dependency on InventoryClient is in the source code. The TypeScript compiler verifies the interface contract at build time. If InventoryClient changes its method signature, every consumer fails to compile. The IDE shows you exactly which implementation you’re calling. A dependency graph tool (like madge or nx graph) can map every service-to-service dependency in the system. There is no hidden resolution path, no ordering-dependent behavior, no runtime surprise.
At the API boundary between services, the contract is an OpenAPI spec or a protobuf definition — versioned, diffable, and enforceable via contract testing (Pact, Buf breaking-change detection for proto). If the Inventory Service changes its response schema, the contract test fails in CI before the change reaches production. The equivalent scenario in SFCC — a vendor cartridge changing its helper function signature — is discovered when the storefront throws a runtime error on a customer’s browser.
Why the Service Architecture Is More Maintainable at Year Three
The claim isn’t that microservices are inherently simpler — they’re not. The claim is that the debt profile is different, and the service architecture’s debt is manageable in ways the cartridge model’s debt is not.
Debt Visibility
In a service architecture, when something breaks, the blast radius is traceable. A distributed trace (OpenTelemetry) shows you: request entered the BFF, BFF called Catalog Service (200, 45ms), called Pricing Service (200, 12ms), called Inventory Service (500, timeout). You know exactly which service failed, which API call failed, what the request payload was, and what the error response was. The debugging path is linear: find the failing service, read its logs, identify the root cause.
In the cartridge model at year three, when something breaks: a page renders incorrectly. Which cartridge’s controller handled the request? Check the cartridge path. Which template rendered? Walk the <isinclude> chain across cartridges. Which helper function returned the wrong data? Trace the require('*/...') resolution across the cartridge path. Did a Business Manager configuration change? Check the audit log (if it exists and if the relevant configuration is audited). Did a vendor cartridge update change a shared helper’s behavior? Diff the vendor cartridge against the previous version, if you kept a copy. Each debugging session is an archaeology exercise in a codebase where the execution path was never explicitly declared.
Change Isolation
Service architecture year three scenario: you need to change how promotional pricing is calculated. You modify the Pricing Service. It has its own repository, its own test suite, its own deploy pipeline. You run its unit and integration tests, deploy to staging, run contract tests against its consumers (BFF, Cart Service), validate, and deploy to production. The Catalog Service, Inventory Service, and Order Service are untouched — they don’t know pricing changed because the API contract didn’t change.
Cartridge model year three scenario: you need to change how promotional pricing is calculated. The pricing logic is in app_storefront_base/cartridge/scripts/helpers/pricing.js, but you overrode it in app_custom_sharkninja two years ago. The vendor cartridge for the loyalty program also hooks into pricing via a module.superModule chain. Changing the pricing logic means understanding the three-cartridge chain, testing the interaction between your override and the loyalty cartridge’s extension, and deploying the entire cartridge stack as a unit (because there’s no independent deployment of a single cartridge — you activate a code version that includes all cartridges). The blast radius of a pricing change is the entire storefront.
Team Scaling
Service architecture: at year three, you have 12 engineers. You can assign 2-person teams to own specific services. The Cart team owns cart logic, cart tests, cart deploys. The Search team owns the search index, search relevance tuning, search deploys. Teams work in parallel with minimal coordination because the interface contracts are the coordination mechanism. A team can refactor their service’s internals without coordinating with other teams as long as the API contract holds.
Cartridge model: at year three, you have 12 engineers. They all work in the same cartridge codebase (or a small set of cartridges). Every feature branch potentially touches controllers, templates, and helpers that other branches also touch. Merge conflicts are structural, not incidental — because the override model means customization concentrates in the same set of overridden files. The Cart.js controller that three teams need to modify is a serialization point. You either take turns (slow) or merge frequently (conflict-heavy). There’s no way to decompose the work into independent streams because the cartridge model doesn’t provide real modularity boundaries — it provides a file-override mechanism that collapses into a single execution context at runtime.
Upgrade Path
Service architecture: at year three, you need to upgrade the search engine from Elasticsearch 7 to OpenSearch 2. You modify the Search Service’s infrastructure and its client library. Reindex, validate relevance, deploy. No other service is affected because they consume search results via the Search Service’s API, not via a direct Elasticsearch dependency.
Cartridge model: at year three, Salesforce releases SFRA 7.x with security patches and new features. You’re on SFRA 5.x because the 6.x upgrade was deferred twice — it required re-merging 40+ overridden files, two vendor cartridges were incompatible with the new version, and the QA cycle for a full regression was estimated at three weeks. You’re now two major versions behind the reference architecture, accumulating security exposure and missing platform features. Each deferred upgrade makes the next one harder because the diff between your overrides and the new base grows monotonically. This is the specific debt compounding mechanism that makes the cartridge model unsustainable at scale — the cost of staying current increases over time rather than staying constant.
Failure Mode Comparison
At year three, the service architecture’s failure mode is operational complexity — more services to monitor, more deploys to coordinate for cross-cutting changes, more infrastructure to maintain. These are real costs, but they’re visible, measurable, and addressable with tooling and process (better observability, deploy automation, service mesh).
The cartridge model’s failure mode is invisible coupling and frozen architecture — the team can’t confidently change the system because the interaction effects between cartridges are undocumented and only discoverable at runtime. The response is organizational: change velocity drops, estimates inflate with “impact analysis” buffers, the platform becomes something the team maintains rather than evolves. New features route around the core platform (”let’s build that in a microservice that calls OCAPI”) rather than extending it, creating a shadow architecture that further increases the total system complexity while leaving the cartridge debt untouched.
That shadow architecture pattern — where the team builds new capabilities outside the platform because modifying the platform is too risky — is the clearest signal that the cartridge model’s debt has reached a tipping point. And it’s extremely common in mature SFCC implementations. You end up with the worst of both worlds: a monolithic cartridge stack you’re afraid to touch, plus a constellation of external services that duplicate state and logic because they can’t safely extend the monolith.
The Core Asymmetry
The service architecture’s costs are upfront and linear — you pay to build each service, you pay to operate each service, and those costs are roughly constant per service over time.
The cartridge model’s costs are deferred and exponential — initial development is fast (override a file, add a cartridge, done), but the cost of every subsequent change increases as the override graph grows, vendor cartridges accumulate, and the distance from the base reference architecture widens. By year three, the accumulated cost often exceeds what the service architecture would have cost to build and operate from scratch — and unlike the service architecture, there’s no clean path to reduce it without a replatform.
That’s the structural argument. The cartridge model doesn’t just create debt — it creates debt with a compounding interest rate that accelerates as the system matures.


