Feature Flags
@modularityjs/feature-flags defines an abstract FeatureFlagsService for runtime toggles and multivariate experiments. Drivers fetch flag definitions in the background and evaluate them locally per-call so apps can ask flags.isEnabled('new-checkout') from controllers and constructors without await.
Contract
interface FeatureIdentity {
id: string;
attributes?: Record<string, unknown>;
}
interface FeatureContext {
identity?: FeatureIdentity;
attributes?: Record<string, unknown>; // request-derived overrides
}
abstract class FeatureFlagsService {
abstract isEnabled(key: string, context?: FeatureContext): boolean;
abstract variant<T = string>(
key: string,
context?: FeatureContext,
): T | undefined;
abstract all(context?: FeatureContext): Record<string, unknown>;
}FeatureIdentity is structurally compatible with AuthIdentity — apps wiring @modularityjs/http-auth can pass the auth identity directly. FeatureContext.attributes lets callers add request-time data (geo, device, A/B cohort) — request attributes win over identity attributes on key collision so request data overrides per-user data.
Unknown flags return false / undefined — safe defaults so deploying code ahead of its toggle doesn't crash.
Drivers
Static (@modularityjs/feature-flags-static)
Flags from a config object — no network, no targeting. Useful for tests, dev toggles, and apps that want flags driven by their existing config pipeline.
import { FeatureFlagsModule } from '@modularityjs/feature-flags';
import { FeatureFlagsStaticModule } from '@modularityjs/feature-flags-static';
const modules = [
FeatureFlagsModule,
FeatureFlagsStaticModule.forRoot({
flags: {
'new-checkout': true, // boolean shorthand
'banner-color': { enabled: true, variant: 'green' },
'paused-feature': { enabled: false },
},
}),
];isEnabled returns the boolean; variant returns the variant value when enabled (and undefined when disabled, even if a variant is configured); all() returns the variant for each enabled flag (or true when no variant is configured) and false for disabled ones — handy for SSR bootstrap.
GrowthBook (@modularityjs/feature-flags-growthbook)
Wraps the @growthbook/growthbook SDK. Fetches feature definitions from a GrowthBook backend (or self-hosted proxy) at boot and evaluates locally — supports targeting by attributes, percentage rollouts, and A/B experiments.
import { FeatureFlagsGrowthbookModule } from '@modularityjs/feature-flags-growthbook';
const modules = [
FeatureFlagsModule,
FeatureFlagsGrowthbookModule.forRoot({
apiHost: 'https://cdn.growthbook.io',
clientKey: process.env.GROWTHBOOK_KEY!,
}),
];Other config options:
| Field | Use case |
|---|---|
decryptionKey | When the GrowthBook payload is encrypted (paid plans / strict mode). |
bootstrapFeatures | Skip the network fetch and pass pre-loaded definitions (tests, SSR). |
clientFactory | Build a fully configured GrowthBook instance (custom transport, plugins). |
For tests, bootstrapFeatures or clientFactory skips the network entirely:
FeatureFlagsGrowthbookModule.forRoot({
bootstrapFeatures: {
'new-checkout': { defaultValue: true },
'pro-only': {
defaultValue: false,
rules: [{ condition: { tier: 'pro' }, force: true }],
},
},
});HTTP Bridge
@modularityjs/http-feature-flags registers two parameter decorators that resolve a FeatureContext from the request and call into FeatureFlagsService:
import { Controller, Get, Post } from '@modularityjs/http';
import { Flag, Variant } from '@modularityjs/http-feature-flags';
@Controller('/checkout')
class CheckoutController {
@Post('/')
async checkout(@Flag('new-checkout') enabled: boolean) {
if (!enabled) {
throw new NotFoundException('legacy checkout disabled, see /v2/checkout');
}
// …
}
@Get('/dashboard')
async dashboard(@Variant('layout') layout?: 'classic' | 'beta') {
return layout === 'beta' ? this.renderBeta() : this.renderClassic();
}
}Identity comes from request.auth if @modularityjs/http-auth is wired — the extension reads it via the same key setRequestAuth writes to, with no peer-dep on http-auth. Apps without auth get an undefined identity and rely on attributes alone.
getFeatureContext(request) is also exported for manual context construction:
import { getFeatureContext } from '@modularityjs/http-feature-flags';
@Get('/promo')
async promo(@Inject(FeatureFlagsService) flags: FeatureFlagsService, @Request() req) {
const ctx = getFeatureContext(req);
if (flags.isEnabled('seasonal-promo', ctx)) { /* … */ }
}Identity-Aware Targeting
Combine with http-auth for per-identity rollouts. The auth extension writes the identity to the request before route handlers run; the feature-flags extension picks it up automatically.
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
HttpAuthModule,
HttpAuthJwtModule,
AuthJwtModule.forRoot({ secret: process.env.JWT_SECRET! }),
FeatureFlagsModule,
FeatureFlagsGrowthbookModule.forRoot({
apiHost: 'https://cdn.growthbook.io',
clientKey: process.env.GROWTHBOOK_KEY!,
}),
HttpFeatureFlagsModule,
];In GrowthBook, define a feature with a targeting rule:
{
"key": "pro-only",
"defaultValue": false,
"rules": [{ "condition": { "tier": "pro" }, "force": true }]
}The extension passes identity.attributes.tier through to GrowthBook's evaluator — and the JWT identity contributes those attributes via auth-jwt's claim mapping.
Failure Modes and Tuning
| Concern | Behaviour |
|---|---|
| Backend unreachable at boot | The driver's onInit rejects → createApp() throws — app never starts with stale flags. Use bootstrapFeatures to start with a known set. |
| Backend goes down at runtime | Continue evaluating against last-fetched definitions. No request-time degradation. |
| Flag never defined in the backend | Returns false / undefined (safe default). |
isEnabled called before onInit completes | Returns false (the driver has no definitions yet). |
| Variant value type mismatch | TypeScript narrows via the variant<T>(...) generic; runtime values are unchecked — apps validate. |
Choosing a Driver
| Stage | Driver |
|---|---|
| Tests, local dev, simple env-driven flags | feature-flags-static |
| Production with targeting / experiments | feature-flags-growthbook |
(Future: feature-flags-unleash, feature-flags-posthog) | — |
Apps can register multiple modules — the last preferences: wins per the framework's standard last-wins rule. In practice you pick one driver per environment.