[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.]
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();
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.
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.
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:
.env
files, Kubernetes manifests, the “Environment” tab on the CI web UI; to them, DBURI
is just as good as DB_URI
.DB_URI
correctly in .env
, but TypeScript will happily accept process.env.DBURI
.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",
}