Product Post #1 (EN)

Let’s be honest, configuration management is not fun. We all just want to set up our project’s configuration as easily as possible and then move on to build whatever we actually want to build. However, proper configuration management can be the difference between a smooth twelve factor app that is a joy to develop and have around the house versus an app that strikes fear in the hearts of developers and operations people alike.

What We are Going to Solve

Every engineer developing a sufficiently complex backend application probably encountered the following problem:

  1. You want certain parts of your app to be configurable (e.g., the web-server’s port number).
  2. Configuration should be provided via environment variables e.g. stored inside a .env file.
  3. You write a module that loads the configuration object from environment variables at runtime.
  4. Now you have one large configuration object that is being passed around all over the place.
  5. Oh no! You just violated the principles of a modular codebase. Even worse, you have no type-safety, auto-completion or formal dependency declaration for your configuration.

Inject Your Config — But Only What You Need!

Like every other part of our NestJS application, we want our modules to leverage dependency injection to formally declare a dependency on a certain set of configuration values. However, depending on one big configuration object does not provide us with a whole lot of information (other than the module needs to access the application’s configuration in some way). We therefore split up the configuration object into many smaller ones so that a module can decide to only inject the configuration values it actually needs.

To illustrate this technique, consider the “hello-world” feature module:

import { Module } from '@nestjs/common';
import { HelloWorldController } from './hello-world.controller';

@Module({
  controllers: [HelloWorldController],
})
export class HelloWorldModule {}
import { Controller, Get } from '@nestjs/common';
import { HelloWorldConfig } from './hello-world.config';

@Controller()
export class HelloWorldController {
  constructor(private readonly config: HelloWorldConfig) {}

  @Get()
  sayHello(): string {
    return this.config.greeting;
  }
}
export class HelloWorldConfig {
  readonly greeting: string;
}

We declare the configuration values relevant to the hello-world module inside a separate HelloWorldConfig configuration class. The file is located inside the hello-world feature module folder. So we got ourselves a nice cohesive hello-world module that explicitly declares a set of values that should be configurable via environment variables at runtime. The hello-world config only contains the values that are relevant to the hello-world module, it cannot access any other configuration values by default. But who provides the hello-world config?

Well, the hello-world module could figure out things by itself. Read environment variables, maybe command line parameters or hit an API and then assemble the final hello-world configuration object and provide it so we can access it via dependency injection. This is a bad idea. If youthink of your modules as libraries, it becomes quite obvious why. It is not the responsibility of the individual module (library) to figure out how configuration values should be obtained. The library just expects them to be there. Think of a traditional npm package. Does it get its configuration via environment variables? No, you have to provide the package’s options during instantiation/ when you call a function.

It is the responsibility of the application – not the libraries – to figure out the application’s final configuration.

We just apply this concept to the NestJS world by shifting the responsibility to create the configuration providers to the application. The library just assumes that the configuration is being provided and does not care where it comes from. This is where the config module comes into play.

The Config Module

The config module is a central place where the application’s configuration is being calculated from the application’s runtime environment.

In this example, we leverage the ts-configurable package to automagically make all properties of the AppConfig class configurable via environment variables and command line parameters.

import { HelloWorldConfig } from '../feature-modules/hello-world/hello-world.config';
import { Configurable } from 'ts-configurable';
import { HelloMarsConfig } from '../feature-modules/hello-mars/hello-mars.config';

@Configurable()
export class AppConfig {
  readonly helloWorld: HelloWorldConfig = {
    greeting: 'Hello World!',
  };

  readonly HelloMars: HelloMarsConfig = {
    marsianGreeting: 'Hello Mars!',
  };
}

It is now possible to change the value of each property of the AppConfig class instance via environment variables e.g., helloWorld__greeting="Hello Environment!" (Nested properties are specified via double underscore__).

The config module is then able to create the config providers needed for the individual configurations:

import { Module, DynamicModule, Global } from '@nestjs/common';
import { AppConfig } from './app.config';
import { HelloWorldConfig } from '../feature-modules/hello-world/hello-world.config';
import { HelloMarsConfig } from '../feature-modules/hello-mars/hello-mars.config';

@Global()
@Module({})
export class ConfigModule {
  static forRoot(): DynamicModule {
    const config = new AppConfig();

    const providers = [
      { provide: HelloWorldConfig, useValue: config.helloWorld },
      { provide: HelloMarsConfig, useValue: config.HelloMars },
    ];

    return {
      module: ConfigModule,
      providers,
      exports: providers,
    };
  }
}

That’s it! Beside being really simple, the above approach has three major benefits:

Keeping it cohesive

We have a cohesive config module that is solely responsible for calculating configuration values. Whether this happens via environment variables, HTTP calls or smoke signals, we now have one place to make this happen. This makes it easy to adapt and refactor the strategy if necessary.

Keeping it (type) safe

I hope type-safety is one of the reasons you decided to use NestJS. It sounds quite irrational to use the advantages of TypeScript throughout your whole codebase but then throw it out the window when it comes to your configuration values. The problem is two-folded:

  1. Using string-literals to access configuration values: config.get('web.port'). No one can help you now, especially not the TypeScript compiler. How about renaming the configuration value? No problem, there is a beautiful VSCode refactoring operation for that. Oh wait no all strings will be unaffected by a renaming operation and have to be renamed using search & replace…
  2. The type of the configuration value will be any or string at best. Developers go out of their way trying to reduce the number of any occurrences inside there code-base and yet here we are handing them out like candy.

Luckily we introduce a class for each configuration object with type information. Why a class and not an interface? While we could technically use an interface, we would then have to introduce a separate injection token as interfaces cannot be used as injection tokens (interfaces do not exist at runtime, classes do). This way we can use the class for both dependency injection as well as type annotations.

The ts-configurable package provides a convenient way for making type annotations a natural by-product of writing your configuration code. Even better, the package can check if the configuration values provided at runtime have the correct type e.g., providing a string instead of a number for the server’s port will result in an error.

Keeping it consistent

Creating lowly coupled feature modules by limiting the scope of each configuration class to its own module is great. However, this can quickly lead to a situation where the same configuration value (e.g., the web server’s port number) has to be specified multiple times as multiple feature modules depend on it and have their own configuration class each.

We can avoid redundant specifications of the same configuration value by having one central place for retrieving configuration values from the application’s environment (in our example, the AppConfig class). This single source of truth can then be used inside the configuration module to construct the providers for the individual module’s configuration classes. So a port number would only have to be set once inside the environment but can then be used to construct multiple configuration objects! This two-step process is also useful for derived configuration values e.g., a URL can be calculated from the configuration values protocol , host and port .


Conclusion

  • We should apply the same level of code-quality to our configuration management as to the rest of our application.
  • We solve a lot of headaches by making configuration management a two step process: First: Calculate all configuration values from the application’s environment in one central place. Second: Leverage NestJS depdency injection to split up the single configuration object into many smaller ones and provide them to the individual modules.
  • Thinking in libraries helps — check out Nx workspaces!

Remote development teams in Tape

All of our insights are being used to develop the most customizable remote work platform in the world: Tape. Our development team plans, tracks and collaborates on everyday development tasks using Tape, closely together with the rest of the team. Try out Tape today for your team and see how it might change your workflows for the better.

Comments are closed.