Loading And Using Remote Feature Flags In Angular 9.0.0-next.12
The other day, I wrote about using feature flags to conditionally render routable components in Angular 9. After that post, it occurred to me that I've talked a lot about how much we love feature flags here at InVision; but, I've never really looked at how we get feature flags into an Angular application. As such, I wanted to put together a simple demo that loads remote feature flags into an Angular 9.0.0-next.12 application as part of the bootstrapping and initialization process.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
When it comes to getting configuration data into an Angular application, you have three options:
You can embed the data in the parent page request, assuming that the data changes on a per-user basis.
You can gather the data using AJAX during the application bootstrapping process, assuming that the data changes on a per-user basis.
You can embed the data directly into the compiled code, assuming that the data changes only on a per-environment basis and is available at build-time.
Regardless of the methodology, the consumption within the Angular application is generally the same: the data gets folded into some sort of a service Provider that can then be injected into other factories and Classes using Angular's Dependency Injection (DI) framework. For the purposes of this exploration, I'm going to use the AJAX (Asynchronous JavaScript and JSON) approach as I believe that this is likely to be the majority use-case for Single-Page Applications (SPA) written in Angular.
Since this Angular demo is being hosted on a static server (GitHub Pages), I don't have an "API" that can serve-up dynamic, user-specific data. As such, I'm representing my server's API using a hard-coded JSON (JavaScript Object Notation) file:
{
"user": {
"id": 1,
"email": "ben@bennadel.com"
},
"company": {
"id": 101,
"name": "Dig Deep Fitness"
},
"featureFlags": {
"feature-a": true,
"feature-b": false,
"feature-c": true
}
}
This JSON file is going to be requested during the Angular bootstrapping process and the featureFlags
key is going to be folded into the Angular Dependency Injection framework. And, since feature flags may play a critical role in the way that the Angular application is configured, we want to block the application bootstrapping process until these feature flags have been retrieved from the remote API.
Luckily, Angular natively supports asynchronous configuration using the APP_INITIALIZER
provider. The APP_INITIALIZER
provider is just a collection of functions that have to be resolved before the Angular application completes the bootstrapping process. We're going to use these functions in our root module to trigger an AJAX request using the AppConfig
class.
The AppConfig
class acts as both a data fetcher and a Service provider that can then be injected into other providers and services. Let's look at the AppConfig
class first:
// Import the core angular services.
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Injectable({
providedIn: "root"
})
export class AppConfig {
// In the properties list, we're using the "DEFINITE ASSIGNMENT ASSERTION" (!) so
// that we don't have to provide default values for all of the properties that are
// going to be overwritten by the remote configuration data. As part of the
// Application Bootstrapping logic, we know that this class will never be used until
// the .loadRemoteConfig() method runs and the results have been appended to the
// instance. As such, by the time the rest of the classes inject the AppConfig, these
// data structures will all have been created / populated.
public featureFlags!: {
"feature-a": boolean;
"feature-b": boolean;
"feature-c": boolean;
};
private httpClient: HttpClient;
// I initialize the app initializer.
constructor( httpClient: HttpClient ) {
this.httpClient = httpClient;
}
// ---
// PUBLIC METHODS.
// ---
// I load the remote configuration file into the instance.
// --
// CAUTION: This method is being called as part of the application bootstrapping
// process. Changes to this logic here may affect the ability for the Angular
// application to start.
public async loadRemoteConfig() : Promise<void> {
try {
var remoteConfig = await this.httpClient
.get<AppConfig>( "assets/api/config.json" )
.toPromise()
;
// The intention here is for the remote configuration payload to have the
// same structure as the AppConfig object. As such, we should just be able to
// merge the remote structure into the local structure and all of the
// "definite assignment assertions" will come into fruition.
Object.assign( this, remoteConfig );
console.group( "Remote Configuration Loaded" );
console.log( remoteConfig );
console.groupEnd();
} catch ( error ) {
console.group( "Remote Configuration Failed" );
console.error( error );
console.groupEnd();
// NOTE: Throwing this error will prevent the application from bootstrapping.
throw( error );
}
}
}
The AppConfig
class serves two purposes:
It loads the remote user-specific configuration data through the
.loadRemoteConfig()
method.It acts as a Provider of the aforementioned configuration data by merging said data into the public API of the class (ie, the
.featureFlags
public property).
ASIDE: The
Object.assign()
call is also merging-in the.user
and.company
keys that are present in the remoteconfig.json
file; however, in order to keep the demo simple, I am only defining the.featureFlags
key.
Note that the .loadRemoteConfig()
method returns a Promise
. This Promise
will be used to block the bootstrapping process of the Angular application until the AppConfig
class has been populated with the remote data. And, once the AppConfig
class is hydrated and the Promise
has been resolved, the rest of the Angular app can bootstrap and consume the AppConfig
public API.
Let's look at the root app module to see how the APP_INITIALIZER
is configured:
// Import the core angular services.
import { APP_INITIALIZER } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { HttpClientModule } from "@angular/common/http";
import { NgModule } from "@angular/core";
// Import the application components and services.
import { AppComponent } from "./app.component";
import { AppConfig } from "./app-config.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@NgModule({
imports: [
BrowserModule,
HttpClientModule
],
providers: [
// The APP_INITIALIZER multi-provider allows for additional asynchronous
// configuration to take place during the bootstrapping process. These
// initializers will "block" the application until the returned Promises are
// resolved, which allows us to make a request to the remote server in order to
// gather user-specific or environment-specific configuration values that are NOT
// available at compile-time.
{
provide: APP_INITIALIZER,
useFactory: function( appConfig: AppConfig ) {
async function asyncInitializer() : Promise<void> {
// The .loadRemoteConfig() method will trigger an AJAX request that
// will populate the AppConfig instance. This way, it will contain
// the remote configuration data by the time that it gets injected
// into other Providers and Classes.
await appConfig.loadRemoteConfig();
}
// NOTE: The factory function returns the asynchronous FUNCTION - it does
// not execute the function directly.
return( asyncInitializer );
},
deps: [ AppConfig ],
multi: true
}
],
declarations: [
AppComponent
],
bootstrap: [
AppComponent
]
})
export class AppModule {
// ...
}
As you can see, the APP_INITIALIZER
factory function just returns a function that invokes our AppConfig.loadRemoteConfig()
method. And, again, this will block the bootstrapping process until the resultant Promise
resolves, allowing the AppConfig
class time to hydrate with user-specific data before the rest of the Services and Components get instantiated.
Once the asynchronous data fetch resolves, folding it into the rest of the Angular application is done on as as-needed basis. Meaning, you could do any of the following:
Push the remote data into other services that have been injected into the
AppConfig
class.Expose the remote data as an injectable Provider using other Factory functions.
Leave the remote data in the
AppConfig
, and then treat theAppConfig
class as an injectable Provider that can be consumed by other Services and Components.
That last option is the most straightforward option; and, the option that I am using in this demo. Once the AppConfig
has requested the remote data and the feature flags have been merged into its own public API, I'm going to treat the AppConfig
as a server Provider. This service will then be injected into my Angular components, where the exposed feature flags can drive rendering and other decision logic.
To see this in action, I'm going to inject the AppConfig
service into my root Angular component where I'm going to conditionally render parts of the HTML template:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { AppConfig } from "./app-config.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
template:
`
<!--
NOTE: Feature flags are NOTHING MORE than indicators of whether or not some
logic branch in the application should be used. DON'T OVERTHINK IT. You don't
need special directives that "handle the feature flag" for you. Angular
already has structural directives that do just that: manage logic branching.
In thise case, I'm just using the *ngIf directive to show or hide markup
based on the user's feature flag settings.
-->
<ng-template [ngIf]="isShowingFeatureA">
<p>
Woot! You have <strong>Feature-A</strong>!
</p>
</ng-template>
<ng-template [ngIf]="isShowingFeatureB">
<p>
Noice! You have <strong>Feature-B</strong>!
</p>
</ng-template>
<ng-template [ngIf]="isShowingFeatureC">
<p>
Sweet! You have <strong>Feature-C</strong>!
</p>
</ng-template>
`
})
export class AppComponent {
public isShowingFeatureA: boolean;
public isShowingFeatureB: boolean;
public isShowingFeatureC: boolean;
// I initialize the app component.
constructor( appConfig: AppConfig ) {
// Once the app has been initialized, the AppConfig instance will contain all of
// the feature flags for this user. At this point, it's as simple as checking the
// values and seeing if they are turned On or Off.
this.isShowingFeatureA = appConfig.featureFlags[ "feature-a" ];
this.isShowingFeatureB = appConfig.featureFlags[ "feature-b" ];
this.isShowingFeatureC = appConfig.featureFlags[ "feature-c" ];
}
}
As you can see, the AppConfig
service has been fully populated with remote data by the time the root component is instantiated. This is only possible because the Angular application bootstrapping process was temporarily blocked by the APP_INITIALIZER
, which triggered the AJAX call to the remote API server.
And, once we have the AppConfig
service injected, all we have to do is pull values out of the .featureFlags
property. In this case, we're using these feature flags to power a few ngIf
directives. And, when we run the above Angular code, we get the following output:
As you can see, the two enabled feature flags result in two rendered portions of the HTML template. And, the one disabled feature flag results in a hidden portion of the HTML template.
And, that's really all there is to consuming feature flags in an Angular application. It doesn't get much more complicated than that. I have seen "Feature Flag Libraries" that include special directives that help you render template partials based on feature flag state. But, if you look at what these directives are doing, they are, basically, glorified ngIf
directives. As such, there's no need to create any higher-level abstraction: Angular already has all the tools you need to load and consume feature flags.
Static vs. Dynamic Feature Flags In A Single-Page Application
In this exploration, the feature flags are "static". Meaning, they are loaded dynamically during the bootstrapping process; but then, they remain static (unchanged) for the rest of the Angular application life-span. This may or may not be acceptable for your context. Personally, this is the only approach that I have used to-date. It makes the application a easier to reason about; and, much easier to build since you don't have to worry about values changing dynamically. That said, there's nothing stopping you from updating the feature flags throughout the user's experience. But, that goes beyond what I can talk about with any confidence.
Want to use code from this post? Check out the license.
Reader Comments
This is fabulous.
I have been looking for a way to inject variables into Angular, before the rest of the application has initialized.
Previously, I have used:
And then add a snippet of JS [local example], which redirects to my Angular Application:
I then find out which Angular component renders first, which is usually:
I then capture the URL variables using a JS function.
But this is Jank central.
Your approach is much more elegant.
@Charles,
Awesome my man, glad you found this helpful. If you're using ColdFusion (
.cfm
) files anyway, then you can definitely make an API call to a dynamic-endpoint in the way that I have outlined. What actually serves-up your Angular app? Is that just a static site somewhere?Well. My first attempt was a bit of a mess but it worked.
I had the following structure:
And then the:
Has the JS redirect, back to:
But, most Angular guys have told me not place the cfm files inside the Angular assets directory.
So, I was thinking of doing something like:
In this way, I separate my Angular Application from my Coldfusion files. As you say, I can then make a REST call to bring in my config settings, using your methodology.
But, the answer to your original question, is that I use Lucee on a VPS, so I can pretty much do anything I want.
@Charles,
Yeah, keep the things well separated is not something I have a great instinct for. In my "production" app, it's so old, and uses Gulp scripts to do all the compilation (not to mention that it is still using AngularJS 1.2.22 :scream:). And then, all of the new R&D that I do with Angular is usually only Angular; so, I don't have a good instinct for it.
In the few cases that I have tried, it does end up looking like what you have in your latter example. I usually have some sort of
/api
folder that houses my ColdFusion / Lucee code. But, that only works locally because I never have a live demo that runs ColdFusion (since most of my stuff just runs on GitHub pages).So, at least I think we are both moving in the same general direction.