Massimiliano Mirra

Notes
















Date:
Tags: code · collaboration
Status: finished

Four patterns for developer-friendly, production-grade NodeJS services

[Originally written for people onboarding a project I’ve been leading, to explain why services are structured the way they are, and to provide a set of reasoned guidelines.]

1. Decouple service configuration, definition, creation, and startup

If your index.ts looks like this:

import express from "express";

const app = express();

app.get("/", (req, res) => {
  /* ... */
});

app.listen(3000);

Then you’ll have a hard time using the service in any environment other than a live one (such as testing), because definition, creation, and startup are all mixed with module loading.

Instead, define the service creation in service.ts, and don’t start it as part of creation, but return a control interface so that the caller can decide when to start it:

import express from 'express'

const createService = ({ port }: { port: number }) => {
  const app = express()

  app.get('/', (req, res) => {
    /* ... */
  })

  return {
    start () =>
      new Promise((resolve) => {
        app.listen(port, resolve)
      }),
  }
}

Then create, configure, and start the service in index.ts:

import { createService } from "./service";

const main = async () => {
  const service = createService({ port: 3000 });
  await service.start();
};

main();

2. (Generalization of #1) With the exception of the entry point, use module scope only for definitions, never for side effects

Consider this:

import { computeSignature } from "something-sdk";

And notice it’s not:

importDefinitionWhileMakingSomethingElseHappen {
  computeSignature
} from 'acme-sdk'

Which is why we’d never expect node_modules/acme-sdk/index.js to be:

export const computeSignature = (data) => {
  /* ... */
};

startAcmeConnection(process.env.ACME_API_URL);

That would reduce the number of scenarios where computeSignature can be used (to live environments which have a connetion to Acme). Arguably, a module author’s job is to maximize that number.

A good rule of thumb is that if a module contains side effects, it should probably not contain exports.

3. Don’t instantiate dependencies as part of service creation

Instead of passing dbUri to createService and expect it to instantiate a database connection, like so:

// service.ts

const createService = (config: { dbUri: string }) => {
  const app = express();
  const db = await createDatabaseConnection(
    config.dbUri,
  );

  app.get("/items", async (req, res) => {
    const items = await db.getItems();
    /* ... */
  });

  /* ... */
};

Instantiate the database connection and pass it to the service factory as just one more configuration property:

// index.ts

import { createService } from "./service";
import { createDbConnection } from "./db";

const main = async () => {
  const db = createDbConnection(
    "postgres://dbhost/devdb",
  );
  const service = createService({ db });
  await service.start();
};

Mocking db when testing the service will be far easier that way.

4. Environment is broken until proven otherwise

If you cringed at hardcoded configuration values in the previous examples and wondered why not read them from environment variables, that’s because the topic deserves its own section.

process.env is the silent killer of production services:

  • Its value is set in places that can’t validate it: .env files, Kubernetes manifests, the “Environment” tab on the CI web UI; to them, DBURI is just as good as DB_URI.
  • It has all possible values in existence… at least according to its typings; you may have set DB_URI correctly in .env, but TypeScript will happily accept process.env.DBURI.
  • Environment values are often optional, so we tend to provide default values, which compounds the previous point. What happens when you correctly set DB_URI=posgress//dbhost/proddb in .env, but your code has the following?
const dbUri =
  process.env.DBURI ?? "postgres://dbhost/devdb";

The answer is that nothing happens immediately

The solution is the same as for any external data: validate.

Instead of importing from process.env, import from a custom env module:

// index.ts

import { env } from "./env";

const main = async () => {
  const service = createService({});
  await service.start();
};

main();

And define the env module as:

// env.ts
import { ensure, v } from "suretype"; // or your preferred validator

const envSchema = v.object({
  NODE_ENV: v
    .string()
    .enum("development", "production")
    .required(),
  PORT: v.string(),
  DB_URI: v.string().required(),
});

export const env = ensure(envSchema, process.env);

That way, misconfigurations will cause evident crashes instead of hard to track, potentially catastrophic bugs.

Once you’ve banished process.env, to prevent it from sneaking in again (it’s so ubiquitous that newcomers to the team will no doubt use it without giving it a second thought), consider adding this to .eslintrc:

  "rules": {
    "no-process-env": "error",
  }

Stay in touch

© 2020-2024 Massimiliano Mirra