Feature flags
|
The repository’s own convention, stated in capitals in the README: feature flags "SHOULD BE USED ON ALL MODULES BY DEFAULT". New modules and routes are expected to be flag-gated. See decision D7. |
The vocabulary
src/app/shared/feature-flags/feature-flags.const.ts holds the FEATURE_FLAGS
enum — the single list of valid flag names. Always add to it; never use a bare string.
Current flags and what they gate:
| Flag | Gates |
|---|---|
|
The supplier health tab and health cards. |
|
A simpler task-counter query in |
|
The |
|
The bank-transaction-categories underwriting tab. |
|
EVA-rendered onboarding summary. |
|
The onboarding ML-prediction underwriting tab. |
|
The daily ML-prediction underwriting tab. |
|
Supplier two-factor management. |
|
Requires UW tool results before approval. |
|
The PYB documents backend. |
|
EVA-rendered supplier menu. |
|
Starts |
|
Supplier account deletion. |
|
The Airwallex card programme. |
Also relevant, in feature-flags.enum.ts:
export enum FEATURE_FLAGS_ENV { IA = 'IA', EVA = 'EVA' }
export enum FEATURE_FLAGS_ENV_GLOBAL_SETTINGS_KEY {
IA = 'FEATURE_FLAGS_IA',
EVA = 'FEATURE_FLAGS_EVA',
}
Flags are managed for two applications: this one and EVA.
The three layers
┌───────────────────────────────────────────────────────────────────┐
│ 1. BUILD environment.featureFlags │
│ a comma-separated string, no spaces │
│ baked in by deployment/set-env.ts from │
│ $FEATURE_FLAGS at build time │
│ → FeatureFlagsService.featureFlags │
├───────────────────────────────────────────────────────────────────┤
│ 2. GLOBAL SETTINGS Actions Tools setting FEATURE_FLAGS_IA / _EVA │
│ config_value = { flags: 'A,B,C', lastChange: n } │
│ environment-wide, editable in DEV Admin │
├───────────────────────────────────────────────────────────────────┤
│ 3. SESSION sessionStorage['FeatureExecFlags'] │
│ + ['FeatureExecFlagsLastChange'] │
│ (EVA: 'FeatureExecFlagsEva' / …'LastChangeEva') │
│ per-tab overrides, set in DEV Admin │
└───────────────────────────────────────────────────────────────────┘
↓
resolved "exec" set
An empty featureFlags string means all build flags are disabled — flags are opt-in,
never opt-out.
Precedence
FeatureFlagsService.getRecalculatedFlagsStatus() decides. For the IA environment:
-
availableFlags= every member of theFEATURE_FLAGSenum. -
buildProvidedFlags= parsedenvironment.featureFlags. -
globalSettingsProvidedFlags= parsed from theFEATURE_FLAGS_IAsetting. -
execFlagsstarts as a copy of the global settings set — note that build flags do not seed it. -
Session overrides are applied only if they are newer than the global-settings record:
const lastChangedInStorage = this.getLastChangedByKey(); // setting's lastChange const lastChangedInSession = this.getLastChangeExecFlagsFromSession(); if (lastChangedInSession > lastChangedInStorage || lastChangedInStorage === 0) { availableFlagExecStatus = this.getAvailableFlagsExecStatus(); // use session } else { availableFlagExecStatus = []; // discard session } -
applyExecFlagsthen adds everytrueoverride and removes everyfalseone. -
The result is one row per flag:
{ key, build, exec, globalSettings }.
|
Two non-obvious consequences. Global settings beat build flags. The exec set is seeded from global settings, so a
flag baked into the bundle but absent from Editing the global setting invalidates session overrides. Writing the setting stamps a
new |
isEnabled at runtime
isEnabled(feature: FEATURE_FLAGS): boolean {
const flags = [...this.featureFlags]; // build flags
this.applyExecFlags(this.getAvailableFlagsExecStatus(), flags); // + session overrides
if (flags.length === 0 || typeof feature !== 'string') return false;
return flags.findIndex((f) => f === feature) >= 0;
}
|
The practical implication: |
Initialisation
FeatureFlagsGeneralGuard (src/app/guards/feature-flags-general.guard.ts) is on
every feature route, second in the guard order:
return this.store.select('auth').pipe(
filter((data) => data && data.user), // wait for a logged-in user
first(),
switchMap(() => from(this.featureFlagsService.initFF())),
tap(() => { if (!environment.production) this.displayFeatureFlagsStatus(); }),
map(() => true),
catchError(() => of(true)), // never block on flag failure
);
Three things to note:
-
It waits for authentication — flags cannot resolve before login, because the global-settings read needs a token.
-
It always resolves
true. A flag-service outage degrades to build-flag behaviour rather than locking users out. -
In non-production it logs the resolved table once per page load:
[Feature Flags] Current Functionality Status: SUPPLIER_ACCOUNT_HEALTH: Enabled TASKS_MID_VER_SIMPLE_QUERY: Disabled …
initFF() itself:
async initFF(): Promise<void> {
this.featureFlags = this.parseRawFlags(environment.featureFlags);
const flags = await this.getRecalculatedFlagsStatus();
this.storeAvailableFlagsExecStatus(flags.map(({ key, exec }) => ({ [key]: exec })));
}
LoginComponent clears session flag state on entry
(clearAvailableFlagsExecStatus() and FeatureFlagsEvaService.reset()), so overrides do
not survive a logout.
The four ways to apply a flag
1. Service check
if (this.featureFlagsService.isEnabled(FEATURE_FLAGS.DELETE_SUPPLIER)) { … }
For logic. Note the isEnabled caveat above.
2. Structural directive
<div *appEnabledByFeatureFlag="featureFlags.EVENT_RULES">…</div>
EnabledByFeatureFlagDirective wraps NgIf by instantiating it manually:
this.ngIfDirective = new NgIf(this.viewContainer, this.templateRef);
this.ngIfDirective.ngIf = this.featureFlagsService.isEnabled(featureName);
|
The flag is evaluated once, in the input setter. Toggling a flag in DEV Admin does not re-render already-created views — a reload is required. |
3. Route guard
{
path: 'documentation',
component: DocumentationComponent,
canLoad: [FeatureFlagGuard],
canActivate: [FeatureFlagGuard],
data: { enabledByFeatureFlag: FEATURE_FLAGS.PYB_DOCUMENTS },
}
FeatureFlagGuard returns true when data or data.enabledByFeatureFlag is absent,
and navigates to /404 when the flag is off. Note canLoad has no effect here — nothing
is lazily loaded.
|
|
EVA flags
FeatureFlagsEvaService manages EVA’s flags from inside Internal Admin:
-
Separate session keys:
FeatureExecFlagsEva,FeatureExecFlagsLastChangeEva. -
Separate global-settings key:
FEATURE_FLAGS_EVA. -
initFF(availableFlags, buildProvidedFlags)takes EVA’s own available and build sets as arguments — EVA reports them overpostMessage(CommunicationService.featureFlagsFromEva$). -
storeAndSend(flags)persists and pushes the enabled set to EVA viasendUpdatedConfig. -
An always-on list forces certain flags true:
private readonly ALWAYS_ON_FLAGS = ['🎪'];The single entry is an emoji sentinel, unioned into the fetched set and forced to
trueinstoreAndSend. Intentional, not a stray character. -
reset()clears theinitializedguard — though the early return insideinitFFis commented out, so initialisation always re-runs.
See iframe messaging and DEV Admin.
Persisting flags
storeState(settingKey, flags) writes the enabled set to global settings:
config.config_value.flags = flags.join(',');
config.config_value.lastChange = +new Date();
return this.globalSettingsService.edit(settingKey, config);
This is an environment-wide change: every user’s next initFF() picks it up, and as
noted above it invalidates everyone’s session overrides.
changeFlag(flag, value) is the session-only equivalent — it recalculates the table,
flips one exec value and rewrites sessionStorage.
Parsing rules
private parseRawFlags(flagString: string): FEATURE_FLAGS[] {
if (!flagString) return [];
let flags = flagString.split(',').map((f) => f.trim()).sort();
if (flags[0] === '') flags = [];
return flags as FEATURE_FLAGS[];
}
-
Comma-separated, no spaces (the README’s convention;
trim()tolerates them anyway). -
Sorted, so ordering in the source string is irrelevant.
-
An empty string yields an empty array, and a leading empty element clears the whole list — so
',A,B'resolves to no flags, not two. -
The cast to
FEATURE_FLAGS[]is unchecked: a typo’d flag name in the environment variable silently becomes a flag that nothing reads.
Setting flags per environment
FEATURE_FLAGS is read from the shell at build time by deployment/set-env.ts:
featureFlags: '${process.env.FEATURE_FLAGS ? process.env.FEATURE_FLAGS : ''}',
Neither setup_template-dev.cfg nor setup_template-production.cfg exports it, so build
flags are empty unless the pipeline sets FEATURE_FLAGS explicitly. In practice flags
are managed through global settings, not the build.
For local work, edit featureFlags in src/environments/environment.ts — remembering
that the file is regenerated by setenv.sh. See
environments.
Adding a flag
-
Add a member to
FEATURE_FLAGSinfeature-flags.const.ts. -
Apply it — service check, directive, or
FeatureFlagGuardwithdata.enabledByFeatureFlag. -
If it gates a menu item, wrap the item in
appEnabledByFeatureFlag*and decide whether the route itself should be blocked withFeatureFlagGuard. -
Enable it per environment through DEV Admin (
/admin/feature-flags) → save to global settings. -
Document it in the flag catalogue.
Gotchas summary
| Issue | Detail |
|---|---|
Global settings beat build flags |
The exec set is seeded from global settings. |
Editing the setting resets session overrides |
Via the |
|
It reads build flags + session storage only. |
The directive evaluates once |
No live re-render on toggle. |
Two similarly named guards |
|
Flag typos are silent |
The cast to |
Leading comma clears the list |
|
Session storage is per tab |
Two tabs can disagree. |