Skip to content

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

typescript
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.

typescript
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.

typescript
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:

FieldUse case
decryptionKeyWhen the GrowthBook payload is encrypted (paid plans / strict mode).
bootstrapFeaturesSkip the network fetch and pass pre-loaded definitions (tests, SSR).
clientFactoryBuild a fully configured GrowthBook instance (custom transport, plugins).

For tests, bootstrapFeatures or clientFactory skips the network entirely:

typescript
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:

typescript
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:

typescript
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.

typescript
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:

json
{
  "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

ConcernBehaviour
Backend unreachable at bootThe driver's onInit rejects → createApp() throws — app never starts with stale flags. Use bootstrapFeatures to start with a known set.
Backend goes down at runtimeContinue evaluating against last-fetched definitions. No request-time degradation.
Flag never defined in the backendReturns false / undefined (safe default).
isEnabled called before onInit completesReturns false (the driver has no definitions yet).
Variant value type mismatchTypeScript narrows via the variant<T>(...) generic; runtime values are unchecked — apps validate.

Choosing a Driver

StageDriver
Tests, local dev, simple env-driven flagsfeature-flags-static
Production with targeting / experimentsfeature-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.