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>
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:
- Module gating -- a module whose flag is disabled is never imported, never bundled into the active page, and never visible in navigation.
- Incremental rollout -- experimental features can be enabled for specific users or environments without a separate deployment.
- 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:
-
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. -
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} />;
}
- 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 dynamicimport()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:
- On mount, the provider fetches flags from the remote service.
- 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 - The provider caches remote flags in localStorage for offline resilience.
- Consumer code (
useFeatureFlag,FeatureGate) requires zero changes.
The FeatureFlag interface and provider API are designed to accommodate this without breaking changes.