Custom structural directives are powerful. They promote a declarative approach and help you keep your Angular components DRY and clean. In this article, we are going to build a simple yet useful structural directive that will show one or another piece of the template depending on the value of the feature flag. Here is what we expect from our feature flag directive's public interface:
- Directive will allow conditionally showing parts of the template depending on the feature flag's status.
- No additional code should be needed in the component class apart from importing the directive itself to make it available in the template.
How is this one better
Directives like this are quite common in Angular projects. But with a bit of additional knowledge, we can build a more powerful directive by implementing an option to use an alternative template to show in case the feature flag's value is falsy. This option is very convenient, and yet it is not used as often as it deserves, at least based on my experience working with several Angular codebases.
If you have used *ngIf=A; else B
syntax in your Angular templates, you already know that it is possible to have a behavior like this. Here is how our structural directive will be used in the component's template:
<div *appIfFeatureFlag="'FEATURE_1'; else: defaultTemplate">Feature 1 template</div>
<ng-template #defaultTemplate>Default template</ng-template>
We will also use some APIs that Angular team introduced not so long time ago. In particular, standalone directives and the inject
function as an alternative to injecting via the constructor's arguments.
Why feature flag use case
We could pick any shared feature for the structural directive demo purpose. I'd hence like to say a couple of words about the reasoning behind picking the feature flags use case before we start.
The path to building bug-proof applications is long and challenging. It entails many things to learn and many practices to adopt. You need very good test coverage, correct abstractions, short development cycle to name a few. Introducing feature flags takes nowhere near as much time as mastering the above-mentioned practices. By no means do feature flags replace these things, but they are the right first step on this path. You get the option to roll back problematic changes in any environment within seconds without redeploying.
I strongly believe that feature flags are a must-have thing in any application that runs in production.
This is crucial both for users who are no longer forced to use the broken feature waiting for the fix, and for developers who are thus able to address issues in non-stressful conditions.
Implementation
Without further ado, let's create the directive. We will start with the directive's Input
s and dependencies:
@Directive({
selector: '[appIfFeatureFlag]',
standalone: true
})
export class FeatureFlagDirective implements OnInit {
@Input()
appIfFeatureFlag!: string;
@Input()
appIfFeatureFlagElse?: TemplateRef<unknown>;
private templateRef = inject(TemplateRef<unknown>);
private viewContainerRef = inject(ViewContainerRef);
private featureFlagService = inject(FEATURE_FLAGS_SERVICE);
//...
}
We are using standalone: true
to make our directive importable directly without NgModule
. And define two inputs for the name of the feature flag we will be checking and the alternative template to show in case of falsy flag value. The code is written in Typescript strict
mode, so we explicitly mark optional and mandatory inputs with corresponding assertion operators.
Pay attention to how these inputs are named.
To use the desired syntax in templates we need to prefix all additional inputs with the name of the directive, which is
appIfFeatureFlag
in our case.
Hence, if we want to specify the alternative ng-template
to show in the else
parameter, we should name the relevant input appIfFeatureFlagElse
.
Bear in mind how the last part of the input's name written in camel case turns into lower case automatically when the directive is used in the template:
<div *appIfFeatureFlag="'FEATURE_1'; else: defaultTemplate">Feature</div>
And then we inject the following providers:
-
templateRef
- holds the reference to the template we want to show if the feature flag in question is turned on, i.e. the content of the element*appIfFeatureFlag
is attached to.
If we removed the *
symbol at the beginning of the directive's name we would get a No provider for TemplateRef found
error. As you see, we don't specify anywhere in the @Directive
decorator's metadata that this is a structural directive as opposed to attribute directive. Instead, the *
symbol serves as syntactic sugar for us, wrapping the host element in the following construction:
<ng-template [appIfFeatureFlag]="'FEATURE_1'">
<div>Feature</div>
</ng-template>
The dependencies directive injects are:
viewContainerRef
- the container used to create embedded views withing the current host elementfeatureFlagService
- the service exposing APIs to work with feature flags provider. We will shed some light on good practices when working with similar dependencies further in the article.
Since Angular 14 we get these providers without injecting them via the constructor
with help of the inject
function that now can be called during the component's construction phase.
Now let's implement the view creation logic:
async ngOnInit() {
try {
const featureFlag = await this.featureFlagService.getFeatureFlag(this.appIfFeatureFlag);
featureFlag ? this.onIf() : this.onElse();
} catch (error) {
this.onElse();
// additional error handling logic goes here
}
}
private onIf(): void {
this.createView(this.templateRef);
}
private onElse(): void {
if (!this.appIfFeatureFlagElse) {
return;
}
this.createView(this.appIfFeatureFlagElse);
}
private createView(templateRef: TemplateRef<unknown>): void {
this.viewContainerRef.createEmbeddedView(templateRef);
}
What happens here is pretty straightforward. When the component is initialized, the directive fetches the feature flag's value and depending on the result calls either onIf
method that creates the view of the provided templateRef
or onElse
method that checks if the fallback template was provided to create an alternative view.
The createView
method just calls the relevant method of the viewContainerRef
to create a view from the provided templateRef
.
Preparing dependencies
For our feature flag directive to work we need a way to get the value of the feature flag. Let's create an interface FeatureFlagsService
and an injection token FEATURE_FLAGS_SERVICE
with this interface passed as a generic. The interface will contain a single method getFeatureFlag
returning a Promise
that should resolve to a boolean value.
export interface FeatureFlagsService {
getFeatureFlag(flagName: string): Promise<boolean>;
}
export const FEATURE_FLAGS_SERVICE = new InjectionToken<FeatureFlagsService>('feature.flags.service');
Why not just create a FeatureFlagService
?
We want our directive to depend on abstraction instead of implementation.
This allows us to easily use different FEATURE_FLAGS_SERVICE
implementations behind this token depending on the context. For example, we might decide to switch to a different feature flags provider in the future or use a local stub service when using this directive in tests.
Directive in action
Let's test our directive in battle. For this demo purpose, we will use a stub for the FeatureFlagsService
service implementation that will return true
for FEATURE_1 and false
for FEATURE_2:
const FEATURE_FLAGS_MOCK: Record<string, boolean> = {
FEATURE_1: true,
FEATURE_2: false
}
@Injectable({providedIn: 'root'})
export class FeatureFlagServiceMock implements FeatureFlagsService {
public getFeatureFlag(featureFlag: string): Promise<boolean> {
return Promise.resolve(FEATURE_FLAGS_MOCK[featureFlag]);
}
}
Now let's import the directive. Since it's a standalone
directive, we can import it both in Angular modules and in standalone entities.
imports: [FeatureFlagDirective]
And use one in the template:
<div *appIfFeatureFlag="'FEATURE_1'; else: defaultTemplate">Feature 1 template</div>
<div *appIfFeatureFlag="'FEATURE_2'; else: defaultTemplate">Feature 2 template</div>
<ng-template #defaultTemplate>Default template</ng-template>
This gives us the following result:
Feature 1 template
Default template
As you see, our conditional display logic becomes declarative and very concise.
Possible enhancements
The directive we built is deliberately simple. But depending on your application's needs you might want to support additional features like:
- Flag values different from
true
/false
, e.g. strings. - Sub-properties of feature flags (sometimes called feature variables in third-party services).
- Reacting to
appIfFeatureFlag
changes instead of running the check once inngOnInit
lifecycle hook. It will make your directive more universal, allowing you to pass the feature flag's name dynamically if there is a use case for this.
Keep in mind that reasonable default values are your friends when adding additional options.
Links
You can find the complete version of the code by this link.
Read more about structural directives in the official docs.
Wrapping up
I hope that the practices described in this read and the results we achieved will motivate you to delegate more work to structural directives. If you haven't been doing that already in your Angular projects:)
Thanks for reading and see you in future articles!
Top comments (3)
Nice article, I wrote a similar one a couple of years ago and I created a npm library for this.
Article
NPM library
Nice post. Thanks for writing it.
nice👌