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 export
s.
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 asDB_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 acceptprocess.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",
}