Repository navigation
Feature Flags
Feature flags (also called feature toggles) are runtime controls that allow teams to enable or disable specific behavior without redeploying code. They separate code deployment from feature release, which is valuable when we need to reduce rollout risk, validate behavior in production, or respond quickly to incidents.
In practical terms, a feature flag is a named decision point in code:
- If flag is ON, execute the new path.
- If flag is OFF, execute the existing stable path.
This approach gives delivery teams safer release mechanics and lets product, engineering, and operations coordinate changes with more precision.
Instead of exposing a change to all users at once, we can gradually increase exposure by role, tenant, or percentage. If we detect regressions, rollback is an immediate flag change, not a full redeploy.
When a new capability causes instability, a kill switch flag can disable the problematic path in seconds while preserving unrelated platform functionality.
Flags can support A/B or canary-style rollout strategies where behavior is intentionally varied to compare outcomes before broad adoption.
Large features can be merged incrementally behind flags, reducing long-lived branches and merge conflicts while still preserving release control.
| Flag Type | Purpose | Typical Lifetime |
|---|---|---|
| Release flag | Gradual launch of new functionality | Short to medium |
| Operational flag (kill switch) | Immediate disablement during incidents | Long-lived |
| Experiment flag | Controlled behavioral variants for learning | Short |
| Permission flag | Entitlements by user role, group, or environment | Medium to long |
- Use explicit, intention-revealing names (for example,
waste-search-v2-enabled). - Keep default behavior documented and deterministic.
- Scope targeting rules clearly (environment, role, tenant, percentage).
- Define owner, review date, and expected removal date for every short-lived flag.
- Add observability around flagged code paths (logs/metrics) so behavior is verifiable.
- Remove stale flags quickly to avoid permanent branching in business logic.
This section defines the canonical naming and mapping contract for new feature flags used by Waste Plus frontend and backend code.
Use lowercase kebab-case for the canonical key:
<domain>-<capability>-enabled
Examples:
waste-search-v2-enabledforest-client-lookup-enabledoffline-mode-enabled
Rules:
- Use positive names (
*-enabled), not negative names (disable-*,no-*). - Keep keys business-oriented and stable across both stacks.
- Do not include environment names in keys (
dev,test,prod). - Do not encode owner/team names in keys.
- Do not use temporary suffixes such as
-new,-temp, or-vnext.
New flags must use the same canonical key string in both layers:
- Backend:
features.flags.<canonical-key>in Spring YAML - Frontend:
VITE_FEATURE_FLAGS["<canonical-key>"]in runtime config
This avoids drift and makes rollout decisions traceable across architecture, configuration, and code.
Some existing flags may use legacy key names. Keep them only until migrated, and record the mapping in this table.
| Canonical Key | Current Frontend Key | Current Backend Key | Status | Removal Target |
|---|---|---|---|---|
offline-mode-enabled |
OFFLINE |
N/A |
Migrated (alias removed) | 2026-04-16 |
When introducing new flags, do not create additional aliases unless a migration is explicitly approved.
Avoid these anti-patterns when creating new keys:
- Same behavior represented by different keys in frontend and backend.
- Name includes implementation detail (
use-new-hook-enabled). - Name is ambiguous (
new-feature-enabled,feature-x-enabled). - Name encodes policy instead of behavior (
staff-only-enabledas a release flag).
Before a new flag is merged, verify all items:
- Canonical key follows
<domain>-<capability>-enabled. - Frontend and backend use the same canonical key.
- Default state is documented per environment.
- Owner, rollout plan, and removal criteria are documented in this page.
- Tests cover enabled, disabled, and undefined behavior.
- A removal target date is assigned for short-lived flags.
Feature flags improve release safety, but they add complexity:
- More conditional paths increase testing surface.
- Forgotten flags become technical debt.
- Poorly governed targeting can produce inconsistent user experience.
This is why governance matters as much as implementation.
This page is intentionally cross-cutting and applies to both frontend and backend architecture. As implementation details are introduced, we can extend this page with:
- Toggle ownership model and naming convention.
- Environment-specific defaults.
- Rollout and rollback runbook examples.
- Testing expectations for flagged behavior.
The frontend already supports environment-scoped feature flags through
VITE_FEATURE_FLAGS, validated and merged at runtime by frontend/src/env.ts.
Frontend flags are resolved from two sources:
- Vite environment (
import.meta.env.VITE_FEATURE_FLAGS) - Runtime-injected config (
window.config.VITE_FEATURE_FLAGS), typically populated by OpenShift ConfigMaps through files such asfrontend/public/data/config.jsandfrontend/public/data/params.js.
At runtime, window.config can be environment-specific, which allows PR,
dev, test, and production deployments to expose different feature states
without rebuilding the frontend bundle.
Feature flags are schema-validated using Zod before being consumed. The current typed contract includes:
-
offline-mode-enabled: boolean(optional) -
bookmark-ru-enabled: boolean(optional) -
reporting-unit-details-enabled: boolean(optional) -
reporting-unit-create-enabled: boolean(optional) -
reporting-unit-block-details-enabled: boolean(optional) -
configuration-enabled: boolean(optional)
Important behavior:
- Unknown keys are rejected (
strictschema). - Invalid values throw a descriptive runtime error.
- String values for
VITE_FEATURE_FLAGSare parsed as JSON. - Missing flags resolve safely to an empty object.
Frontend flags are merged in this order:
- Parsed Vite flags
- Parsed runtime flags from
window.config
Because runtime flags are applied second, they override Vite defaults when both define the same key. This is intentional and enables environment-level control through ConfigMaps.
A deployment workflow can inject frontend flags using a ConfigMap payload such as:
configmap-frontend: |-
window.config = {
...(window.config ?? {}),
VITE_FEATURE_FLAGS: {
'offline-mode-enabled': false,
},
};This pattern allows operators to change feature exposure per environment while keeping application code and container image unchanged.
To add a new flag safely:
- Define a canonical key using this page's naming contract.
- Declare it in
featureFlagsSchemainfrontend/src/env.ts. - Consume it through the exported
featureFlagsobject. - Add it to environment-specific ConfigMap content when rollout is needed.
- Document owner, rollout plan, and removal criteria in this page.
The backend supports feature flags through Spring Boot's @ConfigurationProperties
mechanism, loaded from application.yml files specific to each deployment profile.
The implementation is provided by FeatureFlagsConfiguration and can be injected
into any Spring component.
Backend flags are defined in environment-specific YAML files under the features.flags
prefix:
features:
flags:
multi-address-enabled: true
legacy-search-enabled: false
staff-match-enabled: trueFlags are loaded at Spring Boot startup and validated through the FeatureFlagsConfiguration
component, which is autowired wherever flag decisions are needed.
The component provides a single method for safe flag queries:
public boolean isEnabled(String flag)Behavior:
- Returns
trueonly if the flag exists in the map AND is explicitly set totrue. - Returns
falsefor flags not found or set tofalse. - Unknown flags default safely to
false(disabled). - Null values are treated as
false.
Inject the configuration in a service or controller:
@Service
public class MyService {
private final FeatureFlagsConfiguration featureFlags;
public MyService(FeatureFlagsConfiguration featureFlags) {
this.featureFlags = featureFlags;
}
public void handleRequest(Request request) {
if (featureFlags.isEnabled("multi-address-enabled")) {
// Use new multi-address logic
handleMultiAddressPath(request);
} else {
// Fall back to single-address logic
handleSingleAddressPath(request);
}
}
}FeatureFlagsConfiguration keeps startup visibility while adapting message
detail to the environment audience:
- Production profiles (
prod,production): log an INFO summary only (total,enabled,disabled) and emit per-flag detail at DEBUG. - Non-production profiles: log per-flag state at INFO for faster debugging.
This avoids overexposing detailed toggle state in broad operational channels while preserving deep detail for responders when DEBUG logging is enabled.
Every call to FeatureFlagsConfiguration.isEnabled(...) records a Micrometer
counter:
- Metric name:
hrs.feature.flag.evaluations - Tags:
-
flag: canonical flag key -
value: evaluated result (trueorfalse)
-
This provides low-cost, always-on observability for critical decision points without requiring additional instrumentation in each caller.
Unit Test — Build flags directly:
@Test
@DisplayName("should return true for an enabled flag")
void shouldReturnTrueForEnabledFlag() {
FeatureFlagsConfiguration featureFlags = FeatureFlagsConfiguration.builder()
.flags(Map.of("my-feature", true))
.build();
assertThat(featureFlags.isEnabled("my-feature")).isTrue();
}
@Test
@DisplayName("should return false for a non-existent flag")
void shouldReturnFalseForNonExistentFlag() {
FeatureFlagsConfiguration featureFlags = FeatureFlagsConfiguration.builder()
.flags(Map.of())
.build();
assertThat(featureFlags.isEnabled("missing-flag")).isFalse();
}Integration Test — Load flags from application.yml:
@DisplayName("Integrated Test | FeatureFlagsConfiguration")
class FeatureFlagsConfigurationIntegrationTest extends AbstractTestContainerIntegrationTest {
@Autowired
private FeatureFlagsConfiguration featureFlagsConfiguration;
@Test
@DisplayName("should load enabled flag from application-default.yml")
void shouldLoadEnabledFlag() {
assertThat(featureFlagsConfiguration.isEnabled("enabled-flag")).isTrue();
}
@Test
@DisplayName("should return false for a flag not defined in configuration")
void shouldReturnFalseForUndefinedFlag() {
assertThat(featureFlagsConfiguration.isEnabled("non-existent-flag")).isFalse();
}
}To add a new flag:
-
Use the same canonical key defined for frontend.
-
Add the flag to your
application.yml(or environment-specific variant) underfeatures.flags.<canonical-key>:features: flags: waste-search-v2-enabled: true
-
Inject
FeatureFlagsConfigurationinto the component that needs the flag. -
Call
featureFlags.isEnabled("waste-search-v2-enabled")to guard the behavior. -
Add unit tests to verify the flag path executes correctly.
-
Add integration tests that load the flag from a test YAML file.
-
Document the flag owner, rollout plan, and removal criteria in this page.
Flags can be scoped by Spring profile using separate application files:
-
application.yml— shared defaults -
application-dev.yml— development flags -
application-test.yml— test-specific flags -
application-prod.yml— production flags
This allows different environments to have different flag states without code changes.
This section describes a low-risk migration path from the current custom flag implementation to an OpenFeature-style model. The intent is to improve portability and governance without forcing a large rewrite.
Stay custom for now when all are true:
- Flags are mostly global on/off toggles.
- One configuration source is sufficient.
- No near-term requirement for user/tenant targeting or experiments.
Start OpenFeature alignment when any are true:
- Targeting by user, role, tenant, or region is needed.
- Experiment or canary evaluation is needed.
- A provider swap may be needed (for example vendor-hosted flag service).
- Multiple services need a consistent cross-platform evaluation contract.
Do not rewrite all consumers immediately. Introduce an adapter layer that wraps the current implementation and exposes a stable evaluation API.
Recommended sequence:
- Introduce a small feature flag client interface in each stack.
- Implement it with the current custom source (
FeatureFlagsConfigurationand frontendfeatureFlagsobject). - Route only new call sites through this client first.
- Migrate existing call sites gradually during normal feature work.
- Add an OpenFeature-backed provider implementation behind the same interface.
- Switch provider wiring by environment or startup config.
This avoids dead-end wrappers because the interface becomes the long-term seam, not a temporary compatibility layer.
Keep the adapter API intentionally small:
getBooleanValue(flagKey, defaultValue)- Optional overload with context for future targeting
- Optional metadata output only if operationally required
Avoid adding provider-specific methods to this contract.
Add an application-level client abstraction:
public interface FeatureFlagClient {
boolean getBooleanValue(String flagKey, boolean defaultValue);
}Start with a custom adapter backed by FeatureFlagsConfiguration, then add an
OpenFeature adapter later. Existing services should depend on
FeatureFlagClient, not directly on provider SDKs.
Centralize flag reads in one module (or hook) and avoid direct key access in many components. Keep current Zod validation and canonical key typing, but move evaluation calls behind a single exported API.
This allows frontend provider changes (or OpenFeature Web SDK adoption) without touching most component code.
Do not add context until targeting/experiments are required.
When needed, introduce context in phases:
- Define a minimal context schema (for example
role,clientType,region). - Populate context at one boundary (request/session bootstrap), not ad hoc in every call site.
- Validate and sanitize context values before evaluation.
- Add observability for context-aware evaluations (counts and error paths).
- Keep canonical key ownership in this page and in typed key sources.
- Keep evaluation API provider-agnostic.
- Keep rollout and rollback behavior outside provider-specific code.
- Do not expose SDK-specific objects outside adapter packages.
- Require tests at adapter boundary for enabled/disabled/default behavior.
For this project, "OpenFeature enough" means:
- Consumers depend on a stable feature flag client abstraction.
- One provider can be swapped for another without consumer rewrites.
- Evaluation defaults, observability, and governance remain consistent.
- Context support exists as an optional extension, not a hard dependency.
Use this procedure when a flagged code path causes customer or platform impact.
- Elevated error rate, latency, or timeout alerts linked to a recent rollout.
- Verified user-impacting defect isolated to a flagged path.
- Security or data-integrity concern tied to newly enabled behavior.
-
Identify the owning flag and confirm current state from startup logs and/or config source.
-
Set the kill switch OFF in the environment configuration source. Frontend:
window.config.VITE_FEATURE_FLAGS['<flag>'] = false. Backend:features.flags.<flag>: false. -
Apply configuration through the standard deployment/config pipeline.
-
Verify state. Check backend startup summary and DEBUG per-flag logs where needed, then confirm
hrs.feature.flag.evaluations{flag='<flag>',value='false'}increases on affected traffic. -
Validate functional recovery on the impacted journey.
-
Post incident update: create or update the incident record, document timeline/blast radius/recovery evidence, and decide whether to keep the flag disabled, patch and re-enable, or retire.
- Do not delete the flag during active incident response.
- Keep rollback changes minimal: flip flag state before broader code changes.
- If rollback does not reduce impact, escalate to full deployment rollback.
This table is maintained by the code-docs and architecture-maintainer skills.
Every time a documented component, class, or architectural feature is found to be
behind a feature flag, the skill adds or updates the corresponding row.
When adding a row manually, include all required governance fields. Do not add rows for test-only flags (flags that only appear in test YAML files and are never checked in production code).
| Flag Name | Owner | Type | Default State by Environment | Created Date | Review Date | Target Removal Date |
|---|---|---|---|---|---|---|
offline-mode-enabled |
Waste Plus Frontend Team | Operational | local/dev: true; PR/test/prod: false | 2026-04-16 | 2026-05-14 | N/A (long-lived operational flag) |
user-identity-persistence-enabled |
Waste Plus Backend Team | Permission / Privacy Gate | all environments: false | 2026-04-20 | 2026-07-20 | After PIA and STRA are updated to cover local persistence of Cognito identity attributes; long-lived until governance approval |
bookmark-ru-enabled |
Waste Plus | Release | defaults per environment (note: test YAML sets this to true) | 2026-04-21 | N/A | N/A |
reporting-unit-details-enabled |
Waste Plus Backend Team | Release | all environments: false (test YAML: true) | 2026-05-14 | N/A | After full rollout is confirmed stable across all environments |
reporting-unit-create-enabled |
Waste Plus Frontend Team | Release | local/PR: true; test/prod: not set (defaults to disabled) | 2026-06-02 | N/A | N/A |
configuration-enabled |
Waste Plus Frontend Team | Release | local/dev/PR/test: true; prod: not set (defaults to disabled) | 2026-06-22 | 2026-08-10 | N/A |
reporting-unit-block-details-enabled |
Waste Plus Frontend Team | Release | local: true; PR preview: true; e2e CI: true; test/prod: not set (defaults to disabled); a11y CI: not set (test injects it at runtime) | 2026-10-05 | N/A | After full rollout is confirmed stable across all environments |
Added 2026-10-05 for issue #1369 (parent epic #625). One flag gates two surfaces (user decision D9):
-
Block details route —
/reporting-units/$ruId/$blockId(ReportingUnitBlockDetailsPage) is the file routefrontend/src/routes/_authenticated/reporting-units/$ruId/$blockId.tsx. It gates the flag inbeforeLoad: when the flag is off, the route throws a router-levelnotFound()— an auth-aware 404 that sends unauthenticated visitors to/and rendersNotFoundPagefor authenticated users. The route is not side-nav visible, so it declares nostaticData.featureFlag; the flag is enforced inbeforeLoadonly. The route sits directly under the_authenticatedgate, so any authenticated user can reach it while the flag is on; role restrictions in this router come from the tier layouts (_admin,_client-access), and this route belongs to neither. That is unchanged from the legacy registry's final state: the earlier D9-eraroles: ADMINgate was dropped by #1254 (block creation and block details, 2026-10-13), before the migration — the file route preserves the registry's final behaviour exactly. -
Blocks list exhibition —
frontend/src/pages/ReportingUnitDetails/index.tsxrenders{featureFlags['reporting-unit-block-details-enabled'] && <ReportingUnitBlocksList />}belowReportingUnitDetailsTombstone. TheReportingUnitBlocksListcomponent itself is flag-agnostic.
The key sits in featureFlagsSchema in frontend/src/env.ts directly after
reporting-unit-create-enabled. Default is disabled in builds that do not set
the flag (strict schema, unknown keys rejected). Values load from
VITE_FEATURE_FLAGS JSON (.env or CI env) and from window.config runtime
injection, with runtime values merged over Vite flags per key.
Environment matrix:
| Environment | Source | Flag state |
|---|---|---|
| PR preview |
.github/workflows/pr-open.yml (configmap-frontend) |
true |
| TEST / PROD |
.github/workflows/merge.yml (both configmap blocks) |
absent — off, matching the sibling reporting-unit flags |
| E2E CI |
.github/workflows/reusable-tests-fe.yml VITE_FEATURE_FLAGS
|
true |
| A11Y CI |
.github/workflows/reusable-tests-fe-extra.yml VITE_FEATURE_FLAGS
|
absent (only offline-mode-enabled: false); the block details a11y test injects the flag at runtime through a params.js route override |
| Local dev |
frontend/.env line 2 (gitignored) |
true |
This wiki documents NR Waste Plus for users and developers. Verify security and deployment guidance against the current application configuration before applying it elsewhere.
- Report an issue or ask a question in the GitHub repository.
- Follow the Contributing Guidelines to propose an update.
- Read the Code of Conduct.
Collection indexes support human browsing and machine retrieval. Keep both current when adding or moving wiki pages.
NR Waste Plus Wiki | GitHub Repository