# 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](../plans/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 `` 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 ```text 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() │ 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`) ```typescript /** * 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`) ```typescript 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 { return { ...config.featureFlags }; } // Convenience exports (evaluated once at startup) export const isNewDashboardEnabled = config.featureFlags.newDashboard; export const isBetaRecipesEnabled = config.featureFlags.betaRecipes; ``` #### Usage in Routes ```typescript 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`) ```typescript 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`) ```typescript 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`) ```typescript 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 { return useMemo(() => ({ ...config.featureFlags }), []); } ``` #### Declarative Component (`src/components/FeatureFlag.tsx`) ```typescript 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 ```tsx // Declarative approach }> ; // Hook approach (for logic beyond rendering) const isNewDashboard = useFeatureFlag('newDashboard'); useEffect(() => { if (isNewDashboard) analytics.track('new_dashboard_viewed'); }, [isNewDashboard]); ``` ### Testing Patterns #### Backend Test Setup ```typescript // 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 ```typescript // Mock config module vi.mock('../config', () => ({ default: { featureFlags: { newDashboard: true, betaRecipes: false, }, }, })); // Component test describe('FeatureFlag', () => { it('renders fallback when disabled', () => { render( Old}>
New
); 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) ```typescript // 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) |