[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 exports.
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",
}