NestJS — Keep it Simple, Stupid

An alternative for using the Config Module

Robert-Jan Kuyper
4 min readFeb 22, 2023

Imagine, a new coworker starts this morning, and all he has to do is to consume an environment variable in your Nest application. Happily he agrees to do the task, to simply extract a variable via process.env. Until someone tells him, we do not access process.env directly, but we use the Config Module instead.

In the past years I’ve guided multiple teams on NestJS projects, and most of them simply followed the NestJS docs, they did not start with why.

“Why do we do what we do?”

But I faced the good, the bad and even the ugly parts of the framework. And I started to ask myself “is Nest with us or against us?”. And surprisingly I concluded that some parts of Nest are actually working against us.

Facing our enemy

Don’t get me wrong, I still love Nest. But I’m actually quite glad that the Configuration part of Nest, does not ship with it out-of-the-box. In fact, the @nestjs/config is a core module of Nest, that you could install separately — so only if you need it. And the funny part is, on almost each project I’ve worked, the Config Module was implemented for the following reasons:

  • It feels like the config module is the way to go within Nest.
  • There is quite some documentation available (and clearly needed).
  • It follows the same opinionated structure.
  • One could extend it with validation (a best-practice I’ve seen much to less)

But be honest, is the Config Module really the solution to the problem it should tackle? In other words, is the cure better than the disease? Honestly, I think not.

What the Config Module lacks

In 1 word, simplicity. Accessing an environment variable is one of the most common tasks during a day and thus it should be as easy as possible. So the Config Module should work before us, not against us.

To give an example, accessing the property foo in the app namespace forces us to implement something like the following.

// app.config.ts
const appConfig = register('app', () => ({
foo: process.env.APP_FOO
}))

// app.module.ts
ConfigModule.forRoot({
load: [
appConfig,
],
}),
]);

// MyService.ts
class MyService {
constructor(
private configService: ConfigService
)

myMethod(){
// now we can finally get the env var
const foo = this.configService.get<string>('app.foo')
}
}

Even-tough it looks quite robust and well-designed, accessing the app.foo property does not guarantee us anything. In fact, we are not even sure that we receive the correct environment variable. Or worse, receiving nothing at all.

We assume the response will be a string, but we’ll only know it on runtime. We can’t just use our editor or IDE to verify if app.foo actually exists. A typo can be made in a split second, resulting in minutes (or hours) of debugging. And the painful conclusion is, that what we actually wanted, was to access the app.foo property. But we ended up with the so called gorilla-banana-jungle problem.

“… Because the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. “

— Joe Armstrong, creator of Erlang programming language

Nest did introduce some kind of a workaround to be able to verify the type of a property on runtime, by passing an options object.

const foo = this.configService.get<string>('app.foo', { infer: true })

But honestly, it does not get much better, as adding more code in general, does not solve the underlying problem. Because what we actually wanted was:

  • that we target a property that actually exists;
  • that linting and code highlighting etc. in our IDE just work;
  • that the type of the property, is inferred by reference;
  • and that we can use the environment variable outside the scope of Nest.

The KISS-principle

As an engineer, we should always take a step back and think about if we are still implementing the Keep it simple, stupid acronym. And as we now see, the Config Module clearly breaks this law.

Though I would seem to be a pessimist, if I would not introduce another approach. A more convenient one. An approach that would still be opinionated, but also tackles the problems mentioned earlier. So just Say hello to my little friend:

export const appConfig = {
foo: process.env.APP_FOO
}

// and consume it elsewhere...
const foo = appConfig.foo

Instead of just follow the docs, this time we started with why. And to be honest this is what we actually tried to achieve. By Implementing this we can again focus on the banana, instead of the gorilla (and the jungle).

And even in larger applications we could implement something similar. Though then it would be helpful to introduce a separate config directory in a directory structure that looks something like this:

├── src
│ ├── foo-module
│ │ ├── foo.controller.ts
│ │ ├── foo.service.ts
│ │ └── foo.module.ts
│ │
│ └── config
│ ├── appConfig.ts
│ └── dbConfig.ts

├── package.json
└── nest-cli.json

Conclusion

As an engineer we should always start with why. We should force ourselves to think out-of-the-box rather than keep thinking inside of it. Even if it means we’d partially leave an opinionated framework like NestJS.

To end with a famous quote: “Let’s make accessing environment vars great again”. Focus on bananas, not gorillas in jungles.

--

--

Robert-Jan Kuyper

Senior Backend Engineer specialised in NodeJS, NestJS, Docker and CI/CD | https://datails.nl/