Files
ArchiTools/docs/architecture/FEATURE-FLAGS.md
Marius Tarau 4c46e8bcdd Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules:
Email Signature, Word XML Generator, Registratura, Dashboard,
Tag Manager, IT Inventory, Address Book, Password Vault,
Mini Utilities, Prompt Generator, Digital Signatures,
Word Templates, and AI Chat.

Includes core platform systems (module registry, feature flags,
storage abstraction, i18n, theming, auth stub, tagging),
16 technical documentation files, Docker deployment config,
and legacy HTML tool reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:50:25 +02:00

18 KiB

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

// 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.

// 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<string, FeatureFlag> = 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.

// 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<FlagState | null>(null);

const STORAGE_KEY = 'architools.flag-overrides';

function loadOverrides(): Record<string, boolean> {
  if (typeof window === 'undefined') return {};
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    return raw ? JSON.parse(raw) : {};
  } catch {
    return {};
  }
}

function saveOverrides(overrides: Record<string, boolean>): 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<Record<string, boolean>>(loadOverrides);

  const resolvedFlags = useMemo(() => {
    // Step 1: Compute effective enabled state for each flag
    const effective = new Map<string, boolean>();

    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<string, boolean>();

    function resolve(key: string, visited: Set<string> = 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<FlagState>(() => ({
    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 (
    <FeatureFlagContext.Provider value={contextValue}>
      {children}
    </FeatureFlagContext.Provider>
  );
}

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:

// src/app/layout.tsx

import { FeatureFlagProvider } from '@/providers/FeatureFlagProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ro">
      <body>
        <FeatureFlagProvider>
          {children}
        </FeatureFlagProvider>
      </body>
    </html>
  );
}

useFeatureFlag Hook

The primary consumer API. Returns a boolean.

// 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

function SomeComponent() {
  const aiEnabled = useFeatureFlag('exp.ai-assistant');

  return (
    <div>
      {aiEnabled && <AiSuggestionPanel />}
    </div>
  );
}

FeatureGate Component

A declarative alternative to the hook, useful when you want to conditionally render a subtree without introducing a new component.

// 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

<FeatureGate flag="exp.dark-mode">
  <ThemeToggle />
</FeatureGate>

<FeatureGate flag="module.pontaj" fallback={<ModuleDisabledNotice />}>
  <PontajWidget />
</FeatureGate>

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.

// 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 <ModuleLoader config={config} />;
}
  1. 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:

// 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:

// 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_<NORMALIZED_KEY>=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

# 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)

// 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

// 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

// 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.