Files
flyer-crawler.projectium.com/docs/adr/0024-feature-flagging-strategy.md
Torben Sorensen 61f24305fb
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m13s
ADR-024 Feature Flagging Strategy
2026-01-28 23:23:45 -08:00

12 KiB

ADR-024: Feature Flagging Strategy

Date: 2025-12-12 Status: Accepted Implemented: 2026-01-28 Implementation Plan: 2026-01-28-adr-024-feature-flags-implementation.md

Implementation Summary

Feature flag infrastructure fully implemented with 89 new tests (all passing). Total test suite: 3,616 tests passing.

Backend:

  • Zod-validated schema in src/config/env.ts with 6 feature flags
  • Service module src/services/featureFlags.server.ts with isFeatureEnabled(), getFeatureFlags(), getEnabledFeatureFlags()
  • Admin endpoint GET /api/v1/admin/feature-flags (requires admin authentication)
  • Convenience exports for direct boolean access

Frontend:

  • Config section in src/config.ts with VITE_FEATURE_* environment variables
  • Type declarations in src/vite-env.d.ts
  • React hook useFeatureFlag() and useAllFeatureFlags() in src/hooks/useFeatureFlag.ts
  • Declarative component <FeatureFlag> in src/components/FeatureFlag.tsx

Current Flags: bugsinkSync, advancedRbac, newDashboard, betaRecipes, experimentalAi, debugMode


Context

Application lacks controlled feature rollout capability. No mechanism for beta testing, quick production disablement, or gradual rollouts without full redeployment. Need type-safe, configuration-based system integrating with ADR-007 Zod validation.

Decision

Implement environment-variable-based feature flag system. Backend: Zod-validated schema in src/config/env.ts + dedicated service. Frontend: Vite env vars + React hook + declarative component. All flags default false (opt-in model). Future migration path to Flagsmith/LaunchDarkly preserved via abstraction layer.

Consequences

  • Positive: Decouples releases from deployments → reduced risk, gradual rollouts, A/B testing capability
  • Negative: Conditional logic complexity → requires sunset policy (3-month max after full rollout)
  • Neutral: Restart required for flag changes (acceptable for current scale, external service removes this constraint)

Implementation Details

Architecture Overview

Environment Variables (FEATURE_*, VITE_FEATURE_*)
        │
        ├── Backend ──► src/config/env.ts (Zod) ──► src/services/featureFlags.server.ts
        │                                                     │
        │                                          ┌──────────┴──────────┐
        │                                          │                     │
        │                              isFeatureEnabled()    getAllFeatureFlags()
        │                                          │
        │                                   Routes/Services
        │
        └── Frontend ─► src/config.ts ──► src/hooks/useFeatureFlag.ts
                                                   │
                                    ┌──────────────┼──────────────┐
                                    │              │              │
                            useFeatureFlag()  useAllFeatureFlags()  <FeatureFlag>
                                    │                             Component
                                Components

File Structure

File Purpose Layer
src/config/env.ts Zod schema + env loading Backend config
src/services/featureFlags.server.ts Flag access service Backend runtime
src/config.ts Vite env parsing Frontend config
src/vite-env.d.ts TypeScript declarations Frontend types
src/hooks/useFeatureFlag.ts React hook Frontend runtime
src/components/FeatureFlag.tsx Declarative wrapper Frontend UI

Naming Convention

Context Pattern Example
Backend env var FEATURE_SNAKE_CASE FEATURE_NEW_DASHBOARD
Frontend env var VITE_FEATURE_SNAKE_CASE VITE_FEATURE_NEW_DASHBOARD
Config property camelCase config.featureFlags.newDashboard
Hook/function param camelCase literal isFeatureEnabled('newDashboard')

Backend Implementation

Schema Definition (src/config/env.ts)

/**
 * Feature flags schema (ADR-024).
 * All flags default false (disabled) for safety.
 */
const featureFlagsSchema = z.object({
  newDashboard: booleanString(false),      // FEATURE_NEW_DASHBOARD
  betaRecipes: booleanString(false),       // FEATURE_BETA_RECIPES
  experimentalAi: booleanString(false),    // FEATURE_EXPERIMENTAL_AI
  debugMode: booleanString(false),         // FEATURE_DEBUG_MODE
});

// In loadEnvVars():
featureFlags: {
  newDashboard: process.env.FEATURE_NEW_DASHBOARD,
  betaRecipes: process.env.FEATURE_BETA_RECIPES,
  experimentalAi: process.env.FEATURE_EXPERIMENTAL_AI,
  debugMode: process.env.FEATURE_DEBUG_MODE,
},

Service Module (src/services/featureFlags.server.ts)

import { config, isDevelopment } from '../config/env';
import { logger } from './logger.server';

export type FeatureFlagName = keyof typeof config.featureFlags;

/**
 * Check feature flag state. Logs in development mode.
 */
export function isFeatureEnabled(flagName: FeatureFlagName): boolean {
  const enabled = config.featureFlags[flagName];
  if (isDevelopment) {
    logger.debug({ flag: flagName, enabled }, 'Feature flag checked');
  }
  return enabled;
}

/**
 * Get all flags (admin/debug endpoints).
 */
export function getAllFeatureFlags(): Record<FeatureFlagName, boolean> {
  return { ...config.featureFlags };
}

// Convenience exports (evaluated once at startup)
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;

Usage in Routes

import { isFeatureEnabled } from '../services/featureFlags.server';

router.get('/dashboard', async (req, res) => {
  if (isFeatureEnabled('newDashboard')) {
    return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
  }
  return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
});

Frontend Implementation

Config (src/config.ts)

const config = {
  // ... existing sections ...

  featureFlags: {
    newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
    betaRecipes: import.meta.env.VITE_FEATURE_BETA_RECIPES === 'true',
    experimentalAi: import.meta.env.VITE_FEATURE_EXPERIMENTAL_AI === 'true',
    debugMode: import.meta.env.VITE_FEATURE_DEBUG_MODE === 'true',
  },
};

Type Declarations (src/vite-env.d.ts)

interface ImportMetaEnv {
  readonly VITE_FEATURE_NEW_DASHBOARD?: string;
  readonly VITE_FEATURE_BETA_RECIPES?: string;
  readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
  readonly VITE_FEATURE_DEBUG_MODE?: string;
}

React Hook (src/hooks/useFeatureFlag.ts)

import { useMemo } from 'react';
import config from '../config';

export type FeatureFlagName = keyof typeof config.featureFlags;

export function useFeatureFlag(flagName: FeatureFlagName): boolean {
  return useMemo(() => config.featureFlags[flagName], [flagName]);
}

export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
  return useMemo(() => ({ ...config.featureFlags }), []);
}

Declarative Component (src/components/FeatureFlag.tsx)

import { ReactNode } from 'react';
import { useFeatureFlag, FeatureFlagName } from '../hooks/useFeatureFlag';

interface FeatureFlagProps {
  name: FeatureFlagName;
  children: ReactNode;
  fallback?: ReactNode;
}

export function FeatureFlag({ name, children, fallback = null }: FeatureFlagProps) {
  const isEnabled = useFeatureFlag(name);
  return <>{isEnabled ? children : fallback}</>;
}

Usage in Components

// Declarative approach
<FeatureFlag name="newDashboard" fallback={<LegacyDashboard />}>
  <NewDashboard />
</FeatureFlag>;

// Hook approach (for logic beyond rendering)
const isNewDashboard = useFeatureFlag('newDashboard');
useEffect(() => {
  if (isNewDashboard) analytics.track('new_dashboard_viewed');
}, [isNewDashboard]);

Testing Patterns

Backend Test Setup

// Reset modules to test different flag states
beforeEach(() => {
  vi.resetModules();
  process.env.FEATURE_NEW_DASHBOARD = 'true';
});

// src/services/featureFlags.server.test.ts
describe('isFeatureEnabled', () => {
  it('returns false for disabled flags', () => {
    expect(isFeatureEnabled('newDashboard')).toBe(false);
  });
});

Frontend Test Setup

// Mock config module
vi.mock('../config', () => ({
  default: {
    featureFlags: {
      newDashboard: true,
      betaRecipes: false,
    },
  },
}));

// Component test
describe('FeatureFlag', () => {
  it('renders fallback when disabled', () => {
    render(
      <FeatureFlag name="betaRecipes" fallback={<div>Old</div>}>
        <div>New</div>
      </FeatureFlag>
    );
    expect(screen.getByText('Old')).toBeInTheDocument();
  });
});

Flag Lifecycle

Phase Actions
Add 1. Add to both schemas (backend + frontend) 2. Default false 3. Document in .env.example
Enable Set env var ='true' → restart application
Remove 1. Remove conditional code 2. Remove from schemas 3. Remove env vars
Sunset Max 3 months after full rollout → remove flag

Admin Endpoint (Optional)

// GET /api/v1/admin/feature-flags (admin-only)
router.get('/feature-flags', requireAdmin, async (req, res) => {
  sendSuccess(res, { flags: getAllFeatureFlags() });
});

Integration with ADR-007

Feature flags extend existing Zod configuration pattern:

  • Validation: Same booleanString() transform used by other config
  • Loading: Same loadEnvVars() function loads FEATURE_* vars
  • Type Safety: FeatureFlagName type derived from config schema
  • Fail-Fast: Invalid flag values fail at startup (Zod validation)

Future Migration Path

Current implementation abstracts flag access via isFeatureEnabled() function and useFeatureFlag() hook. External service migration requires:

  1. Replace implementation internals of these functions
  2. Add API client for Flagsmith/LaunchDarkly
  3. No changes to consuming code (routes/components)

Explicitly Out of Scope

  • External service integration (Flagsmith/LaunchDarkly)
  • Database-stored flags
  • Real-time flag updates (WebSocket/SSE)
  • User-specific flags (A/B testing percentages)
  • Flag inheritance/hierarchy
  • Flag audit logging

Key Files Reference

Action Files
Add new flag src/config/env.ts, src/config.ts, src/vite-env.d.ts, .env.example
Check flag (backend) Import from src/services/featureFlags.server.ts
Check flag (frontend) Import hook from src/hooks/useFeatureFlag.ts or component from src/components/FeatureFlag.tsx
Test flag behavior Mock via vi.resetModules() (backend) or vi.mock('../config') (frontend)