Developer guide — Sales Channels
How a backend module integrates with the Sales Channels module: scoping queries by the resolved channel, managing memberships, and reading channel context from request handlers.
Reading the resolved channel inside a route
The resolver middleware decorates every request under /api/v1/* with req.salesChannel (a serialised CachedChannel view of the resolved row). Use the typed helper to keep the access pattern consistent:
import { getResolvedChannel } from '../../kernel/sales-channels/sales-channel-resolver.middleware.js';
app.get('/api/v1/storefront/products', async (request) => {
const channel = getResolvedChannel(request);
return productService.list({ salesChannelId: channel.id });
});
Outside the request lifecycle (background jobs, CLI scripts), call SalesChannelResolverService.getByCode(code) or getSystemDefault() directly through the module's composition handle.
Adding a channel-scoped entity to your module
Channel scoping has two layers:
- Schema — the entity gains a many-to-many relationship to
sales_channelsvia a new bridge tablesales_channel_<entity>(composite primary key on both ids,ON DELETE CASCADEon both sides). Add the table in your module's next migration. - Service — every read path of the entity that should be filtered by channel takes a
salesChannelIdparameter and joins through the bridge table. Every create / update path that lands a new entity callsSalesChannelMembershipService.bindToDefaultIfEmpty(entityType, entity.id)afterpersistAndFlushso newly-created entities default to the system-default channel.
Then add the member to the contract's ChannelMemberEntityTypeSchema enum, and declare the bridge from your own module: export the { entityType, table, entityIdColumn } triple from src/backend/index.ts and register it from a boot hook —
ctx.onBoot(() => {
const { salesChannelBridgeRegistry } = ctx.cradle<YourCradle>();
for (const bridge of salesChannelBridges) salesChannelBridgeRegistry.register(bridge);
});
The bidirectional admin routes then pick it up automatically — no per-module routes needed. The platform deliberately holds no map of the bridges: it used to, total over the enum, which meant a membership call for a member whose module an instance never installed ran SQL against a relation that is not there. A member no module registered now refuses with 503 MODULE_DISABLED before the database is reached, and the enum stays the published vocabulary while the registry decides which members are live.
Mutating memberships
SalesChannelMembershipService is the single mutator for every bridge table. Direct INSERT / DELETE on sales_channel_* from anywhere else is forbidden — the lint rule no-unscoped-channel-query is the safety net (it ships disabled and gets turned on once every existing call site has been threaded).
const result = await membershipService.addToChannel(channelId, 'product', productId);
// result.changed is false on idempotent re-add.
const removed = await membershipService.removeFromChannel(channelId, 'product', productId, {
fallbackToDefault: true,
});
// throws ENTITY_WOULD_HAVE_ZERO_CHANNELS if it would orphan the entity AND fallbackToDefault is false.
The Default channel guarantee
The DefaultChannelReconciler runs at every backend boot from composition.ts (and from test-server.ts for integration tests). Three branches:
- Empty
sales_channelstable — inserts a newdefaultrow sourced fromDEFAULT_SALES_CHANNEL_CODE(env, defaultdefault). - Rows exist but none has
system_default = true— promotes the lexically-first row, with a tie-break preferring the row whosecode = 'default'. - Exactly one row already has
system_default = true— no-op.
The reconciler never demotes, never deletes, and never edits identity. Code outside this module assumes the default exists; if you're writing infra-level code that runs before the reconciler, call DefaultChannelReconciler.run() first.
Optimistic concurrency for identity edits
The version integer column on sales_channels increments by exactly 1 on every successful identity update. The PATCH endpoint requires the client's expectedVersion to match the row's current version, mismatch → HTTP 412 STALE_SALES_CHANNEL_WRITE with the current version in the error envelope so the client can refresh and retry.
Membership add / remove operations are idempotent by construction and do not bump the channel's version.
Audit hooks
Every identity change, lifecycle change, and membership change writes one audit_log_entries row synchronously inside the same transaction. Action codes live in @endora-commerce/contracts:
import { SALES_CHANNEL_AUDIT_ACTIONS } from '@endora-commerce/contracts';
await auditLogService.record({
action: SALES_CHANNEL_AUDIT_ACTIONS.IDENTITY_CHANGED,
// ...
});
Use the constants — never hardcode the strings — so a future enum / type rename ripples cleanly.
Cache invalidation
SalesChannelsCache (process-local LRU + Redis) holds a CachedChannel per code. The cache is invalidated on every identity / lifecycle change via the existing EventBus:
sales_channels.identity_changed→ drops one entry by code.sales_channels.lifecycle_changed→ drops one entry, or all entries wheninvalidateAll: trueis set (used on hard delete).
Both drop the shared Redis entry first and the local one second, with the key marked for the whole operation so a concurrent read cannot re-pin the pre-change channel — and so a failed Redis drop leaves reads falling through to PostgreSQL rather than being served the value the invalidation was meant to remove. The EventBus is in-process, so a second instance converges through the local layer's 30 s window instead.
Membership lookups go through MikroORM directly (no cache layer); if you need them faster, add a Redis layer keyed by (entityType, entityId) with a short TTL — the hooks are already in place.
Testing your channel-aware code
Use the existing setupBackendServer() test harness — it boots the full stack with a fresh default channel reconciled against the test seed (en-US / PLN). For raw-DB-level tests, use setupTestDb() and call DefaultChannelReconciler.run() yourself, optionally overriding the bootstrap defaults if your test seeds different language/currency codes.
The repo's existing precedent for channel-aware integration tests (transactional rollback, parameterised entity types, raw-SQL fixtures decoupled from owning-module entity classes) is in:
backend/test/integration/sales_channels/bidirectional-membership-every-bridge.test.ts— table-driven over all 9 bridges.backend/test/integration/sales_channels/at-least-one-channel-invariant.test.ts— enforcement of the at-least-one-channel invariant.backend/test/contract/sales_channels/admin-membership.contract.test.ts— HTTP-side cover.