Back to main page Traveling Coderman

Node.js Microservice Architecture Managing configs with TypeScript code in Node.js

951 views

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 like ENV=local to commands. For the production version, especially in an infrastructure-as-code approach, the value production can be passed for the environment variable ENV.

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.