Node.js Microservice Architecture Managing configs with TypeScript code in Node.js
Node.js Part 6 Mar 21, 2022
In this blog post, we implement different configurations within TypeScript code for local development and production environment in the Node.js microservice architecture.
It's beneficial to keep local and production software similar. This reduces the chance to have bugs in a production environment that are hard to reproduce locally. Still, minor differences like disabling access token expiration locally can make your developers life easier.
We create a new folder configuration
within the src
directory.
configuration
|-- config.ts
|-- config.type.ts
`-- configs
|-- get-config.ts
|-- get-config.spec.ts
|-- get-local-config.ts
|-- get-local-config.spec.ts
|-- get-production-config.ts
`-- get-production-config.spec.ts
At this point, we distinguish between local and production config. In a later post, we provide configurations for individual engineers. This allows for additional adjustments like Macs dockerhost
and Linux localhost
as the domain of the local Docker environment.
Note: The configuration is a TypeScript file in this setup. With full deployments within minutes, there is no need to view configuration as something external and exchangeable from the application. Treating the configuration as code brings type-safety and testability.
The config object 🔗
The main idea is that the file config.ts
is the single entrypoint of the configuration
folder. It defines a global constant config
that can be read from every point in the application.
import { ProcessVariables } from "./config.type";
import { getConfig } from "./configs/get-config";
export const config = getConfig(process.env as unknown as ProcessVariables);
This constant config
is initialized from a pure testable function getConfig
. The function getConfig
receives the process variables of type ProcessVariables
and returns the configuration of type Config
. The type ProcessVariables
defines the options that can be passed via environment variables to the application. The type Config
defines the common format of production and local configuration.
import { Level } from "pino";
export type Environment =
// The service running in a production cluster available for customers
| "production"
// The service running locally on a development machine
| "local";
export interface Config {
environment: Environment;
logLevel: Level;
}
export interface ProcessVariables {
ENV?: Environment;
LOG_LEVEL?: Level;
}
As a first option, we allow the logLevel
to be configurable.
Configuration selection 🔗
The creation of the configuration getConfig
dispatches to the proper function for production and local configuration depending on the environment.
import { Config, Environment, ProcessVariables } from "../config.type";
import { getProductionConfig } from "./get-production.config";
import { getLocalConfig } from "./get-local.config";
export function getConfig(processVariables: ProcessVariables): Config {
const environment: Environment = processVariables.ENV || "local";
switch (environment) {
case "production":
return getProductionConfig(processVariables);
case "local":
return getLocalConfig(processVariables);
}
}
Note: We default to the local environment in case the environment variable
ENV
is not set. This is a general pattern: Local first. The goal is to reduce the required setup for engineers by not requiring additional input likeENV=local
to commands. For the production version, especially in an infrastructure-as-code approach, the valueproduction
can be passed for the environment variableENV
.
The function getConfig
can be tested.
import { getConfig } from "./get-config";
jest.mock("./get-local.config", () => ({
getLocalConfig: () => ({ environment: "local" }),
}));
jest.mock("./get-production.config", () => ({
getProductionConfig: () => ({ environment: "production" }),
}));
describe("the configuration", () => {
it("defaults to the local environment", () => {
expect(getConfig({})).toHaveProperty("environment", "local");
});
it("returns the local config for a local environment", () => {
expect(getConfig({ ENV: "local" })).toHaveProperty("environment", "local");
});
it("returns the production config for a production environment", () => {
expect(getConfig({ ENV: "production" })).toHaveProperty(
"environment",
"production"
);
});
});
Environment-specific configuration 🔗
The files get-local.config.ts
and get-production.config.ts
define how the respected environment configurations look like. The local configuration get-local.config.ts
defaults the log level to debug
.
import { Config, ProcessVariables } from "../config.type";
export function getLocalConfig(processVariables: ProcessVariables): Config {
return {
environment: "local",
logLevel: processVariables.LOG_LEVEL ?? "debug",
};
}
It has dedicated tests. At the current simplicity of the configuration, having dedicated tests is over-engineered. However, in following posts with incoming authentication, outgoing authentication and multi-region support such a configuration becomes increasingly complex.
import { getLocalConfig } from "./get-local.config";
describe("the local configuration", () => {
it("prefers the log level from the environment", () => {
expect(getLocalConfig({ LOG_LEVEL: "fatal" })).toHaveProperty(
"logLevel",
"fatal"
);
});
it("defaults the log level to debug", () => {
expect(getLocalConfig({})).toHaveProperty("logLevel", "debug");
});
});
The production configuration get-production.config.ts
is similar and can be viewed in the full code on GitHub.
Using the config object 🔗
Within the logger src/logger.ts
, it is now possible to use the global object config
.
Note: This approach leads to the configuration being initialized on the first execution of a module that imports the configuration.
import pino from "pino";
import { config } from "./configuration/config";
export const logger = pino({
level: config.logLevel,
});
Within the request logger src/pre-request-handlers/log-request.ts
, the log level can also be considered.
import expressPino from "express-pino-logger";
import { config } from "../configuration/config";
export const logRequest = (enabled: boolean) =>
expressPino({
level: config.logLevel,
enabled,
});
Passing environment variables 🔗
For local exploration testing with different environment variables, the changed environment variables can be passed directly to npm start
.
LOG_LEVEL=fatal npm start
ENV=production npm start
ENV=production LOG_LEVEL=error npm start
Mocking the config object 🔗
Within tests, it is possible and advisable to mock the configuration. It is sufficient to only provide the configuration options that are relevant in the scope of the test.
jest.mock("./configuration/config", () => ({
config: {
logLevel: "info",
},
}));
Become a GitHub sponsor to access the code of this post as a GitHub repo.