A thing I kept running into while building StoreFlux (a multi-tenant commerce backend): the moment you add enough optional features, you start wanting some way to gate them per tenant. The usual answer is a feature-flag system or a plan-tier check scattered across controllers. I didn't want either, so here's what we actually built instead.
The rule we set first
Core commerce — products, orders, customers, payments, inventory — is never behind a flag, a plan, or a tier. If a tenant can sign up, they get all of that, full stop. Everything else lives in a short, closed list of optional modules: realtime, AI, loyalty, referrals, B2B, coupons, gift cards, banners, SEO, social ads, carriers, API keys, webhooks. Thirteen of them right now.
Every module ships off by default for every tenant. A SuperAdmin turns one on for a specific tenant — there's no self-serve upgrade flow yet, it's deliberate and manual. The catalog itself is a single source-of-truth file mapping module keys to names/descriptions, not scattered constants.
Enforcement, not just UI hiding
The thing I didn't want was a system where the backend trusts the frontend to hide a module and calls it done — that's not gating, that's decoration. So enforcement happens as an authorization attribute on the actual API controllers. Roughly 20 endpoints across the admin API carry it. If a tenant doesn't have a module enabled and somehow hits that endpoint anyway (stale UI, direct API call, whatever), they get a 403 with a specific MODULE_DISABLED code, not a generic permission error.
Two cases bypass the check entirely: SuperAdmin requests (they need to reach anything to manage it) and anonymous/public requests (storefront-facing endpoints aren't gated this way — that's a separate, not-yet-solved problem, more below).
Where the enabled-set actually lives
Tenant.EnabledModules is a jsonb column — an array of the module keys that are on. Checking it on every request would mean a database hit per request, so there's a small service that caches the enabled-set in Redis for 60 seconds per tenant, invalidated immediately whenever a SuperAdmin changes the set. 60 seconds felt like a reasonable tradeoff between "changes take effect fast enough to matter" and "we're not hammering Redis on every single request."
The frontend side has to fail open, not closed
This is the detail I almost got backwards. The admin panel fetches the tenant's enabled modules once on load and uses it to filter the sidebar nav and guard routes. My first instinct was: if that fetch fails, hide everything, safe default. Wrong instinct — if a flaky network blip makes that call fail, you don't want the entire optional-features section of someone's store to vanish on them. So it fails open: a failed fetch just means nothing gets filtered out client-side, and the real enforcement is still sitting on the backend regardless. The frontend check is a UX nicety, not the security boundary — the API attribute is.
What's still unsolved
Storefront-facing pages (the actual customer-visible store, not the admin panel) don't check module state at all yet. If a tenant has, say, loyalty disabled, the admin UI correctly hides it — but a loyalty widget baked into their storefront theme would still render, because the storefront controller is anonymous and currently ungated for this purpose. Fixing that needs resolving tenant identity from the store's slug inside an otherwise-anonymous request path, which I haven't done yet. Flagging it here mostly because "ship it, write it down, fix it later" felt more honest than pretending it's already solved.
Curious if anyone else has built a similar opt-in-module system for a multi-tenant product — specifically interested in how you handled the storefront/anonymous-path version of this same gating problem, since that's the part I don't have a clean answer for yet.
Top comments (0)