Architecture

Prev Next

Feature Flags Architecture

This document explains how Feature Flags work internally — tracing the path from flag declaration to UI consumption.

Core Principle

Code defines what features can exist.
Remote tools only suggest values.

The FeatureRegistry in core/registry.ts is the single source of truth. GrowthBook (or any provider) cannot introduce new features, change defaults, or affect behavior unless the code explicitly allows it.

Architectural Layers

Image

Each layer has a single responsibility and cannot bypass the layer below it.

Layer 1: Feature Registry (Allow-list)

File: core/registry.ts

The registry is a TypeScript object declaring every valid feature flag:

export const FeatureRegistry = {
  'enable-google-signin': {
    key: 'enable-google-signin',
    default: true,
    owner: 'identity',
    lifecycle: 'capability',
    description: 'Enable Google Sign-In option on the login page',
    exposure: 'server',
  },
  // ...
} satisfies FeatureFlagRegistry;

Each flag definition includes:

Field Type Purpose
key string Globally unique identifier (must match object key)
default boolean | string | number Safe fallback when provider is unavailable
owner string Team or domain that owns the flag
lifecycle 'release' | 'capability' | 'experiment' | 'kill' Governance category
exposure 'server' | 'client' Where the flag may be consumed
description string Human-readable intent

Helper exports:

  • FeatureFlagKeys — array of all registered flag keys
  • getFeatureDefinition(key) — lookup with explicit error on unknown keys

Invariant: if a flag is not in this object, it does not exist for the platform.

Layer 2: Feature Provider (Adapter)

Interface: providers/provider.interface.ts

interface FeatureProvider {
  evaluate(key: string, context: FeatureContext, fallback: FeatureFlagValue): Promise<FeatureFlagValue>;
}

Providers are replaceable adapters. The contract requires:

  • Deterministic results for a given (key, context) pair
  • Must never throw on missing flags
  • Must always fall back to the provided default on failure

GrowthBook Provider

Files: providers/growthbook/provider.ts, providers/growthbook/client.ts

The production provider. createGrowthBookClient() initializes the SDK:

  • Reads GROWTHBOOK_API_HOST and GROWTHBOOK_CLIENT_KEY from config
  • Calls client.init({ streaming: true, skipCache: true })
  • Returns null if config is missing (triggers Noop fallback)
  • Marked server-only — cannot be imported on the client

GrowthBookFeatureProvider.evaluate() calls growthbook.setAttributes(context) then growthbook.getFeatureValue(key, fallback). Errors are caught and return the fallback.

Noop Provider

File: providers/noop/provider.ts

Used when no external system is configured. Always returns the fallback value:

async evaluate(_key, _context, fallback) {
  return fallback;
}

This ensures the app works with zero external dependencies.

Layer 3: Feature Resolver (Enforcement)

File: runtime/resolver.ts

The resolver is the enforcement point. resolveFeatures() takes the registry, a provider, and a context:

async function resolveFeatures(
  registry: FeatureFlagRegistry,
  provider: FeatureProvider,
  context: FeatureContext
): Promise<ResolvedFeatures>;

For each registry-declared flag, it:

  1. Queries the provider — wrapped in try/catch, falls back to definition.default on error
  2. Enforces kill-switch dominance — if lifecycle === 'kill', forces value = (value === true)
  3. Type-guards the result — if typeof value !== typeof definition.default, falls back to default
  4. Freezes the outputObject.freeze(resolved) makes the snapshot immutable

Image

Invariants:

  • Only registry-declared flags are evaluated — remote-only flags are ignored
  • Provider failures always fall back to defaults
  • Kill switches always win
  • Returned object is immutable

Layer 4: UI Mapper (Intent Projection)

File: runtime/uiMapper.ts

The mapper converts infrastructure flag keys into UI-intent booleans:

interface UIFeatureFlags {
  showGoogleSignin: boolean;
  showMicrosoftSignin: boolean;
  enableParallelChunkProcessing: boolean;
}

function mapToUIFeatures(flags: ResolvedFeatures): UIFeatureFlags {
  return {
    showGoogleSignin: flags['enable-google-signin'] === true,
    showMicrosoftSignin: flags['enable-microsoft-signin'] === true,
    enableParallelChunkProcessing: flags['enable-parallel-chunk-processing'] === true,
  };
}

This layer:

  • Hides raw flag keys from the UI
  • Renames flags to UX-focused concepts (enable-google-signinshowGoogleSignin)
  • Strict-equals === true — non-boolean values or undefined resolve to false
  • Security-sensitive flags never leak to client

Delivery Pipeline

API Route

File: src/app/api/platform/features/route.ts

GET /api/platform/features

The route handler:

  1. Reads siteId, site, and id cookies from the request
  2. Builds a FeatureContext — anonymous { env } if cookies are missing, or { userId, siteId, site, env } if present
  3. Creates a GrowthBook client (or falls back to Noop)
  4. Calls resolveFeatures(FeatureRegistry, provider, context)
  5. Maps through mapToUIFeatures()
  6. Returns UIFeatureFlags as JSON

Service Layer

File: src/services/FeatureFlagService.ts

class FeatureFlagService {
  static async list(): Promise<UIFeatureFlags> {
    // Fetches from /api/platform/features via AsyncHandler
    // Falls back to {} on error
  }
}

React Integration

File: src/context/FeatureFlagContext.tsx

// Root layout (src/app/layout.tsx)
const featureFlags = await FeatureFlagService.list();
<FeatureFlagContext features={featureFlags}>
  {children}
</FeatureFlagContext>

// Any client component
const { showGoogleSignin } = useFeatureFlagContext();

The context provider is a memoized component. useFeatureFlagContext() throws an AppError if called outside the provider.

Context-Based Resolution

Feature flags are resolved based on available context, not application pages.

Anonymous (Pre-auth)

When cookies are missing:

{
  env: 'production';
}

Authenticated (Post-auth)

When cookies are present:

{ userId: 'u_123', siteId: 's_456', site: 'example', env: 'production' }

Missing context automatically results in fallback behavior — the resolver does not distinguish between these phases.

Failure Model

Image

At every failure point, the system degrades gracefully to registry defaults. The application never crashes due to feature flag infrastructure.

Type System

All types are defined in core/types.ts:

type FeatureFlagValue = boolean | string | number;
type FeatureFlagLifecycle = 'release' | 'capability' | 'experiment' | 'kill';
type FeatureFlagExposure = 'server' | 'client';
type FeatureContext = Record<string, string | number | boolean>;
type ResolvedFeatures = Readonly<Record<string, FeatureFlagValue>>;

interface FeatureFlagDefinition<T extends FeatureFlagValue> {
  key: string;
  default: T;
  owner: string;
  description: string;
  lifecycle: FeatureFlagLifecycle;
  exposure: FeatureFlagExposure;
}

Current Registry

Flag Key Default Owner Lifecycle Exposure
enable-google-signin true identity capability server
enable-microsoft-signin false identity capability server
enable-parallel-chunk-processing true platform capability server

Design Decisions

  • Code-first declaration — remote tools cannot introduce flags. This prevents dangling flags and makes audit trivial.
  • Kill-switch dominance — flags with lifecycle: 'kill' are forced to boolean true/false. They cannot be overridden to arbitrary values.
  • Type safety at runtime — if a provider returns a type that doesn't match definition.default, the resolver silently falls back. This prevents a string value from appearing where a boolean is expected.
  • Frozen outputObject.freeze() on resolved features prevents mutation after resolution. Consumers get a read-only snapshot.
  • server-only importscreateGrowthBookClient() uses the server-only package to enforce that GrowthBook SDK code never leaks into client bundles.