Here in Poland, many backend engineers have NestJS experience, to a level that it can even affect hiring focus. I think that Nest played its role, but is not needed anymore. I'm writing it down to remember why.
Nest has its own module system. And that's the point in itself against it. Modern JS natively organizes code into modules, and with Nest you are adding another layer of indirection upon it to solve the same problem. Because the framework leverages its own module system heavily, that abstraction cascades.
Let's show it with a simple example - we will have a controller for handling http requests, a code container that will isolate business logic ("service" not used intentionally), and a thing that wires them together. All of them are singletons so not much magic will happen.
// Examples taken from nest docs
// cats.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class CatsService {
findAll(): string {
return ["a cat"];
}
}
// cats.controller.ts
import { Controller, Get } from "@nestjs/common";
import { CatsService } from "./cats.service";
@Controller("cats")
export class CatsController {
constructor(private readonly catsService: CatsService) {}
@Get()
listCats(): string {
return this.catsService.findAll();
}
}
import { Module } from "@nestjs/common";
import { CatsController } from "./cats.controller";
import { CatsService } from "./cats.service";
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {
constructor(private catsService: CatsService) {}
}
You would say, a normal thing, aye? But it could as well be just:
// cats.ts
export function findAll(): string {
return ["a cat"];
}
and then in some kind of routes.ts
file:
import * as cats from "./cats";
import { app } from "your-framework-or-http-module";
app.get("/cats", () => {
return cats.findAll();
});
Even this abstract and not really useful example should beget a question - why something so basic as a one-liner grows into a configuration mess when using Nest? Unnecessary complexity is the answer, but we will spend a bit more time to prove the point. Instead of writing a lot of arguments, I will instead show you features from Nest, and how you can implement them with JustJavascriptTM.
First, everyone want dependency injection to help them with testing. This is solved by default in every testing tool today. You import a module and mock what you need per test. No need for a framework to do it for you. When Nest was starting out, it was a different time, JS module system was a mess, but today it's not.
import * as cats from "./cats";
import { vi, describe, it, expect } from "vitest";
describe("custom cats", () => {
vi.mock(cats, "findAll", () => ["a custom cat"]);
it("should return a custom cat", () => {
expect(cats.findAll()).toEqual(["a custom cat"]);
});
});
Next, there may come a time in your life when you will have to create some kind of pluggable abstraction for your code. For example, you want to save some data, which will be stored in different places. You want to be able to swap storage implementations without refactoring your code, and hide the hassle of configuration from the developers who aren't responsible for this part of the project. With how powerful Typescript has become, it is a breeze to just work with interfaces and generics.
Typescript has super powerful inference primitives. Speaking plainly, you can replace heavy wiring systems with a more functional approach, while still making sure that nothing will change without you knowing about it.
type DataStorage = {
write: (data: string) => void;
load: () => string;
};
function createStorage<Storages extends Record<string, DataStorage>>(
storages: Storages
) {
return {
use(name: keyof Storages): DataStorage {
return storages[name];
},
};
}
const storages = createStorage({
local: {
write: (data: string) => console.log("local", data),
load: () => "local",
},
cloud: {
write: (data: string) => console.log("cloud", data),
load: () => "cloud",
},
});
// Voila! Your 'use' will only take `local` or `cloud` as an argument
storages.use("local").write("Data!");
Then, complicated IoC is achieved with more builder functions:
createStorage({
s3: createS3Adapter(),
});
And so it goes. You can easily compose things this way even in large codebases. Typescript will make sure that nobody passes anything that breaks the contract without compilation errors. This method more explicit than Nest's IoC, because you can simply navigate the codebase through symbols, without checking how runtime resolves the dependencies.
Final addition. At some point you may be met with usability issues when doing DI by yourself. Circular dependencies or code ownership problems are two things that come to mind. This again, shouldn't be solved with a runtime. ESLint has a plugin that forbids circular dependencies. For the second problem, you can start with CODEOWNERS or look into more sophisticated tools.
Sometimes you need to reuse a piece of code for each request or workflow, which is dependent on your input data. Nest gives you injection scopes, javascript gives you functions. Without magic, which we removed above, you can just pass the data to your function from the request.
It's not always the best solution, but should be the default one. If you need to decouple things further, or make them more ergonomic, AsyncLocalStorage
is for you. Nest also uses it under the hood, so just skip the framework part.
import { AsyncLocalStorage } from "async_hooks";
const requestIdStorage = new AsyncLocalStorage();
function logRequestId() {
console.log(requestIdStorage.getStore().requestId);
}
app.use((req, res, next) => {
requestIdStorage.run({ requestId: req.headers["request-id"] }, () => {
next();
});
});
app.get("/cats", () => {
logRequestId();
return cats.findAll();
});
When we use plain modules, you can resolve them lazily with native imports
app.get("/cats", async () => {
const cats = await import("./cats");
return cats.findAll();
});
If modules need to have input, you can export a builder to configure them first. This will look like:
app.get("/cats", async () => {
const { createCatsFinder } = await import("./cats");
const cats = createCatsFinder({ color: "orange" });
return cats.findAll();
});
At this point I hope you can see that Nest just gives you a sledgehammer to crack a nut. It's not that you can't, it's that you shouldn't. Every abstraction has costs.
Making Java out of JavaScript is what's happening here, and it's not nice. If you inspect things closer, you will start seeing more. Decorators, for example, come to mind.
I was using "module system" instead of "dependency injection system" on purpose. Nest tries to do it, but it's just
not possible without hacks. We don't have a good reflection primitives in JavaScript, period. So you go into the rabbit
hole of experimentalDecorators
and emitDecoratorMetadata
. You will start losing type safety here and there.
Then people start to crete some weird patterns to make it work. At some point, everyone feels that something is off.
The tower of babel grows, and you fall with it. Drown in boilerplate, there comes a time when something breaks, or becomes unmaintainable. You will discuss that the idea was sound, but people didn't follow the best practices. This may even be turned into an argument for the framework, because at least it will prevent most footguns. But it was the framework that created them in the first place, as it tried to work in a way that isn't suited for the language it was written in.
Nest modules were probably inspired by Angular, seeing their syntax and reading references in the documentation. It was made for an era where JavaScript was a total no-standard zone. But it's not the case anymore. We have good tools, great libraries, and ok-ish runtimes. We can remove one layer from the onion and live without it.
Bear with me and the onion metaphor just for one more minute. After seeing Nest biggest pitfall - its module system, and removing it out of the equation, you will start seeing that it glues together a huge amount of other libraries. For me, it doesn't take more than a day or two to configure them all by hand. For your company, you will probably do it once, and learn a lot in the process.
I'm a fan of a well-designed frameworks and abstractions, but if you can replace them today without losing much, what is the reason to pick them in the first place?