# DESIGN — F-004 Typed config and feature flags ## Layering - `src/infrastructure/config/config.ts` — pure config loader. Owns env parsing and validation. No side effects on `process.env`; takes an env object so tests are deterministic. Throws a single `ConfigError` listing every problem found. - `src/modules/flags/` — the feature-flag module. Business-facing, boundary-checked. Exposes only `index.ts`. Contains the `FeatureFlagProvider` interface and a simple in-memory store. Does NOT depend on infrastructure/config (kept independent so it can later be backed by a DB/external service without touching config). ## Config loader (infrastructure) ```ts export interface AppConfig { nodeEnv: 'development' | 'test' | 'production'; port: number; host: string; logLevel: string; databaseUrl: string; // required redisUrl?: string; // optional flags: Record; // parsed FLAG_* vars (initial seed) } export class ConfigError extends Error { problems: string[] } export function loadConfig(env: StringRecord): AppConfig ``` - `DATABASE_URL` required: missing -> ConfigError naming the var. - `PORT` parsed to int, validated 1..65535. - `NODE_ENV` whitelisted, default `development`. - `FLAG_` vars: value `true`/`false` (case-insensitive) -> boolean; other values -> ConfigError naming the var. Names lowercased after stripping `FLAG_` prefix. - Accumulate all problems, then throw once (fail fast AND clear). ## Feature flag module ```ts export interface FeatureFlagProvider { isEnabled(name: string): boolean; } export class InMemoryFeatureFlagStore implements FeatureFlagProvider { constructor(initial?: Record) isEnabled(name): boolean // unknown flag -> false (fail-safe default) setEnabled(name, enabled): void // runtime mutation, no redeploy needed } export function createFlagStore(initial?): InMemoryFeatureFlagStore ``` - Unknown flags are OFF by default (fail-safe: risky paths stay skipped). - `setEnabled` mutates live state: deployment ≠ activation. - Guarded-path helper kept trivial; consumers do `if (flags.isEnabled('x')) { ... }`. ## Wiring (composition root) - `src/app/build-app.ts` stays DB-free for now; but `buildApp` accepts an optional `flags` dep so routes can guard paths. Default: empty store (all flags off). - `src/infrastructure/http/server.ts` calls `loadConfig(process.env)`, uses typed `port`/`host`, and passes `config.flags` into the app. On ConfigError it logs the problems and exits non-zero. - `pool.ts` keeps `createPoolFromEnv` but gains an optional typed overload note; no breaking change needed this ticket (config owns DATABASE_URL validation at startup). ## Boundary notes - `flags` module: only imports from `shared` (currently nothing needed). It must not import `infrastructure/config`. Config parses FLAG_* into a plain record and hands it to the module at composition time — inversion keeps the module clean. - Boundary checker will enforce this automatically. ## Files | File | Role | |---|---| | src/infrastructure/config/config.ts | typed loader + ConfigError | | src/infrastructure/config/tests/config.test.ts | loader unit tests | | src/modules/flags/index.ts | module public API | | src/modules/flags/domain/feature-flag-store.ts | interface + store | | src/modules/flags/tests/feature-flag-store.test.ts | store unit tests | | src/app/build-app.ts | optional flags dep injection | | src/infrastructure/http/server.ts | loadConfig + typed listen + ConfigError exit | ## Risks / mitigations - Silent flag default: unknown -> false, documented; tested. - ConfigError leaking secrets: messages name var NAMES only, never values. - Boundary violation (flags importing config): prevented by composition-time injection + checker.