# Feature Flag System > ArchiTools internal architecture reference -- feature flag design, implementation, and usage patterns. --- ## Overview The feature flag system controls which modules and capabilities are available at runtime. It serves three purposes: 1. **Module gating** -- a module whose flag is disabled is never imported, never bundled into the active page, and never visible in navigation. 2. **Incremental rollout** -- experimental features can be enabled for specific users or environments without a separate deployment. 3. **Operational control** -- system flags allow disabling capabilities (e.g., AI features) without a code change. Flags are defined statically in `src/config/flags.ts`, consumed via a React context provider, and checked with a hook or a gate component. There is no remote flag service today; the system is designed so one can be added without changing consumer code. --- ## Flag Definition Model ```typescript // src/types/flags.ts type FlagCategory = 'module' | 'experimental' | 'system'; interface FeatureFlag { key: string; // unique identifier, matches module's featureFlag field enabled: boolean; // default state label: string; // Romanian display name description: string; // Romanian description of what this flag controls category: FlagCategory; // classification for the admin UI requiredRole?: Role; // minimum role to see this feature (future use) dependencies?: string[]; // other flag keys that must be enabled for this flag to take effect overridable: boolean; // whether this flag can be toggled at runtime via admin panel } ``` ### Field Details | Field | Notes | |---|---| | `key` | Convention: `module.[module-id]` for module flags, `exp.[feature]` for experimental, `sys.[capability]` for system. | | `enabled` | The compiled default. This is what ships. Can be overridden per-environment via env vars or at runtime. | | `dependencies` | If flag A depends on flag B, enabling A while B is disabled has no effect -- the system treats A as disabled. | | `overridable` | System-critical flags (e.g., `sys.storage-engine`) should be `false` to prevent accidental runtime changes. | | `requiredRole` | Not enforced today. Reserved for role-based flag filtering when the auth system is implemented. | --- ## Default Flags Configuration All flags are declared in a single file. This is the source of truth. ```typescript // src/config/flags.ts import type { FeatureFlag } from '@/types/flags'; export const defaultFlags: FeatureFlag[] = [ // ─── Module Flags ────────────────────────────────────────── { key: 'module.devize-generator', enabled: true, label: 'Generator Devize', description: 'Activeaza modulul de generare devize.', category: 'module', overridable: true, }, { key: 'module.oferte', enabled: true, label: 'Oferte', description: 'Activeaza modulul de creare si gestionare oferte.', category: 'module', overridable: true, }, { key: 'module.pontaj', enabled: true, label: 'Pontaj', description: 'Activeaza modulul de pontaj si evidenta ore.', category: 'module', overridable: true, }, // ─── Experimental Flags ──────────────────────────────────── { key: 'exp.ai-assistant', enabled: false, label: 'Asistent AI', description: 'Activeaza asistentul AI experimental pentru generare de continut.', category: 'experimental', overridable: true, }, { key: 'exp.dark-mode', enabled: false, label: 'Mod Intunecat', description: 'Activeaza tema intunecata (in testare).', category: 'experimental', overridable: true, }, // ─── System Flags ───────────────────────────────────────── { key: 'sys.offline-mode', enabled: true, label: 'Mod Offline', description: 'Permite functionarea aplicatiei fara conexiune la internet.', category: 'system', overridable: false, }, { key: 'sys.analytics', enabled: false, label: 'Analitice', description: 'Colectare de date de utilizare anonimizate.', category: 'system', overridable: false, }, ]; /** Build a lookup map for O(1) access by key */ export const flagDefaults: Map = new Map( defaultFlags.map((flag) => [flag.key, flag]) ); ``` --- ## FeatureFlagProvider The provider initializes flag state from defaults, applies environment variable overrides, applies runtime overrides from localStorage, resolves dependencies, and exposes the resolved state via context. ```typescript // src/providers/FeatureFlagProvider.tsx 'use client'; import React, { createContext, useContext, useMemo, useState, useCallback } from 'react'; import { defaultFlags, flagDefaults } from '@/config/flags'; import type { FeatureFlag } from '@/types/flags'; interface FlagState { /** Check if a flag is enabled (dependency-resolved) */ isEnabled: (key: string) => boolean; /** Get the full flag definition */ getFlag: (key: string) => FeatureFlag | undefined; /** Set a runtime override (only for overridable flags) */ setOverride: (key: string, enabled: boolean) => void; /** Clear a runtime override */ clearOverride: (key: string) => void; /** All flags with their resolved states */ allFlags: FeatureFlag[]; } const FeatureFlagContext = createContext(null); const STORAGE_KEY = 'architools.flag-overrides'; function loadOverrides(): Record { if (typeof window === 'undefined') return {}; try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } function saveOverrides(overrides: Record): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); } function getEnvOverride(key: string): boolean | undefined { // Convention: NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR=true // Transform flag key: "module.devize-generator" -> "MODULE_DEVIZE_GENERATOR" const envKey = `NEXT_PUBLIC_FLAG_${key.replace(/[.-]/g, '_').toUpperCase()}`; const value = process.env[envKey]; if (value === 'true') return true; if (value === 'false') return false; return undefined; } export function FeatureFlagProvider({ children }: { children: React.ReactNode }) { const [overrides, setOverrides] = useState>(loadOverrides); const resolvedFlags = useMemo(() => { // Step 1: Compute effective enabled state for each flag const effective = new Map(); for (const flag of defaultFlags) { // Priority: env override > runtime override > compiled default const envOverride = getEnvOverride(flag.key); const runtimeOverride = overrides[flag.key]; const baseValue = envOverride ?? runtimeOverride ?? flag.enabled; effective.set(flag.key, baseValue); } // Step 2: Resolve dependencies (a flag is only enabled if all its dependencies are enabled) const resolved = new Map(); function resolve(key: string, visited: Set = new Set()): boolean { if (resolved.has(key)) return resolved.get(key)!; if (visited.has(key)) return false; // circular dependency -- fail closed visited.add(key); const flag = flagDefaults.get(key); if (!flag) return false; let enabled = effective.get(key) ?? false; if (enabled && flag.dependencies?.length) { enabled = flag.dependencies.every((dep) => resolve(dep, visited)); } resolved.set(key, enabled); return enabled; } for (const flag of defaultFlags) { resolve(flag.key); } return resolved; }, [overrides]); const setOverride = useCallback((key: string, enabled: boolean) => { const flag = flagDefaults.get(key); if (!flag?.overridable) { console.warn(`[FeatureFlags] Flag "${key}" is not overridable.`); return; } setOverrides((prev) => { const next = { ...prev, [key]: enabled }; saveOverrides(next); return next; }); }, []); const clearOverride = useCallback((key: string) => { setOverrides((prev) => { const { [key]: _, ...rest } = prev; saveOverrides(rest); return rest; }); }, []); const contextValue = useMemo(() => ({ isEnabled: (key: string) => resolvedFlags.get(key) ?? false, getFlag: (key: string) => flagDefaults.get(key), setOverride, clearOverride, allFlags: defaultFlags.map((f) => ({ ...f, enabled: resolvedFlags.get(f.key) ?? false, })), }), [resolvedFlags, setOverride, clearOverride]); return ( {children} ); } export function useFeatureFlagContext(): FlagState { const context = useContext(FeatureFlagContext); if (!context) { throw new Error('useFeatureFlagContext must be used within a FeatureFlagProvider'); } return context; } ``` ### Provider Placement The provider wraps the entire application layout: ```typescript // src/app/layout.tsx import { FeatureFlagProvider } from '@/providers/FeatureFlagProvider'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` --- ## `useFeatureFlag` Hook The primary consumer API. Returns a boolean. ```typescript // src/hooks/useFeatureFlag.ts 'use client'; import { useFeatureFlagContext } from '@/providers/FeatureFlagProvider'; /** * Check if a feature flag is enabled. * Returns false for unknown keys (fail closed). */ export function useFeatureFlag(key: string): boolean { const { isEnabled } = useFeatureFlagContext(); return isEnabled(key); } ``` ### Usage ```typescript function SomeComponent() { const aiEnabled = useFeatureFlag('exp.ai-assistant'); return (
{aiEnabled && }
); } ``` --- ## `FeatureGate` Component A declarative alternative to the hook, useful when you want to conditionally render a subtree without introducing a new component. ```typescript // src/components/FeatureGate.tsx 'use client'; import { useFeatureFlag } from '@/hooks/useFeatureFlag'; interface FeatureGateProps { flag: string; children: React.ReactNode; fallback?: React.ReactNode; } /** * Renders children only if the specified flag is enabled. * Optionally renders a fallback when disabled. */ export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) { const enabled = useFeatureFlag(flag); return <>{enabled ? children : fallback}; } ``` ### Usage ```tsx }> ``` --- ## Flags and Module Loading The critical integration point: **a disabled flag means the module's JavaScript is never fetched**. This works because of the layered architecture: 1. **Navigation**: The sidebar queries `useFeatureFlag(module.featureFlag)` for each registered module. Disabled modules are excluded from the nav -- the user has no link to click. 2. **Route guard**: The module's route page checks the flag before rendering the `ModuleLoader`. If the flag is disabled, it renders a 404 or redirect. ```typescript // src/app/(modules)/devize/page.tsx import { ModuleLoader } from '@/lib/module-loader'; import { moduleRegistry } from '@/config/modules'; import { useFeatureFlag } from '@/hooks/useFeatureFlag'; import { notFound } from 'next/navigation'; export default function DevizePage() { const config = moduleRegistry.get('devize-generator')!; const enabled = useFeatureFlag(config.featureFlag); if (!enabled) { notFound(); } return ; } ``` 3. **Lazy import**: `React.lazy(() => import(...))` only executes the import when the component renders. Since the route guard prevents rendering when the flag is off, the dynamic `import()` never fires, and the browser never requests the chunk. **Result**: Disabling a module flag removes it from the bundle that the user downloads. There is zero performance cost for disabled modules. --- ## Runtime Override Mechanism Overridable flags can be toggled at runtime through the admin panel or the browser console. Overrides are stored in `localStorage` under the key `architools.flag-overrides`. ### Admin Panel The admin panel (planned) will render all overridable flags as toggles, grouped by category. It uses `setOverride` and `clearOverride` from the context. ### Console API (Development) For development and debugging, flags can be manipulated directly: ```javascript // In the browser console: // Enable a flag localStorage.setItem('architools.flag-overrides', JSON.stringify({ ...JSON.parse(localStorage.getItem('architools.flag-overrides') || '{}'), 'exp.ai-assistant': true }) ); // Then reload the page. ``` A convenience utility can be exposed on `window` in development builds: ```typescript // src/lib/dev-tools.ts if (process.env.NODE_ENV === 'development') { (window as any).__flags = { enable: (key: string) => { /* calls setOverride */ }, disable: (key: string) => { /* calls setOverride */ }, reset: (key: string) => { /* calls clearOverride */ }, list: () => { /* logs all flags with their resolved states */ }, }; } ``` --- ## Flag Categories ### Module Flags (`module.*`) One flag per module. Controls whether the module is loaded and visible. Key must match the `featureFlag` field in the module's `ModuleConfig`. Examples: `module.devize-generator`, `module.oferte`, `module.pontaj`. ### Experimental Flags (`exp.*`) Control features that are under active development or testing. These may cut across modules or affect platform-level behavior. Expected to eventually become permanent (either promoted to always-on or removed). Examples: `exp.ai-assistant`, `exp.dark-mode`. ### System Flags (`sys.*`) Control infrastructure-level capabilities. Typically not overridable. Changing these may require understanding of downstream effects. Examples: `sys.offline-mode`, `sys.analytics`. --- ## Environment Variable Overrides For deployment-time control, flags can be overridden via environment variables. This is useful for: - Enabling experimental features in staging but not production. - Disabling a broken module without a code change. - Per-environment flag profiles in Docker Compose. ### Convention ``` NEXT_PUBLIC_FLAG_=true|false ``` The key is normalized by replacing `.` and `-` with `_` and uppercasing: | Flag Key | Environment Variable | |---|---| | `module.devize-generator` | `NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR` | | `exp.ai-assistant` | `NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT` | | `sys.offline-mode` | `NEXT_PUBLIC_FLAG_SYS_OFFLINE_MODE` | ### Priority Order ``` Environment variable > Runtime override (localStorage) > Compiled default ``` Environment variables take the highest priority because they represent a deployment decision that should not be overridden by stale localStorage state. ### Docker Compose Example ```yaml # docker-compose.staging.yml services: web: environment: - NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT=true - NEXT_PUBLIC_FLAG_SYS_ANALYTICS=true ``` --- ## Anti-Patterns ### 1. Direct Module Import (bypassing flags) ```typescript // WRONG: This imports the module unconditionally -- it will be in the bundle // regardless of the flag state. import { DevizeGenerator } from '@/modules/devize-generator'; ``` Always go through the flag-gated `ModuleLoader` or use `FeatureGate`/`useFeatureFlag`. ### 2. Checking Flags Outside React ```typescript // WRONG: This reads the default, not the resolved state with overrides. import { flagDefaults } from '@/config/flags'; if (flagDefaults.get('module.pontaj')?.enabled) { ... } ``` Always use `useFeatureFlag()` inside a component or `useFeatureFlagContext()` to get the resolved state. ### 3. Hardcoding Flag Values ```typescript // WRONG: This defeats the purpose of having flags. const isAiEnabled = process.env.NODE_ENV === 'production' ? false : true; ``` Use the flag system. If you need environment-specific behavior, use the env var override mechanism. ### 4. Flag Key Mismatch The flag `key` in `flags.ts` must exactly match the `featureFlag` in the module's `config.ts`. A mismatch means the module can never be gated. The registry validation (`validateRegistry`) does not currently check this -- it is enforced by code review and the module development checklist. ### 5. Non-Overridable Flags in the Admin Panel Do not expose `overridable: false` flags in the admin toggle UI. The provider already guards against this, but the UI should also filter them out. --- ## Future: Remote Flag Service The current system is local-first by design. When a remote flag service is needed (e.g., for per-user rollout, A/B testing, or centralized control), the integration point is the `FeatureFlagProvider`: 1. On mount, the provider fetches flags from the remote service. 2. Remote values are merged into the resolution chain with a priority between env vars and runtime overrides: ``` Env var > Remote service > Runtime override > Compiled default ``` 3. The provider caches remote flags in localStorage for offline resilience. 4. Consumer code (`useFeatureFlag`, `FeatureGate`) requires zero changes. The `FeatureFlag` interface and provider API are designed to accommodate this without breaking changes.