Angular external configuration using app initializer

Problem definition

Some configuration might not be known as part of development and must be configured during the deployment. Environment variables concept in Angular is good when the configuration is known but might be different in different stages of software development. Using a production or development environment is an easy way to set the values differently for these situations. But, if the configuration has a more dynamic nature and must be defined and set in production then environment variables cannot be used in that case. Lets see how this problem can be tackled in Angular.

Scenario

In this scenario there is a reusable application that is deployed for different customers. But, the customer domain must be set as a configuration as part of deployment to be able to reach the backend service.

Architecture

Implementation

First, install angular cli to be able to create a new Angular application.

sudo npm install -g @angular/cli

Generate a new application for demonestrating how this problem can be solved in Angular application.

ng new angular-dynamic-config-app-initializer
? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? SCSS

Generate a config module to include the service for reading the configuration.

ng generate module config

Generate the config service in the config module.

ng generate service --skip-tests config/config

Update config module and provide the new created service:

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { ConfigService } from './config.service'

@NgModule({
  declarations: [],
  imports: [CommonModule],
  providers: [ConfigService],
})
export class ConfigModule {}

Create a json file containing the application configuration (in this case the address of the API backend service). This file is located in assets directory and can be updated as part of deployment to reflect the customer data.

code src/assets/config.json

The content.

{
  "ApiBaseUrl": "https://api.myapplication.com"
}

Create an interface matching the format of data received from the config file. This interface will be used in the config service to map the data received from the json file to a variable in the config service.

mkdir src/app/config/model
code src/app/config/model/app-config.interface.ts

The content:

export interface AppConfig {
  ApiBaseUrl: string
}

Next, it is important to load the configuration as part of application bootstrap process so that the configuration is in place before all other services that are dependent on this configuration are started. For that an APP_INITIALIZER is created to handle the loading of the configuration during app initialization. It will use the config service to load the configuration. A method needs to be created in the config service that reads the configuratio and returns a Promise or an Observable. Angular ensures that initialization does not complete until the Promise is resolved or the Observable is completed.

code src/app/app-initializer.factory.ts
import { ConfigService } from './config/config.service'

export function initializeAppFactory(
  configService: ConfigService
): () => Promise<any> {
  return async () => {
    return await configService.initializeApp()
  }
}

Update the config service to read the configuration from the json. HttpClient is used to get the data and some validation is added to ensure the received value is not empty. It is important that initializeApp is returning a pormise

import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { firstValueFrom } from 'rxjs'
import { AppConfig } from './model/app-config.interface'

@Injectable({
  providedIn: 'root',
})
export class ConfigService {
  configUrl = 'assets/config.json'
  config: AppConfig = { ApiBaseUrl: '' }

  constructor(private httpClient: HttpClient) {}

  initializeApp(): Promise<any> {
    return new Promise(async (resolve, reject) => {
      const response = await firstValueFrom(
        this.httpClient.get<AppConfig>(this.configUrl)
      )
      this.config = {
        ApiBaseUrl: response.ApiBaseUrl,
      }

      if (this.isConfigValid()) {
        resolve(this.config)
      }

      reject(new Error('Could not get the application configuration!'))
    })
  }

  isConfigValid(): boolean {
    if (this.config.ApiBaseUrl in ['', null, undefined]) {
      return false
    }
    return true
  }
}

Now, update the App module to include the httpClient and config module and also the app initializer.

code src/app/app.module.ts
import { HttpClientModule } from '@angular/common/http'
import { APP_INITIALIZER, NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { initializeAppFactory } from './app-initializer.factory'

import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { ConfigModule } from './config/config.module'
import { ConfigService } from './config/config.service'

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule, AppRoutingModule, ConfigModule],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: initializeAppFactory,
      deps: [ConfigService],
      multi: true,
    },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Lets update the app component to show the configuration.

code src/app/app.component.ts
import { Component } from '@angular/core'
import { ConfigService } from './config/config.service'

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  title = 'angular-dynamic-config-app-initializer'

  apiUrl: string = ''

  constructor(private configService: ConfigService) {
    this.apiUrl = configService.config.ApiBaseUrl
  }
}
code src/app/app.component.html
<h1>My config</h1>
<p>API BASE URL: {{ apiUrl }}</p>

Last but not least start the dev server and open the browser to view the results.

ng serve

** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **

✔ Compiled successfully.
google-chrome http://localhost:4200/
Architecture

The source code can be found in github