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.tswith 6 feature flags - Service module
src/services/featureFlags.server.tswithisFeatureEnabled(),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.tswithVITE_FEATURE_*environment variables - Type declarations in
src/vite-env.d.ts - React hook
useFeatureFlag()anduseAllFeatureFlags()insrc/hooks/useFeatureFlag.ts - Declarative component
<FeatureFlag>insrc/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 loadsFEATURE_*vars - Type Safety:
FeatureFlagNametype 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:
- Replace implementation internals of these functions
- Add API client for Flagsmith/LaunchDarkly
- 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) |