# 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 `<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

```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()  <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`)

```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<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

```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<FeatureFlagName, boolean> {
  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
<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

```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(
      <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)

```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)                       |
