nestjs
NestJS over plain Express

I have shipped both. The internal migration tool I worked on at VMware was Express and Sequelize, and it was fine. The accounts and billing service I have spent the last year and a half on is NestJS, and that is also fine, but for a different reason: on a team, with several people adding endpoints to the same repository over months, the framework having already settled the boring arguments turns out to be worth more than the lines of code it costs.
That is the whole claim. Not that Express is bad — it is a small, stable router with a middleware signature that has outlived almost everything built on top of it. The claim is that Express deliberately declines to answer a set of questions that every real service has to answer, and that answering them once, in a framework, beats answering them again in every repository.
The same endpoint, twice
Take a POST that creates an invoice. Validate the body, write it, return the row. In Express, with a schema library:
const express = require('express')
const { z } = require('zod')
const app = express()
app.use(express.json())
const createInvoice = z.object({
accountId: z.string().min(1),
amountCents: z.number().int().positive(),
currency: z.string().length(3),
})
app.post('/invoices', async (req, res, next) => {
try {
const body = createInvoice.parse(req.body)
const invoice = await invoices.create(body)
res.status(201).json(invoice)
} catch (error) {
next(error)
}
})
Nothing is wrong with that. But look at what it does not say. Where does
invoices come from — a module-level singleton imported at the top, so that
requiring this file connects to a database? How does the error handler know
that a Zod failure is a 400 and a duplicate key is a 409? Is express.json()
applied here or in another file, and is the body size limited? If I want to
test the route's behaviour without a live Mongo, what do I replace, and how?
Every one of those has a good answer. The problem is that it has about four good answers, and a repository with six contributors will contain three of them. That is what I have actually spent time on in Express codebases: not writing routes, but reconciling the four conventions that grew in parallel.
The Nest version splits into more files, and that is the point — each file is one of those questions, answered in the same place in every module.
// invoices/dto/create-invoice.dto.ts
import { IsInt, IsPositive, IsString, Length } from 'class-validator'
export class CreateInvoiceDto {
@IsString()
readonly accountId: string
@IsInt()
@IsPositive()
readonly amountCents: number
@IsString()
@Length(3, 3)
readonly currency: string
}
// invoices/invoices.controller.ts
import { Body, Controller, Post } from '@nestjs/common'
import { CreateInvoiceDto } from './dto/create-invoice.dto'
import { InvoicesService } from './invoices.service'
@Controller('invoices')
export class InvoicesController {
constructor(private readonly invoices: InvoicesService) {}
@Post()
create(@Body() dto: CreateInvoiceDto) {
return this.invoices.create(dto)
}
}
// invoices/invoices.module.ts
import { Module } from '@nestjs/common'
import { InvoicesController } from './invoices.controller'
import { InvoicesService } from './invoices.service'
@Module({
imports: [BillingModule],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
})
export class InvoicesModule {}
The controller returns a value instead of writing to res. A POST handler
that returns normally becomes a 201; anything else becomes a 200. A handler
that throws becomes whatever its exception maps to. There is no try/catch
and no next(error) in the route, because the framework awaits the handler
and owns that conversion.
The container is the product
If I had to keep one thing from NestJS and throw the rest away, it would be
the dependency injection container. Not because injection is fashionable, but
because constructor(private readonly invoices: InvoicesService) is a
declaration the framework can read.
Nest builds a graph from those constructor types at startup. If
InvoicesService needs a BillingClient that no imported module exports,
the application fails to boot with the path it could not resolve — before any
traffic, not on the first request that happens to hit that branch. The
imports and exports arrays in a module are a real boundary: a provider is
not reachable from another module unless that module exports it and the
consumer imports it. In Express, require('../billing/client') reaches
anything from anywhere, forever.
The other half of the value is that nothing is a module-level singleton any more. The service is constructed with its dependencies handed in, which is what makes the next section possible.
Testing is where I stopped arguing
This is the part that changed my mind, and it is not about assertion syntax.
const moduleRef = await Test.createTestingModule({
controllers: [InvoicesController],
providers: [InvoicesService],
})
.overrideProvider(InvoicesService)
.useValue({ create: jest.fn().mockResolvedValue(invoice) })
.compile()
const controller = moduleRef.get(InvoicesController)
That is the real controller, wired by the real container, with one node of the graph swapped. I did not have to start an HTTP server, and I did not have to reach into a module registry to monkey-patch an import.
The Express equivalent, in the codebases I have worked in, is one of: export a factory from every file and thread dependencies through by hand, which works and which nobody does consistently; or mock the module loader, which works until two tests in the same process disagree about what is mocked; or give up and drive the whole application through Supertest against a real database, which is a useful test but a slow and flaky substitute for a fast one.
The interesting detail is that the good Express answer — factories, explicit dependencies, no import-time side effects — is exactly what the Nest container does. You can absolutely have this in Express. It is just that in Express you have to want it, enforce it in review, and re-explain it to every new joiner.
Validation and errors belong to the framework, once
Two global registrations replace a per-route habit:
const app = await NestFactory.create(AppModule)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
)
await app.listen(process.env.PORT ?? 3000)
whitelist strips properties with no decorator on the DTO;
forbidNonWhitelisted rejects the request instead. That pair is the reason I
like this more than a validation middleware I remember to apply: the default
for a field nobody declared is "refuse", not "pass it to the database".
Errors get the same treatment. throw new ConflictException('invoice exists')
in a service becomes a 409 with a JSON body, from anywhere in the call stack,
without the route knowing. Domain errors that are not HTTP errors get one
exception filter that maps them, in one file, for every controller.
Worth being precise about one thing here, because it is often stated as an
Express deficiency and in late 2024 it still is: Express 4 does not catch a
rejected promise from an async handler. Forget the try/catch and the
request hangs until the client gives up, with an unhandled rejection in the
logs and no response. That is what express-async-errors and every
asyncHandler(fn) wrapper in the wild exist to paper over. Nest awaits the
handler, so the wrapper has nowhere to be.
What it costs
I would not pretend this is free.
The decorators are the visible tax, and they are less magic than they look:
@Body() and friends are metadata that a tsc build with
emitDecoratorMetadata writes out and reflect-metadata reads back. That is
also the constraint — the container resolves InvoicesService from emitted
type metadata, so the compile step is not optional, TypeScript is effectively
mandatory, and an interface cannot be an injection token because interfaces do
not survive to runtime. You use a class or a string/symbol token instead.
The dependency tree is bigger, the framework has genuinely more concepts to learn than Express has, and startup does real work — building the module graph costs milliseconds you can measure, which matters more the more often your process starts cold than it does for a service that boots once and serves for a fortnight.
So for two routes and a webhook, I would still write Express, or nothing at all. The same instinct as .NET 6's minimal APIs: when a service is small enough to read in one file, the structure that helps a large team is just ceremony. The crossover for me is roughly when the service acquires a second contributor and a third resource.
Node 22 went LTS three weeks ago
Node 22 moved to Active LTS on 29 October under the codename Jod, which is what pushed this from "interesting" to "the version we target". It also brought several things we have taken up in the last few weeks — none of them NestJS features, all of them things that used to be a dependency.
--env-file reads a .env without dotenv, and the flag can be repeated,
with the later file winning:
node --env-file=.env --env-file=.env.local scripts/reconcile.mjs
For the service itself I still use Nest's ConfigModule, because I want
config validated and injected rather than read off process.env in whichever
file needs it. For the one-off scripts around the service — backfills,
reconciliation, the thing you run once against staging — the flag is exactly
enough and the dependency is gone.
node --run runs a package.json script without going through npm, which
takes a noticeable slice off short CI steps because it does not start a
package manager to start a process:
node --run build
node --run test
It is still experimental in 22, and it deliberately does not do everything npm does — it ignores pre and post scripts, for instance — so read the release notes before swapping it into a pipeline you care about.
node --watch is stable now, which retired nodemon from the plain-Node
workers. Nest's own nest start --watch still drives the API, since that
needs a compile step in front of the restart.
Two changes we did not opt into but did have to notice. Maglev, V8's
mid-tier compiler, is on by default on supported architectures, and the
benefit lands on short-lived processes — our CLI scripts and CI steps, not the
long-running API. And the default stream highWaterMark went from 16 KiB to
64 KiB, which is a throughput win and a memory cost. If you have a lot of
concurrent streams, that shows up on a memory graph rather than in a changelog,
and stream.setDefaultHighWaterMark puts it back. It is the same distinction
I kept running into with .NET in
containers: a default that is
better on average is not automatically better for your shape of load.
The one I am still waiting for is require() of an ES module, which is behind
--experimental-require-module in 22. A Nest build is CommonJS, an increasing
number of small libraries ship ESM only, and the gap between those two facts
is a ERR_REQUIRE_ESM and an afternoon. Not yet.
Express 5 exists, and it is not what npm install gives you
Worth stating plainly, because it changes what this comparison is even about:
Express 5.0 was published in September and
announced on 15 October,
on the next dist-tag. npm install express today still installs 4.x. So the
Express in production, including the Express under NestJS 10, is Express 4.
Express 5 does fix real things. It catches rejected promises from middleware
and routes them to next(err), which removes the wrapper I complained about
above. It drops the deprecated res.send(status, body) signatures, changes
route matching to path-to-regexp 9 — no more inline regex in a path, and
wildcards have to be named — and stops initialising req.body to {}
unconditionally.
Which is to say the headline fix is a thing NestJS already gave me two years ago, and the rest is a migration with a real edge-case list attached. I will take it when Nest takes it, and I expect to spend the time on wildcard routes rather than on anything conceptual.
What I would choose today
For a service a team owns, with more than a handful of resources and a lifetime measured in years: NestJS, on Node 22, with the compile step and the container and the global validation pipe. Not because it is faster — it is Express underneath, so on throughput it is Express minus a small constant — but because the arguments are settled in the framework instead of in review.
For a script, a webhook, or something whose whole job fits on one screen:
Express, or node:http, and no build step.
The failure mode to avoid is the middle: Nest's structure adopted as folder-naming, with services still reaching into module-level singletons and routes still catching their own errors. That gets you the concepts and the build step and none of the payoff.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


