79 lines
3.7 KiB
Markdown
79 lines
3.7 KiB
Markdown
# 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<string, boolean>; // 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_<NAME>` 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<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).
|
|
- `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.
|