Three how-it-works/how-to-extend reference docs (docs/reference/), each with a coupling section, indexed in docs/README.md. New document-feature skill so docs ship in the same diff as the code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.9 KiB
Feature flags — how they're built, extended & (de)coupled
Runtime on/off switches for features. The split, deliberately mirroring stamdata: the catalog is config-as-code (build-validated), the on/off state is runtime (SQLite, like org-templates). Enforcement is end-to-end — the FE hides the surface and the backend refuses the action. Built in WP-47; see ADR-0004 for the catalog-vs-state reasoning.
The one rule: hiding is not enforcing
A flag gates the UI for feel and the endpoint for real. Flipping inschrijving-open
off both removes the "Inschrijven" nav/dashboard action and makes POST /applications
return 403. Never gate only the UI — a hidden button is not a closed door.
Layered pipeline
Backend:
Domain/Features/FeatureFlags.cs— the code catalog (FeatureFlagDef(Key, Description, DefaultEnabled)). One flag today:inschrijving-open(default on).Data/FeatureFlagStore.cs— stores only overrides in SQLite;All()overlays catalog defaults with overrides;IsEnabledis fail-closed on unknown keys;Set(key, …)rejects unknown keys (→ 404).Program.cs:GET /flags(readable by any principal — drives FE gating),PUT /admin/flags/{key}gated byFlagsAdmin; the server-side enforcement lives at the guarded endpoint (POST /applications→ 403 when off).
Frontend — note this feature lives in shared, not beheer (it's consumed app-wide):
shared/domain/feature-flag.ts(FeatureFlag+FLAG_INSCHRIJVING_OPENkey constant) →shared/infrastructure/feature-flags.adapter.ts(list()/set()+parseFlags) →shared/application/feature-flags.store.ts(root singleton mirroringAccessStore; loads once;enabled(key)is deny-by-default and reactive).beheer/ui/feature-flags.page.ts— the admin toggle at/beheer/functies, gated onflags:manage.
How to add a flag
- Add a
FeatureFlagDeftoFeatureFlags.csand a key constant toshared/domain/feature-flag.ts. - Enforce it server-side at the endpoint the flag protects (return 403 when off) — this is the non-negotiable half.
- Gate the UI surface(s) by reading
store.enabled(KEY). - Cover it in
backend/tests/BigRegister.Tests/FeatureFlagTests.cs(default, admin-only toggle, unknown-key 404, off→403 / on→201).
Coupling — the one to watch
This is the most-coupled of the three admin features, and the honest teaching point.
Each UI consumer injects FeatureFlagStore, imports the flag-key constant, and hand-writes
its own gating predicate inline:
shared/layout/site-header/site-header.component.ts— filters the "Inschrijven" nav item.registratie/ui/dashboard.page.ts— hides the "Inschrijven" dashboard action.
So a second flag with a second consumer repeats the pattern by hand — there's no shared
"gate this thing by flag" abstraction. That's fine at one flag / two consumers (a helper for
a single case is speculative). Recommended: when a second flag lands, extract a small
gateByFlag(items, key) / a structural directive rather than growing more inline
.filter(… || enabled(KEY)) copies. Document the intent now; don't build the abstraction
until the second case forces it.
Wire-up gotcha
PUT /admin/flags must be in the role.interceptor ROLE_AWARE list or the toggle
403s silently; the public GET /flags needs no X-Role. Admin nav lives in ADMIN_LINKS
(flags:manage).
See also
- WP-47 — the build.
- ADR-0004 — catalog-as-code vs runtime state.
backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs— the catalog.src/app/shared/application/feature-flags.store.ts— the deny-by-default store.- Roles & access —
flags:manage+ admin gating.