3.7 KiB
3.7 KiB
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 onprocess.env; takes an env object so tests are deterministic. Throws a singleConfigErrorlisting every problem found.src/modules/flags/— the feature-flag module. Business-facing, boundary-checked. Exposes onlyindex.ts. Contains theFeatureFlagProviderinterface 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)
export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
host: string;
logLevel: string;
databaseUrl: string; // required
redisUrl?: string; // optional
flags: Record<string, boolean>; // parsed FLAG_* vars (initial seed)
}
export class ConfigError extends Error { problems: string[] }
export function loadConfig(env: StringRecord): AppConfig
DATABASE_URLrequired: missing -> ConfigError naming the var.PORTparsed to int, validated 1..65535.NODE_ENVwhitelisted, defaultdevelopment.FLAG_<NAME>vars: valuetrue/false(case-insensitive) -> boolean; other values -> ConfigError naming the var. Names lowercased after strippingFLAG_prefix.- Accumulate all problems, then throw once (fail fast AND clear).
Feature flag module
export interface FeatureFlagProvider {
isEnabled(name: string): boolean;
}
export class InMemoryFeatureFlagStore implements FeatureFlagProvider {
constructor(initial?: Record<string, boolean>)
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).
setEnabledmutates live state: deployment ≠ activation.- Guarded-path helper kept trivial; consumers do
if (flags.isEnabled('x')) { ... }.
Wiring (composition root)
src/app/build-app.tsstays DB-free for now; butbuildAppaccepts an optionalflagsdep so routes can guard paths. Default: empty store (all flags off).src/infrastructure/http/server.tscallsloadConfig(process.env), uses typedport/host, and passesconfig.flagsinto the app. On ConfigError it logs the problems and exits non-zero.pool.tskeepscreatePoolFromEnvbut gains an optional typed overload note; no breaking change needed this ticket (config owns DATABASE_URL validation at startup).
Boundary notes
flagsmodule: only imports fromshared(currently nothing needed). It must not importinfrastructure/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.