All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m13s
334 lines
12 KiB
Markdown
334 lines
12 KiB
Markdown
# 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) |
|