Skip to content

Feature Flags

Paulo Gomes da Cruz Junior edited this page Oct 23, 2026 · 3 revisions

Feature Flags — Waste Plus

Overview

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.


Why Feature Flags Matter

Safer progressive rollout

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.

Faster incident mitigation

When a new capability causes instability, a kill switch flag can disable the problematic path in seconds while preserving unrelated platform functionality.

Controlled experimentation

Flags can support A/B or canary-style rollout strategies where behavior is intentionally varied to compare outcomes before broad adoption.

Cleaner release planning

Large features can be merged incrementally behind flags, reducing long-lived branches and merge conflicts while still preserving release control.


Common Flag Types

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

Good Practices

  • 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.

Cross-Stack Naming Contract

This section defines the canonical naming and mapping contract for new feature flags used by Waste Plus frontend and backend code.

Canonical flag key format

Use lowercase kebab-case for the canonical key:

<domain>-<capability>-enabled

Examples:

  • waste-search-v2-enabled
  • forest-client-lookup-enabled
  • offline-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.

Frontend and backend mapping

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.

Legacy compatibility

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.

Prohibited patterns

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-enabled as a release flag).

New Flag Authoring Checklist

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.

Risks and Trade-offs

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.


How This Relates to Waste Plus

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.

Frontend Implementation (Current)

The frontend already supports environment-scoped feature flags through VITE_FEATURE_FLAGS, validated and merged at runtime by frontend/src/env.ts.

Where frontend flags come from

Frontend flags are resolved from two sources:

  1. Vite environment (import.meta.env.VITE_FEATURE_FLAGS)
  2. Runtime-injected config (window.config.VITE_FEATURE_FLAGS), typically populated by OpenShift ConfigMaps through files such as frontend/public/data/config.js and frontend/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.

Validation and safety contract

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 (strict schema).
  • Invalid values throw a descriptive runtime error.
  • String values for VITE_FEATURE_FLAGS are parsed as JSON.
  • Missing flags resolve safely to an empty object.

Merge precedence

Frontend flags are merged in this order:

  1. Parsed Vite flags
  2. 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.

OpenShift ConfigMap pattern

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.

Adding a new frontend flag

To add a new flag safely:

  1. Define a canonical key using this page's naming contract.
  2. Declare it in featureFlagsSchema in frontend/src/env.ts.
  3. Consume it through the exported featureFlags object.
  4. Add it to environment-specific ConfigMap content when rollout is needed.
  5. Document owner, rollout plan, and removal criteria in this page.

Backend Implementation (Current)

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.

Where backend flags come from

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: true

Flags are loaded at Spring Boot startup and validated through the FeatureFlagsConfiguration component, which is autowired wherever flag decisions are needed.

The FeatureFlagsConfiguration API

The component provides a single method for safe flag queries:

public boolean isEnabled(String flag)

Behavior:

  • Returns true only if the flag exists in the map AND is explicitly set to true.
  • Returns false for flags not found or set to false.
  • Unknown flags default safely to false (disabled).
  • Null values are treated as false.

Usage example (backend)

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);
    }
  }
}

Startup logging and observability

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.

Flag evaluation metrics

Every call to FeatureFlagsConfiguration.isEnabled(...) records a Micrometer counter:

  • Metric name: hrs.feature.flag.evaluations
  • Tags:
    • flag: canonical flag key
    • value: evaluated result (true or false)

This provides low-cost, always-on observability for critical decision points without requiring additional instrumentation in each caller.

Test patterns (backend)

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();
  }
}

Adding a new backend flag

To add a new flag:

  1. Use the same canonical key defined for frontend.

  2. Add the flag to your application.yml (or environment-specific variant) under features.flags.<canonical-key>:

    features:
      flags:
        waste-search-v2-enabled: true
  3. Inject FeatureFlagsConfiguration into the component that needs the flag.

  4. Call featureFlags.isEnabled("waste-search-v2-enabled") to guard the behavior.

  5. Add unit tests to verify the flag path executes correctly.

  6. Add integration tests that load the flag from a test YAML file.

  7. Document the flag owner, rollout plan, and removal criteria in this page.

Configuration profiles and environment separation

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.


OpenFeature Alignment Path (Optional, Strategic)

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.

Decision criteria: stay custom or align now

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.

Migration principle: adapter first, consumers later

Do not rewrite all consumers immediately. Introduce an adapter layer that wraps the current implementation and exposes a stable evaluation API.

Recommended sequence:

  1. Introduce a small feature flag client interface in each stack.
  2. Implement it with the current custom source (FeatureFlagsConfiguration and frontend featureFlags object).
  3. Route only new call sites through this client first.
  4. Migrate existing call sites gradually during normal feature work.
  5. Add an OpenFeature-backed provider implementation behind the same interface.
  6. 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.

Minimum adapter contract

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.

Backend migration sketch

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.

Frontend migration sketch

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.

Evaluation context strategy (only when needed)

Do not add context until targeting/experiments are required.

When needed, introduce context in phases:

  1. Define a minimal context schema (for example role, clientType, region).
  2. Populate context at one boundary (request/session bootstrap), not ad hoc in every call site.
  3. Validate and sanitize context values before evaluation.
  4. Add observability for context-aware evaluations (counts and error paths).

Guardrails to avoid dead-end code

  • 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.

Definition of "OpenFeature enough"

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.

Rollback Runbook (Kill Switch)

Use this procedure when a flagged code path causes customer or platform impact.

Trigger conditions

  • 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.

Response steps

  1. Identify the owning flag and confirm current state from startup logs and/or config source.

  2. Set the kill switch OFF in the environment configuration source. Frontend: window.config.VITE_FEATURE_FLAGS['<flag>'] = false. Backend: features.flags.<flag>: false.

  3. Apply configuration through the standard deployment/config pipeline.

  4. 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.

  5. Validate functional recovery on the impacted journey.

  6. 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.

Guardrails

  • 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.

Flag Registry

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

Flag detail: reporting-unit-block-details-enabled

Added 2026-10-05 for issue #1369 (parent epic #625). One flag gates two surfaces (user decision D9):

  1. Block details route — /reporting-units/$ruId/$blockId (ReportingUnitBlockDetailsPage) is the file route frontend/src/routes/_authenticated/reporting-units/$ruId/$blockId.tsx. It gates the flag in beforeLoad: when the flag is off, the route throws a router-level notFound() — an auth-aware 404 that sends unauthenticated visitors to / and renders NotFoundPage for authenticated users. The route is not side-nav visible, so it declares no staticData.featureFlag; the flag is enforced in beforeLoad only. The route sits directly under the _authenticated gate, 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-era roles: ADMIN gate 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.
  2. Blocks list exhibition — frontend/src/pages/ReportingUnitDetails/index.tsx renders {featureFlags['reporting-unit-block-details-enabled'] && <ReportingUnitBlocksList />} below ReportingUnitDetailsTombstone. The ReportingUnitBlocksList component 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

References

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally