25 KiB
ADR-024 Implementation Plan: Feature Flagging Strategy
Date: 2026-01-28 Type: Technical Implementation Plan Related: ADR-024: Feature Flagging Strategy, ADR-007: Configuration and Secrets Management Status: Ready for Implementation
Project Overview
Implement a simple, configuration-based feature flag system that integrates with the existing Zod-validated configuration in src/config/env.ts. The system will support both backend and frontend feature flags through environment variables, with type-safe access patterns and helper utilities.
Key Success Criteria
- Feature flags accessible via type-safe API on both backend and frontend
- Zero runtime overhead when flag is disabled (compile-time elimination where possible)
- Consistent naming convention (environment variables and code access)
- Graceful degradation (missing flag defaults to disabled)
- Easy migration path to external service (Flagsmith/LaunchDarkly) in the future
- Full test coverage with mocking utilities
Estimated Total Effort
| Phase | Estimate |
|---|---|
| Phase 1: Backend Infrastructure | 3-5 hours |
| Phase 2: Frontend Infrastructure | 2-3 hours |
| Phase 3: Documentation & Examples | 1-2 hours |
| Total | 6-10 hours |
Current State Analysis
Backend Configuration (src/config/env.ts)
- Zod-based schema validation at startup
- Organized into logical groups (database, redis, auth, smtp, ai, etc.)
- Helper exports for service availability (
isSmtpConfigured,isAiConfigured, etc.) - Environment helpers (
isProduction,isTest,isDevelopment) - Fail-fast on invalid configuration
Frontend Configuration (src/config.ts)
- Uses
import.meta.env(Vite environment variables) - Organized into sections (app, google, sentry)
- Boolean parsing for string env vars
- Type declarations in
src/vite-env.d.ts
Existing Patterns to Follow
// Backend - service availability check pattern
export const isSmtpConfigured =
!!config.smtp.host && !!config.smtp.user && !!config.smtp.pass;
// Frontend - boolean parsing pattern
enabled: import.meta.env.VITE_SENTRY_ENABLED !== 'false',
Task Breakdown
Phase 1: Backend Feature Flag Infrastructure
[1.1] Define Feature Flag Schema in env.ts
Complexity: Low Estimate: 30-45 minutes Dependencies: None Parallelizable: Yes
Description: Add a new featureFlags section to the Zod schema in src/config/env.ts.
Acceptance Criteria:
- New
featureFlagsSchemaZod object defined - Schema supports boolean flags with defaults to
false(opt-in model) - Schema added to main
envSchemaobject - Type exported as part of
EnvConfig
Implementation Details:
// src/config/env.ts
/**
* Feature flags configuration schema (ADR-024).
* All flags default to false (disabled) for safety.
* Set to 'true' in environment to enable.
*/
const featureFlagsSchema = z.object({
// Example flags - replace with actual feature flags as needed
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,
},
Risks/Notes:
- Naming convention:
FEATURE_*prefix for all feature flag env vars - Default to
falseensures features are opt-in, preventing accidental exposure
[1.2] Create Feature Flag Service Module
Complexity: Medium Estimate: 1-2 hours Dependencies: [1.1] Parallelizable: No (depends on 1.1)
Description: Create a dedicated service module for feature flag access with helper functions.
File: src/services/featureFlags.server.ts
Acceptance Criteria:
isFeatureEnabled(flagName)function for checking flagsgetAllFeatureFlags()function for debugging/admin endpoints- Type-safe flag name parameter (union type or enum)
- Exported helper booleans for common flags (similar to
isSmtpConfigured) - Logging when feature flag is checked in development mode
Implementation Details:
// src/services/featureFlags.server.ts
import { config, isDevelopment } from '../config/env';
import { logger } from './logger.server';
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* Check if a feature flag is enabled.
* @param flagName - The name of the feature flag to check
* @returns boolean indicating if the feature is enabled
*/
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 feature flags and their current states.
* Useful for debugging and admin endpoints.
*/
export function getAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return { ...config.featureFlags };
}
// Convenience exports for common flag checks
export const isNewDashboardEnabled = config.featureFlags.newDashboard;
export const isBetaRecipesEnabled = config.featureFlags.betaRecipes;
export const isExperimentalAiEnabled = config.featureFlags.experimentalAi;
export const isDebugModeEnabled = config.featureFlags.debugMode;
Risks/Notes:
- Keep logging minimal to avoid performance impact
- Convenience exports are evaluated once at startup (not dynamic)
[1.3] Add Admin Endpoint for Feature Flag Status
Complexity: Low Estimate: 30-45 minutes Dependencies: [1.2] Parallelizable: No (depends on 1.2)
Description: Add an admin/health endpoint to view current feature flag states.
File: src/routes/admin.routes.ts (or stats.routes.ts if admin routes don't exist)
Acceptance Criteria:
GET /api/v1/admin/feature-flagsendpoint (admin-only)- Returns JSON object with all flags and their states
- Requires admin authentication
- Endpoint documented in Swagger
Implementation Details:
// In appropriate routes file
router.get('/feature-flags', requireAdmin, async (req, res) => {
const flags = getAllFeatureFlags();
sendSuccess(res, { flags });
});
Risks/Notes:
- Ensure endpoint is protected (admin-only)
- Consider caching response if called frequently
[1.4] Backend Unit Tests
Complexity: Medium Estimate: 1-2 hours Dependencies: [1.1], [1.2] Parallelizable: Yes (can start after 1.1, in parallel with 1.3)
Description: Write unit tests for feature flag configuration and service.
Files:
src/config/env.test.ts(add feature flag tests)src/services/featureFlags.server.test.ts(new file)
Acceptance Criteria:
- Test default values (all false)
- Test parsing 'true'/'false' strings
- Test
isFeatureEnabled()function - Test
getAllFeatureFlags()function - Test type safety (TypeScript compile-time checks)
Implementation Details:
// src/config/env.test.ts - add to existing file
describe('featureFlags configuration', () => {
it('should default all feature flags to false', async () => {
setValidEnv();
const { config } = await import('./env');
expect(config.featureFlags.newDashboard).toBe(false);
expect(config.featureFlags.betaRecipes).toBe(false);
});
it('should parse FEATURE_NEW_DASHBOARD as true when set', async () => {
setValidEnv({ FEATURE_NEW_DASHBOARD: 'true' });
const { config } = await import('./env');
expect(config.featureFlags.newDashboard).toBe(true);
});
});
// src/services/featureFlags.server.test.ts - new file
describe('featureFlags service', () => {
describe('isFeatureEnabled', () => {
it('should return false for disabled flags', () => {
expect(isFeatureEnabled('newDashboard')).toBe(false);
});
// ... more tests
});
});
Phase 2: Frontend Feature Flag Infrastructure
[2.1] Add Frontend Feature Flag Config
Complexity: Low Estimate: 30-45 minutes Dependencies: None (can run in parallel with Phase 1) Parallelizable: Yes
Description: Add feature flags to the frontend config module.
Files:
src/config.ts- Add featureFlags sectionsrc/vite-env.d.ts- Add type declarations
Acceptance Criteria:
- Feature flags section added to
src/config.ts - TypeScript declarations updated in
vite-env.d.ts - Boolean parsing consistent with existing pattern
- Default to false when env var not set
Implementation Details:
// src/config.ts
const config = {
// ... existing sections ...
/**
* Feature flags for conditional feature rendering (ADR-024).
* All flags default to false (disabled) when not explicitly set.
*/
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',
},
};
// src/vite-env.d.ts
interface ImportMetaEnv {
// ... existing declarations ...
readonly VITE_FEATURE_NEW_DASHBOARD?: string;
readonly VITE_FEATURE_BETA_RECIPES?: string;
readonly VITE_FEATURE_EXPERIMENTAL_AI?: string;
readonly VITE_FEATURE_DEBUG_MODE?: string;
}
[2.2] Create useFeatureFlag React Hook
Complexity: Medium Estimate: 1-1.5 hours Dependencies: [2.1] Parallelizable: No (depends on 2.1)
Description: Create a React hook for checking feature flags in components.
File: src/hooks/useFeatureFlag.ts
Acceptance Criteria:
useFeatureFlag(flagName)hook returns boolean- Type-safe flag name parameter
- Memoized to prevent unnecessary re-renders
- Optional
FeatureFlagcomponent for conditional rendering
Implementation Details:
// src/hooks/useFeatureFlag.ts
import { useMemo } from 'react';
import config from '../config';
export type FeatureFlagName = keyof typeof config.featureFlags;
/**
* Hook to check if a feature flag is enabled.
*
* @param flagName - The name of the feature flag to check
* @returns boolean indicating if the feature is enabled
*
* @example
* const isNewDashboard = useFeatureFlag('newDashboard');
* if (isNewDashboard) {
* return <NewDashboard />;
* }
*/
export function useFeatureFlag(flagName: FeatureFlagName): boolean {
return useMemo(() => config.featureFlags[flagName], [flagName]);
}
/**
* Get all feature flags (useful for debugging).
*/
export function useAllFeatureFlags(): Record<FeatureFlagName, boolean> {
return useMemo(() => ({ ...config.featureFlags }), []);
}
[2.3] Create FeatureFlag Component
Complexity: Low Estimate: 30-45 minutes Dependencies: [2.2] Parallelizable: No (depends on 2.2)
Description: Create a declarative component for feature flag conditional rendering.
File: src/components/FeatureFlag.tsx
Acceptance Criteria:
<FeatureFlag name="flagName">component- Children rendered only when flag is enabled
- Optional
fallbackprop for disabled state - TypeScript-enforced flag names
Implementation Details:
// src/components/FeatureFlag.tsx
import { ReactNode } from 'react';
import { useFeatureFlag, FeatureFlagName } from '../hooks/useFeatureFlag';
interface FeatureFlagProps {
/** The name of the feature flag to check */
name: FeatureFlagName;
/** Content to render when feature is enabled */
children: ReactNode;
/** Optional content to render when feature is disabled */
fallback?: ReactNode;
}
/**
* Conditionally renders children based on feature flag state.
*
* @example
* <FeatureFlag name="newDashboard" fallback={<OldDashboard />}>
* <NewDashboard />
* </FeatureFlag>
*/
export function FeatureFlag({ name, children, fallback = null }: FeatureFlagProps) {
const isEnabled = useFeatureFlag(name);
return <>{isEnabled ? children : fallback}</>;
}
[2.4] Frontend Unit Tests
Complexity: Medium Estimate: 1-1.5 hours Dependencies: [2.1], [2.2], [2.3] Parallelizable: No (depends on previous frontend tasks)
Description: Write unit tests for frontend feature flag utilities.
Files:
src/config.test.ts(add feature flag tests)src/hooks/useFeatureFlag.test.ts(new file)src/components/FeatureFlag.test.tsx(new file)
Acceptance Criteria:
- Test config structure includes featureFlags
- Test default values (all false)
- Test hook returns correct values
- Test component renders/hides children correctly
- Test fallback rendering
Implementation Details:
// src/hooks/useFeatureFlag.test.ts
import { renderHook } from '@testing-library/react';
import { useFeatureFlag, useAllFeatureFlags } from './useFeatureFlag';
describe('useFeatureFlag', () => {
it('should return false for disabled flags', () => {
const { result } = renderHook(() => useFeatureFlag('newDashboard'));
expect(result.current).toBe(false);
});
});
// src/components/FeatureFlag.test.tsx
import { render, screen } from '@testing-library/react';
import { FeatureFlag } from './FeatureFlag';
describe('FeatureFlag', () => {
it('should not render children when flag is disabled', () => {
render(
<FeatureFlag name="newDashboard">
<div data-testid="new-feature">New Feature</div>
</FeatureFlag>
);
expect(screen.queryByTestId('new-feature')).not.toBeInTheDocument();
});
it('should render fallback when flag is disabled', () => {
render(
<FeatureFlag name="newDashboard" fallback={<div>Old Feature</div>}>
<div>New Feature</div>
</FeatureFlag>
);
expect(screen.getByText('Old Feature')).toBeInTheDocument();
});
});
Phase 3: Documentation & Integration
[3.1] Update ADR-024 with Implementation Status
Complexity: Low Estimate: 30 minutes Dependencies: [1.1], [1.2], [2.1], [2.2] Parallelizable: Yes (can be done after core implementation)
Description: Update ADR-024 to mark it as implemented and add implementation details.
File: docs/adr/0024-feature-flagging-strategy.md
Acceptance Criteria:
- Status changed from "Proposed" to "Accepted"
- Implementation status section added
- Key files documented
- Usage examples included
[3.2] Update Environment Documentation
Complexity: Low Estimate: 30 minutes Dependencies: [1.1], [2.1] Parallelizable: Yes
Description: Add feature flag environment variables to documentation.
Files:
docs/getting-started/ENVIRONMENT.md.env.example
Acceptance Criteria:
- Feature flag variables documented in ENVIRONMENT.md
- New section "Feature Flags" added
.env.exampleupdated with commented feature flag examples
Implementation Details:
# .env.example addition
# ===================
# Feature Flags (ADR-024)
# ===================
# All feature flags default to disabled (false) when not set.
# Set to 'true' to enable a feature.
#
# FEATURE_NEW_DASHBOARD=false
# FEATURE_BETA_RECIPES=false
# FEATURE_EXPERIMENTAL_AI=false
# FEATURE_DEBUG_MODE=false
#
# Frontend equivalents (prefix with VITE_):
# VITE_FEATURE_NEW_DASHBOARD=false
# VITE_FEATURE_BETA_RECIPES=false
[3.3] Create CODE-PATTERNS Entry
Complexity: Low Estimate: 30 minutes Dependencies: All implementation tasks Parallelizable: Yes
Description: Add feature flag usage patterns to CODE-PATTERNS.md.
File: docs/development/CODE-PATTERNS.md
Acceptance Criteria:
- Feature flag section added with examples
- Backend usage pattern documented
- Frontend usage pattern documented
- Testing pattern documented
[3.4] Update CLAUDE.md Quick Reference
Complexity: Low Estimate: 15 minutes Dependencies: All implementation tasks Parallelizable: Yes
Description: Add feature flags to the CLAUDE.md quick reference tables.
File: CLAUDE.md
Acceptance Criteria:
- Feature flags added to "Key Patterns" table
- Reference to featureFlags service added
Implementation Sequence
Phase 1 (Backend) - Can Start Immediately
[1.1] Schema ──────────┬──> [1.2] Service ──> [1.3] Admin Endpoint
│
└──> [1.4] Backend Tests (can start after 1.1)
Phase 2 (Frontend) - Can Start Immediately (Parallel with Phase 1)
[2.1] Config ──> [2.2] Hook ──> [2.3] Component ──> [2.4] Frontend Tests
Phase 3 (Documentation) - After Implementation
All Phase 1 & 2 Tasks ──> [3.1] ADR Update
├──> [3.2] Env Docs
├──> [3.3] Code Patterns
└──> [3.4] CLAUDE.md
Critical Path
The minimum path to a working feature flag system:
- [1.1] Schema (30 min) - Required for backend
- [1.2] Service (1.5 hr) - Required for backend access
- [2.1] Frontend Config (30 min) - Required for frontend
- [2.2] Hook (1 hr) - Required for React integration
Critical path duration: ~3.5 hours
Non-critical but recommended:
- Admin endpoint (debugging)
- FeatureFlag component (developer convenience)
- Tests (quality assurance)
- Documentation (maintainability)
Scope Recommendations
MVP (Minimum Viable Implementation)
Include in initial implementation:
- [1.1] Backend schema with 2-3 example flags
- [1.2] Feature flag service
- [2.1] Frontend config
- [2.2] useFeatureFlag hook
- [1.4] Core backend tests
- [2.4] Core frontend tests
Enhancements (Future Iterations)
Defer to follow-up work:
- Admin endpoint for flag visibility
- FeatureFlag component (nice-to-have)
- Dynamic flag updates without restart (requires external service)
- User-specific flags (A/B testing)
- Flag analytics/usage tracking
- Gradual rollout percentages
Explicitly Out of Scope
- Integration with Flagsmith/LaunchDarkly (future ADR)
- Database-stored flags (requires schema changes)
- Real-time flag updates (WebSocket/SSE)
- Flag inheritance/hierarchy
- Flag audit logging
Testing Strategy
Backend Tests
| Test Type | Coverage Target | Location |
|---|---|---|
| Schema validation | Parse true/false, defaults | src/config/env.test.ts |
| Service functions | isFeatureEnabled, getAllFeatureFlags |
src/services/featureFlags.server.test.ts |
| Integration | Admin endpoint (if added) | src/routes/admin.routes.test.ts |
Frontend Tests
| Test Type | Coverage Target | Location |
|---|---|---|
| Config structure | featureFlags section exists | src/config.test.ts |
| Hook behavior | Returns correct values | src/hooks/useFeatureFlag.test.ts |
| Component rendering | Conditional children | src/components/FeatureFlag.test.tsx |
Mocking Pattern for Tests
// Backend - reset modules to test different flag states
beforeEach(() => {
vi.resetModules();
process.env.FEATURE_NEW_DASHBOARD = 'true';
});
// Frontend - mock config module
vi.mock('../config', () => ({
default: {
featureFlags: {
newDashboard: true,
betaRecipes: false,
},
},
}));
Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| Flag state inconsistency (backend/frontend) | Medium | Low | Use same env var naming, document sync requirements |
| Performance impact from flag checks | Low | Low | Flags cached at startup, no runtime DB calls |
| Stale flags after deployment | Medium | Medium | Document restart requirement, consider future dynamic loading |
| Feature creep (too many flags) | Medium | Medium | Require ADR for new flags, sunset policy |
| Missing flag causes crash | High | Low | Default to false, graceful degradation |
Files to Create
| File | Purpose |
|---|---|
src/services/featureFlags.server.ts |
Backend feature flag service |
src/services/featureFlags.server.test.ts |
Backend tests |
src/hooks/useFeatureFlag.ts |
React hook for flag access |
src/hooks/useFeatureFlag.test.ts |
Hook tests |
src/components/FeatureFlag.tsx |
Declarative flag component |
src/components/FeatureFlag.test.tsx |
Component tests |
Files to Modify
| File | Changes |
|---|---|
src/config/env.ts |
Add featureFlagsSchema and loading |
src/config/env.test.ts |
Add feature flag tests |
src/config.ts |
Add featureFlags section |
src/config.test.ts |
Add feature flag tests |
src/vite-env.d.ts |
Add VITEFEATURE* declarations |
.env.example |
Add feature flag examples |
docs/adr/0024-feature-flagging-strategy.md |
Update status and details |
docs/getting-started/ENVIRONMENT.md |
Document feature flag vars |
docs/development/CODE-PATTERNS.md |
Add usage patterns |
CLAUDE.md |
Add to quick reference |
Verification Commands
After implementation, run these commands in the dev container:
# Type checking
podman exec -it flyer-crawler-dev npm run type-check
# Backend unit tests
podman exec -it flyer-crawler-dev npm run test:unit -- --grep "featureFlag"
# Frontend tests (includes hook and component tests)
podman exec -it flyer-crawler-dev npm run test:unit -- --grep "FeatureFlag"
# Full test suite
podman exec -it flyer-crawler-dev npm test
Example Usage (Post-Implementation)
Backend Route Handler
// src/routes/flyers.routes.ts
import { isFeatureEnabled } from '../services/featureFlags.server';
router.get('/dashboard', async (req, res) => {
if (isFeatureEnabled('newDashboard')) {
// New dashboard logic
return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
}
// Legacy dashboard
return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
});
React Component
// src/pages/Dashboard.tsx
import { FeatureFlag } from '../components/FeatureFlag';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
// Option 1: Declarative component
function Dashboard() {
return (
<FeatureFlag name="newDashboard" fallback={<LegacyDashboard />}>
<NewDashboard />
</FeatureFlag>
);
}
// Option 2: Hook for logic
function DashboardWithLogic() {
const isNewDashboard = useFeatureFlag('newDashboard');
useEffect(() => {
if (isNewDashboard) {
analytics.track('new_dashboard_viewed');
}
}, [isNewDashboard]);
return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
}
Implementation Notes
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 |
| Type/Hook param | camelCase |
isFeatureEnabled('newDashboard') |
Flag Lifecycle
- Adding a flag: Add to both schemas, set default to
false, document - Enabling a flag: Set env var to
'true', restart application - Removing a flag: Remove conditional code first, then remove flag from schemas
- Sunset policy: Flags should be removed within 3 months of full rollout
Last updated: 2026-01-28