Skip to content

English__Theory__Per Environment Configuration

Ramirez Vargas, José Pablo edited this page Nov 7, 2024 · 4 revisions

Per-Environment Configuration

This is one of the most required functionalities of a good configuration package: The ability to specify values that are different across different application environments. Very commonly found examples are:

  • The database server name
  • The database server's username
  • The logging minimum level
  • The hostname of an externally-hosted web service

The list can go on and on, but hopefully were are now in the same page.

The wj-config package provides several ways to do this, grouped into 2 categories: Classic and Conditional.

Classic Per-Environment Configuration

This is what is found virtually anywhere else: You, as developer, provide the necessary logic during configuration construction to identify the sources pertinent to the current environment.

This is how classic per-environment configuration looks like with wj-config:

import wjConfig, { buildEnvironment } from `wj-config`;
import mainConfig from './config.json' assert { type: 'json' };

const env = buildEnvironment(process.env.NODE_ENV);

const config = await wjConfig()
    .addObject(mainConfig).name('Main')
    .addObject(loadJsonFile(`./config.${env.current.name}.json`)).name(env.current.name)
    .build();

export default config;

This code sample states that you, the developer, want to include the main source, and then another source that comes from reading a JSON file from disk whose name is dynamically constructed (by you, the developer).

It is called the classic method because this is what you find everywhere. You are tasked with the responsibility of choosing the correct list of sources for your configuration. The configuration package does not care at all whether you did the coding correctly or not. Is there a typo in the file name? The package doesn't care. What about a typo in the value of the NODE_ENV environment variable? The package doesn't care. Etc. You most likely got the idea.

Conditional Per-Environment Configuration

This is something not found anywhere. This is only found in wj-config. This package has the ability to select the appropriate data source as long as you provide your list of possible environments and will help you make sure you:

  • Don't have typos in environment names.
  • Don't forget to add at least one environment-specific data source for a possible environment.

Using forEnvironment()

The heart of conditional per-environment configuration is the forEnvironment() function. Let's illustrate it with some sample code:

import wjConfig, { buildEnvironment } from 'wj-config';
import mainConfig from './config.json' assert { type: 'json' };

const myEnvs = [ 'Dev', 'PreProd', 'Prod' ];

const env = buildEnvironment(process.env.NODE_ENV, myEnvs);

const config = await wjConfig()
    .includeEnvironment(env)
    .addObject(mainConfig).name('Main')
    .addObject(loadJsonFile('./config.Dev.json'))
    .forEnvironment('Dev') // Automatically names the data source as "Dev (environment-specific)"
    .addObject(loadJsonFile('./config.PreProd.json'))
    .forEnvironment('PreProd') // Automatically names the data source as "PreProd (environment-specific)"
    .addObject(loadJsonFile('./config.Prod.json'))
    .forEnvironment('Prod') // Automatically names the data source as "Prod (environment-specific)"
    .build();

export default config;

Here we are not worrying about dynamically building a configuration file name. We just type all of our possible configuration files and then use the forEnvironment() function to let the builder know to only apply it if we have a matching environment name.

This forEnvironment() function uses the builder's when() function to condition the data source for you, so all data sources can be freely specified without the need of any logic, hence the name conditional configuration.

By using forEnvironment() we are telling the configuration builder that we want per-environment configuration, and because the builder knows the list of all possible environments (we gave it via the environment object), then the builder's build() function, when executed, will make sure that every possible environment name has at least one data source associated to it. If this is not the case, an error is thrown so you, the developer, may correct the issue immediately, saving you headaches because you find out now about the missing data source, not after you deploy to your environments, forcing you to recreate Docker images and whatnot.

Of course, if you ever have the need to disable this environment coverage check, you can by passing false to the build()'s second argument: .build(false, false)

Using addPerEnvironment()

Ok, so this conditional approach does have its benefits, but it is so wordy! Agreed. This is why the builder object provides the addPerEnvironment() function. It is a shortcut function because it gives you, the developer, the opportunity to specify all per-environment data sources by using a single function.

This is how the previous example looks like when re-written to use addPerEnvironment():

import wjConfig, { buildEnvironment } from 'wj-config';
import mainConfig from './config.json' assert { type: 'json' };

const myEnvs = [ 'Dev', 'PreProd', 'Prod' ];

const env = buildEnvironment(process.env.NODE_ENV, myEnvs);

const config = await wjConfig()
    .includeEnvironment(env)
    .addObject(mainConfig).name('Main')
    // b: The builder object; e: One of the environment names.
    // The provided function is called once for each of the defined environment names.
    .addPerEnvironment((b, e) => b.addObject(loadJsonFile(`./config.${e}.json`)))
    .build();

export default config;

This shortcut function brings the simplicity of the classic approach and the benefits of the conditional approach together. addPerEnvironment() accepts a function that receives two arguments: The builder object and an environment name from the list of all possible environment names defined in the environment object. The function you provide will be called once per environment name, effectively covering all possible environments and without the need to risk a typo by repeating the environment names in code. Look closely: You only had the need to write the environment names once, when defining the myEnvs array.

A Note About Performance

The above conditional examples suffer from a performance hit: Even when only one environment-specific JSON file is needed, the conditional examples actually read and load in memory all JSON files for all environments. To account for this, the examples can be re-written as:

import wjConfig, { buildEnvironment } from 'wj-config';
import mainConfig from './config.json' assert { type: 'json' };

const myEnvs = [ 'Dev', 'PreProd', 'Prod' ];

const env = buildEnvironment(process.env.NODE_ENV, myEnvs);

const config = await wjConfig()
    .includeEnvironment(env)
    .addObject(mainConfig).name('Main')
    .addObject(() => loadJsonFile('./config.Dev.json'))
    .forEnvironment('Dev')
    .addObject(() => loadJsonFile('./config.PreProd.json'))
    .forEnvironment('PreProd')
    .addObject(() => loadJsonFile('./config.Prod.json'))
    .forEnvironment('Prod')
    .build();

export default config;
import wjConfig, { buildEnvironment } from 'wj-config';
import mainConfig from './config.json' assert { type: 'json' };

const myEnvs = [ 'Dev', 'PreProd', 'Prod' ];

const env = buildEnvironment(process.env.NODE_ENV, myEnvs);

const config = await wjConfig()
    .includeEnvironment(env)
    .addObject(mainConfig).name('Main')
    .addPerEnvironment((b, e) => b.addObject(() => loadJsonFile(`./config.${e}.json`)))
    .build();

export default config;

The detailed explanation can be found here.