ADR-024 Feature Flagging Strategy
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m13s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m13s
This commit is contained in:
@@ -1,18 +1,333 @@
|
||||
# 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)
|
||||
|
||||
**Status**: Proposed
|
||||
## 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
|
||||
|
||||
As the application grows, there is no way to roll out new features to a subset of users (e.g., for beta testing) or to quickly disable a problematic feature in production without a full redeployment.
|
||||
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
|
||||
|
||||
We will implement a feature flagging system. This could start with a simple configuration-based approach (defined in `ADR-007`) and evolve to use a dedicated service like **Flagsmith** or **LaunchDarkly**. This ADR will define how feature flags are created, managed, and checked in both the backend and frontend code.
|
||||
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 feature releases from code deployments, reducing risk and allowing for more controlled, gradual rollouts and A/B testing. Enables easier experimentation and faster iteration.
|
||||
**Negative**: Adds complexity to the codebase with conditional logic around features. Requires careful management of feature flag states to avoid technical debt.
|
||||
- **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) |
|
||||
|
||||
@@ -15,9 +15,9 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
| Status | Count |
|
||||
| ---------------------------- | ----- |
|
||||
| Accepted (Fully Implemented) | 41 |
|
||||
| Accepted (Fully Implemented) | 42 |
|
||||
| Partially Implemented | 2 |
|
||||
| Proposed (Not Started) | 13 |
|
||||
| Proposed (Not Started) | 12 |
|
||||
| Superseded | 1 |
|
||||
|
||||
---
|
||||
@@ -78,16 +78,16 @@ This document tracks the implementation status and estimated effort for all Arch
|
||||
|
||||
### Category 6: Deployment & Operations
|
||||
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ------------------ | -------- | ------ | -------------------------- |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
| [ADR-053](./0053-worker-health-checks.md) | Worker Health | Accepted | - | Fully implemented |
|
||||
| [ADR-054](./0054-bugsink-gitea-issue-sync.md) | Bugsink-Gitea Sync | Proposed | L | Automated issue creation |
|
||||
| ADR | Title | Status | Effort | Notes |
|
||||
| -------------------------------------------------------------- | ------------------ | -------- | ------ | ------------------------ |
|
||||
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
|
||||
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
|
||||
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Accepted | - | Fully implemented |
|
||||
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
|
||||
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
|
||||
| [ADR-053](./0053-worker-health-checks.md) | Worker Health | Accepted | - | Fully implemented |
|
||||
| [ADR-054](./0054-bugsink-gitea-issue-sync.md) | Bugsink-Gitea Sync | Proposed | L | Automated issue creation |
|
||||
|
||||
### Category 7: Frontend / User Interface
|
||||
|
||||
@@ -134,15 +134,14 @@ These ADRs are proposed or partially implemented, ordered by suggested implement
|
||||
|
||||
| Priority | ADR | Title | Status | Effort | Rationale |
|
||||
| -------- | ------- | ------------------------ | -------- | ------ | ------------------------------------ |
|
||||
| 1 | ADR-024 | Feature Flags | Proposed | M | Safer deployments, A/B testing |
|
||||
| 2 | ADR-054 | Bugsink-Gitea Sync | Proposed | L | Automated issue tracking from errors |
|
||||
| 3 | ADR-023 | Schema Migrations v2 | Proposed | L | Database evolution support |
|
||||
| 4 | ADR-029 | Secret Rotation | Proposed | L | Security improvement |
|
||||
| 5 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
|
||||
| 6 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
|
||||
| 7 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
|
||||
| 8 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
|
||||
| 9 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
|
||||
| 1 | ADR-054 | Bugsink-Gitea Sync | Proposed | L | Automated issue tracking from errors |
|
||||
| 2 | ADR-023 | Schema Migrations v2 | Proposed | L | Database evolution support |
|
||||
| 3 | ADR-029 | Secret Rotation | Proposed | L | Security improvement |
|
||||
| 4 | ADR-030 | Circuit Breaker | Proposed | L | Resilience improvement |
|
||||
| 5 | ADR-056 | APM (Performance) | Proposed | M | Enable when performance issues arise |
|
||||
| 6 | ADR-011 | Authorization & RBAC | Proposed | XL | Advanced permission system |
|
||||
| 7 | ADR-025 | i18n & l10n | Proposed | XL | Multi-language support |
|
||||
| 8 | ADR-031 | Data Retention & Privacy | Proposed | XL | Compliance requirements |
|
||||
|
||||
---
|
||||
|
||||
@@ -150,6 +149,7 @@ These ADRs are proposed or partially implemented, ordered by suggested implement
|
||||
|
||||
| Date | ADR | Change |
|
||||
| ---------- | ------- | ----------------------------------------------------------------------------------- |
|
||||
| 2026-01-28 | ADR-024 | Fully implemented - Backend/frontend feature flags, 89 tests, admin endpoint |
|
||||
| 2026-01-28 | ADR-057 | Created - Test remediation documentation for ADR-008 Phase 2 migration |
|
||||
| 2026-01-28 | ADR-013 | Marked as Superseded by ADR-023 |
|
||||
| 2026-01-27 | ADR-008 | Test path migration complete - 23 files, ~70 paths updated, 274->345 tests passing |
|
||||
|
||||
@@ -4,16 +4,17 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Key Function/Class | Import From |
|
||||
| ------------------ | ------------------------------------------------- | ---------------------------------- |
|
||||
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
||||
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
||||
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
||||
| Authentication | `authenticateJWT` | `src/middleware/auth.ts` |
|
||||
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
||||
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
||||
| Pattern | Key Function/Class | Import From |
|
||||
| ------------------ | ------------------------------------------------- | ------------------------------------- |
|
||||
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
||||
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
||||
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
||||
| Authentication | `authenticateJWT` | `src/middleware/auth.ts` |
|
||||
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
||||
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
||||
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +28,7 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
|
||||
- [Authentication](#authentication)
|
||||
- [Caching](#caching)
|
||||
- [Background Jobs](#background-jobs)
|
||||
- [Feature Flags](#feature-flags)
|
||||
|
||||
---
|
||||
|
||||
@@ -491,6 +493,153 @@ const flyerWorker = new Worker(
|
||||
|
||||
---
|
||||
|
||||
## Feature Flags
|
||||
|
||||
**ADR**: [ADR-024](../adr/0024-feature-flagging-strategy.md)
|
||||
|
||||
Feature flags enable controlled feature rollout, A/B testing, and quick production disablement without redeployment. All flags default to `false` (opt-in model).
|
||||
|
||||
### Backend Usage
|
||||
|
||||
```typescript
|
||||
import { isFeatureEnabled, getFeatureFlags } from '../services/featureFlags.server';
|
||||
|
||||
// Check a specific flag in route handler
|
||||
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() });
|
||||
});
|
||||
|
||||
// Check flag in service layer
|
||||
function processFlyer(flyer: Flyer): ProcessedFlyer {
|
||||
if (isFeatureEnabled('experimentalAi')) {
|
||||
return processWithExperimentalAi(flyer);
|
||||
}
|
||||
return processWithStandardAi(flyer);
|
||||
}
|
||||
|
||||
// Get all flags (admin endpoint)
|
||||
router.get('/admin/feature-flags', requireAdmin, async (req, res) => {
|
||||
sendSuccess(res, { flags: getFeatureFlags() });
|
||||
});
|
||||
```
|
||||
|
||||
### Frontend Usage
|
||||
|
||||
```tsx
|
||||
import { useFeatureFlag, useAllFeatureFlags } from '../hooks/useFeatureFlag';
|
||||
import { FeatureFlag } from '../components/FeatureFlag';
|
||||
|
||||
// Hook approach - for logic beyond rendering
|
||||
function Dashboard() {
|
||||
const isNewDashboard = useFeatureFlag('newDashboard');
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewDashboard) {
|
||||
analytics.track('new_dashboard_viewed');
|
||||
}
|
||||
}, [isNewDashboard]);
|
||||
|
||||
return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
|
||||
}
|
||||
|
||||
// Declarative component approach
|
||||
function App() {
|
||||
return (
|
||||
<FeatureFlag feature="newDashboard" fallback={<LegacyDashboard />}>
|
||||
<NewDashboard />
|
||||
</FeatureFlag>
|
||||
);
|
||||
}
|
||||
|
||||
// Debug panel showing all flags
|
||||
function DebugPanel() {
|
||||
const flags = useAllFeatureFlags();
|
||||
return (
|
||||
<ul>
|
||||
{Object.entries(flags).map(([name, enabled]) => (
|
||||
<li key={name}>
|
||||
{name}: {enabled ? 'ON' : 'OFF'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Flag
|
||||
|
||||
1. **Backend** (`src/config/env.ts`):
|
||||
|
||||
```typescript
|
||||
// In featureFlagsSchema
|
||||
myNewFeature: booleanString(false), // FEATURE_MY_NEW_FEATURE
|
||||
|
||||
// In loadEnvVars()
|
||||
myNewFeature: process.env.FEATURE_MY_NEW_FEATURE,
|
||||
```
|
||||
|
||||
2. **Frontend** (`src/config.ts` and `src/vite-env.d.ts`):
|
||||
|
||||
```typescript
|
||||
// In config.ts featureFlags section
|
||||
myNewFeature: import.meta.env.VITE_FEATURE_MY_NEW_FEATURE === 'true',
|
||||
|
||||
// In vite-env.d.ts
|
||||
readonly VITE_FEATURE_MY_NEW_FEATURE?: string;
|
||||
```
|
||||
|
||||
3. **Environment** (`.env.example`):
|
||||
|
||||
```bash
|
||||
# FEATURE_MY_NEW_FEATURE=false
|
||||
# VITE_FEATURE_MY_NEW_FEATURE=false
|
||||
```
|
||||
|
||||
### Testing Feature Flags
|
||||
|
||||
```typescript
|
||||
// Backend - reset modules to test different states
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.FEATURE_NEW_DASHBOARD = 'true';
|
||||
});
|
||||
|
||||
// Frontend - mock config module
|
||||
vi.mock('../config', () => ({
|
||||
default: {
|
||||
featureFlags: {
|
||||
newDashboard: true,
|
||||
betaRecipes: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Flag Lifecycle
|
||||
|
||||
| Phase | Actions |
|
||||
| ---------- | -------------------------------------------------------------- |
|
||||
| **Add** | Add to schemas (backend + frontend), default `false`, document |
|
||||
| **Enable** | Set env var `='true'`, restart application |
|
||||
| **Remove** | Remove conditional code, remove from schemas, remove env vars |
|
||||
| **Sunset** | Max 3 months after full rollout - remove flag |
|
||||
|
||||
### Current Flags
|
||||
|
||||
| Flag | Backend Env Var | Frontend Env Var | Purpose |
|
||||
| ---------------- | ------------------------- | ------------------------------ | ------------------------ |
|
||||
| `bugsinkSync` | `FEATURE_BUGSINK_SYNC` | `VITE_FEATURE_BUGSINK_SYNC` | Bugsink error sync |
|
||||
| `advancedRbac` | `FEATURE_ADVANCED_RBAC` | `VITE_FEATURE_ADVANCED_RBAC` | Advanced RBAC features |
|
||||
| `newDashboard` | `FEATURE_NEW_DASHBOARD` | `VITE_FEATURE_NEW_DASHBOARD` | New dashboard experience |
|
||||
| `betaRecipes` | `FEATURE_BETA_RECIPES` | `VITE_FEATURE_BETA_RECIPES` | Beta recipe features |
|
||||
| `experimentalAi` | `FEATURE_EXPERIMENTAL_AI` | `VITE_FEATURE_EXPERIMENTAL_AI` | Experimental AI features |
|
||||
| `debugMode` | `FEATURE_DEBUG_MODE` | `VITE_FEATURE_DEBUG_MODE` | Debug mode |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ADR Index](../adr/index.md) - All architecture decision records
|
||||
|
||||
849
docs/plans/2026-01-28-adr-024-feature-flags-implementation.md
Normal file
849
docs/plans/2026-01-28-adr-024-feature-flags-implementation.md
Normal file
@@ -0,0 +1,849 @@
|
||||
# ADR-024 Implementation Plan: Feature Flagging Strategy
|
||||
|
||||
**Date**: 2026-01-28
|
||||
**Type**: Technical Implementation Plan
|
||||
**Related**: [ADR-024: Feature Flagging Strategy](../adr/0024-feature-flagging-strategy.md), [ADR-007: Configuration and Secrets Management](../adr/0007-configuration-and-secrets-management.md)
|
||||
**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
|
||||
|
||||
1. Feature flags accessible via type-safe API on both backend and frontend
|
||||
2. Zero runtime overhead when flag is disabled (compile-time elimination where possible)
|
||||
3. Consistent naming convention (environment variables and code access)
|
||||
4. Graceful degradation (missing flag defaults to disabled)
|
||||
5. Easy migration path to external service (Flagsmith/LaunchDarkly) in the future
|
||||
6. 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
|
||||
|
||||
```typescript
|
||||
// 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 `featureFlagsSchema` Zod object defined
|
||||
- [ ] Schema supports boolean flags with defaults to `false` (opt-in model)
|
||||
- [ ] Schema added to main `envSchema` object
|
||||
- [ ] Type exported as part of `EnvConfig`
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// 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 `false` ensures 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 flags
|
||||
- [ ] `getAllFeatureFlags()` 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**:
|
||||
|
||||
```typescript
|
||||
// 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-flags` endpoint (admin-only)
|
||||
- [ ] Returns JSON object with all flags and their states
|
||||
- [ ] Requires admin authentication
|
||||
- [ ] Endpoint documented in Swagger
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// 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**:
|
||||
|
||||
```typescript
|
||||
// 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 section
|
||||
- `src/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**:
|
||||
|
||||
```typescript
|
||||
// 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 `FeatureFlag` component for conditional rendering
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// 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 `fallback` prop for disabled state
|
||||
- [ ] TypeScript-enforced flag names
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```typescript
|
||||
// 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**:
|
||||
|
||||
```typescript
|
||||
// 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.example` updated with commented feature flag examples
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
```bash
|
||||
# .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
|
||||
|
||||
```text
|
||||
[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)
|
||||
|
||||
```text
|
||||
[2.1] Config ──> [2.2] Hook ──> [2.3] Component ──> [2.4] Frontend Tests
|
||||
```
|
||||
|
||||
### Phase 3 (Documentation) - After Implementation
|
||||
|
||||
```text
|
||||
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.1] Schema** (30 min) - Required for backend
|
||||
2. **[1.2] Service** (1.5 hr) - Required for backend access
|
||||
3. **[2.1] Frontend Config** (30 min) - Required for frontend
|
||||
4. **[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
|
||||
|
||||
```typescript
|
||||
// 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 VITE*FEATURE*\* 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:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
|
||||
1. **Adding a flag**: Add to both schemas, set default to `false`, document
|
||||
2. **Enabling a flag**: Set env var to `'true'`, restart application
|
||||
3. **Removing a flag**: Remove conditional code first, then remove flag from schemas
|
||||
4. **Sunset policy**: Flags should be removed within 3 months of full rollout
|
||||
|
||||
---
|
||||
|
||||
Last updated: 2026-01-28
|
||||
Reference in New Issue
Block a user