@nestframework 📈 Stateless, so it scales behind any load balancer
🔄 Clients of the 2026 and 2025 protocol revisions on one endpoint
🔑 OAuth 2.1 built in: your identity provider's tokens, scopes per tool
🧪 An in-memory MCP client for your tests
nestjs/mcp
8 likes · 306 views
TypeScript 7.1 beta's native compiler checks a 353,000-line project in 2.8 seconds instead of 12.4
---
title: "TypeScript 7.1 beta's native compiler checks a 353,000-line project in 2.8 seconds instead of 12.4"
published: true
published_at: 2026-10-31 13:00 +0200
description: "I benchmarked TypeScript 7's Go-based compiler against 6.0.3 on a real NestJS checkout: 2.5x to 4.5x faster, not Microsoft's claimed 8-12x, and 7.0's stable release used more memory than the old compiler while dropping the API ts-node needs."
tags: typescript, javascript, performance, devops
ai_disclosure_level: ai_assisted
---
TypeScript 7.0 replaced the compiler's JavaScript implementation with a native port written in Go, and Microsoft's own numbers describe a compile that dropped from 125.7 seconds to 10.6 seconds, a gain of roughly 12x. I wanted to know whether that holds on a codebase I didn't choose for a marketing slide, so I checked out a real project and ran all three compilers against it myself: TypeScript 6.0.3, the last release before the rewrite, 7.0.2, the first stable native release, and 7.1.0-beta, which shipped eleven days ago.
I used nestjs/nest, cloned fresh and with its real dependencies installed. Its root tsconfig.json pulls in the packages, the integration tests, and the samples: 2,934 files once @types/node and the rest of the dependency graph are counted, about 353,000 lines. tsc --noEmit against that tree is a reasonable stand-in for what a CI job or a pre-commit hook does every day.
The headline number
Three runs each, fastest reported, same machine, same checkout:
| Compiler | Fastest run | Peak memory |
|---|---|---|
| TypeScript 6.0.3 | 12.42s | 1.13 GB |
| TypeScript 7.0.2 | 4.95s | 1.86 GB |
| TypeScript 7.1.0-beta | 2.76s | 1.06 GB |
7.0.2 is 2.5x faster than 6.0.3. 7.1.0-beta is 4.5x faster. Both are real, both are worth having, and neither is the 8-12x Microsoft advertises. The VS Code benchmark that produced that number is presumably a bigger, more type-heavy codebase than a mid-sized Node framework, and benchmarks run on your own code will usually look more modest than the one in the release post. If your project looks more like NestJS than like the TypeScript compiler's own source, plan around 2.5x to 4.5x, not 12x.
The number that argues against the headline
Peak memory went the wrong way for the first stable release. 7.0.2 used more memory than the compiler it replaced, not less, consistently across three runs:
6.0.3: Maximum resident set size: 1,119,044 – 1,128,916 KB
7.0.2: Maximum resident set size: 1,778,072 – 1,863,628 KB
7.1.0-beta: Maximum resident set size: 1,013,640 – 1,056,968 KB
A native rewrite is supposed to use less memory than a JavaScript one doing the same job, and in 7.1.0-beta it does, coming in slightly under the old compiler. But 7.0.2, the release most people will actually install because it's the one tagged latest, used about 65% more memory than 6.0.3 at the same task. A CI runner sized around TypeScript 6's memory footprint could OOM on 7.0.2 and recover on 7.1 once that's out of beta. I don't have an explanation for the regression beyond what I could observe: it happened, consistently, on this codebase, and it reversed itself in the next beta.
What it refuses
The old compiler exposed a JavaScript API: require('typescript') gave you createProgram, transpileModule, createSourceFile, and anything built on top of those. That's gone in 7.0.2. The package's default export is now this:
const { version } = require("../package.json");
exports.version = version;
exports.versionMajorMinor = "7.0";
That's it. No createProgram. I confirmed it wasn't a module resolution problem on my end by installing ts-node 10.9.2 and pointing it at TypeScript 7.0.2:
TypeError: Cannot read properties of undefined (reading 'fileExists')
at readConfig (ts-node/dist/configuration.js:91:33)
ts.sys doesn't exist, because ts is just { version, versionMajorMinor }. Any tool that calls into the compiler programmatically rather than shelling out to tsc breaks the same way: ts-node, ts-jest, and the type-aware rules in typescript-eslint all depend on createProgram.
I expected 7.1.0-beta to fix this, because the beta's headline feature is a stabilized API, and coverage of the release describes it as unblocking Vue, Svelte, and Angular tooling. It does ship a new API, under typescript/sync and typescript/async rather than 7.0's typescript/unstable/sync. I checked the package's export map directly:
7.0.2: "./unstable/sync": "./dist/api/sync/api.js"
7.1.0-beta: "./sync": "./dist/api/sync/api.js"
But the default . export is still just version.cjs in 7.1.0-beta too. I reran the exact same ts-node test against it and got the identical crash. "Stabilizes the API" means a new, differently-shaped API exists at a new import path. It does not mean require('typescript').createProgram starts working again. Anything written against the classic API has to be rewritten against the new entry points; nothing becomes compatible by just bumping a version number.
Config files break too, in a smaller but more common way. I fed both compilers a set of deprecated tsconfig.json options. Most of them, like target: "ES3" or out, were already hard errors under 6.0.3, just with a different error code. One wasn't:
compilerOptions: { "importsNotUsedAsValues": "remove" }
6.0.3: (accepted silently, no output, exit 0)
7.0.2: error TS5023: Unknown compiler option 'importsNotUsedAsValues'.
importsNotUsedAsValues has been deprecated in favour of verbatimModuleSyntax for several releases, but 6.0.3 still tolerated it quietly. 7.0 doesn't. Anyone upgrading a tsconfig that was last touched two or three TypeScript versions ago should grep it for options that used to be ignorable warnings, because some of them are now a build that won't start.
Under concurrency
The release notes mention new flags for parallel project-reference builds, and tsc --help --all on 7.0.2 lists --singleThreaded, meaning parallel is now the default. I built a small composite project, four independent packages referenced from a root tsconfig.json, each containing 400 generated generic type definitions, enough that the per-project check takes a fraction of a second rather than nothing:
| Mode | Wall time (3 runs) |
|---|---|
| 7.0.2, default (parallel) | 0.75s, 0.79s, 0.83s |
| 7.0.2, --singleThreaded | 1.38s, 1.40s, 1.48s |
| 6.0.3 (no parallel option exists) | 4.26s |
Parallelism alone is worth about 1.8x on four independent projects on this four-core machine, separate from the raw speed of the native checker. The two effects stack: the full build goes from 4.26 seconds on the old compiler to under a second on the new one by default, with no flag to set. On a bigger monorepo with more independent project references and more cores, I'd expect that gap to widen rather than narrow, because the whole point of --singleThreaded existing as an escape hatch is that someone will eventually need to turn the parallelism off, not that most people will want to.
Watching it in production
tsc --extendedDiagnostics still works, and the breakdown shows where the time actually went on the NestJS checkout:
6.0.3 7.0.2 7.1.0-beta
Parse time: 1.16s 0.419s 0.291s
Bind time: 0.61s 0.093s 0.093s
Check time: 9.91s 4.603s 2.502s
Total time: 12.21s 5.146s 2.910s
Check time is where almost all of the wall clock goes in every version, and it's also where the native compiler's advantage is largest proportionally, not just absolutely. If you're deciding whether upgrading is worth the breakage above, this is the number to look at in your own --extendedDiagnostics output before and after: a codebase that spends most of its time in parsing rather than checking (lots of files, not much generic-heavy type logic) will see a smaller win than NestJS did here. I'd treat the Symbols and Types counts in that same output with caution, though: they roughly doubled between 6.0.3 and 7.0.2 on an identical checkout, which almost certainly reflects a different internal counting convention in the rewrite rather than the new compiler creating twice as many actual symbols, and I have no way to confirm which from the outside.
What I got wrong on the way
My first run of the headline benchmark gave suspiciously good numbers: all three compilers finished in about a quarter of a second, uniformly, with exactly one error each. I almost wrote that down as "TypeScript 7 is absurdly fast even before you account for anything else," which should have been the first clue, because a result that flattering is usually the harness, not the finding. The one error was TS2688: Cannot find type definition file for 'node', because I'd installed NestJS's dependencies with a plain npm install that hit a peer-dependency conflict and silently produced no node_modules directory at all. Every compiler was failing to even construct the program, immediately, before checking a single file, and reporting an identical fast "success." Reinstalling with --legacy-peer-deps fixed it and pushed every run from a quarter of a second to several seconds, which is when the real differences between the three versions showed up.
Run it yourself
This installs three versions of TypeScript side by side without touching a global install, clones NestJS, and runs the headline comparison:
mkdir -p tsbench/engines && cd tsbench
for v in 6.0.3 7.0.2 7.1.0-beta; do
npm install --prefix "engines/ts_${v//[.-]/_}" --silent "typescript@$v"
done
git clone --depth 1 https://github.com/nestjs/nest.git target/nest
npm install --prefix target/nest --ignore-scripts --legacy-peer-deps --silent
for v in 6_0_3 7_0_2 7_1_0_beta; do
echo "=== $v ==="
TSC="engines/ts_$v/node_modules/.bin/tsc"
time "$TSC" --noEmit -p target/nest/tsconfig.json
done
The --legacy-peer-deps flag matters: without it, npm install can fail outright on a conflicting peer dependency, or in the worse case leave you with no node_modules and every compiler reporting the same hollow "success" I got on my first attempt. Check the file count and error count match across versions before you trust a timing number from this, not just the time itself.
What to do with this
If you're on TypeScript 6 and your build is dominated by type-checking rather than I/O, 7.0 is worth the upgrade on speed alone, even at 2.5x rather than 12x. Before you do it, grep your tsconfig files for options your team hasn't touched in a while, because some of yesterday's warnings are today's build failures. And if your toolchain includes ts-node, ts-jest, or anything doing type-aware linting, check whether it has shipped a release that targets TypeScript 7's new API paths before you bump the version in CI; the error if it hasn't is a flat crash, not a graceful fallback, and 7.1's "stabilized API" doesn't change that for code still written against the old one.
9 reactions · 1 comment
Tracing one NestJS request: handlers, response wrapping and test boundaries
An illustrative walkthrough of GET /cats in Nest’s official public 01-cats-app sample. It follows registered modules, the route-specific guard decision, the in-memory service and global response transformation, then distinguishes the HTTP test’s service override from real data behavior. This is not an analysis of anyone’s private application; the test source was inspected, not executed.
Pinned sample: github.com/nestjs/nest/tree/060fae304f9cec0677110…
Request lifecycle: docs.nestjs.com
Produced with Armature: app.armature.ai
reddit.com
NestJS 12 Lifecycle Hooks: The Order You Can't Assume
A NestJS app upgrades from v11 to v12. No application code changes — just npm i @nestjs/core@latest @nestjs/common@latest. The test suite, which has been green for months, starts failing intermittently: a report service reads a cache that a metrics service is supposed to have already populated, and about one run in three, it hasn't been.
Nothing in the diff touches either service. The bug was always there — it just used to be invisible, because the old, undocumented order happened to keep working. The official v12.0.0 release notes list the cause in one line, under breaking changes: "Lifecycle hooks are now invoked by component hierarchy level." The fix isn't a workaround for v12 — it's realizing that "hook order" was never a promise Nest made for providers like these two.
What you'll learn
By the end of this article you'll be able to:
- Name every NestJS lifecycle hook and state precisely when each one fires
- Explain which part of hook ordering Nest actually guarantees — and which part it has always reserved the right to change
- Recognize the v12 breaking change for what it is, and know why it broke code that looked correct
- Fix a hook-ordering bug the right way: with dependency injection, not a delay or a flag
- Enable and reason about shutdown hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) correctly
Who this is for
You've written at least one onModuleInit() in a NestJS service and injected a provider into another provider's constructor. You don't need any prior lifecycle-hook experience beyond that — this article builds the full model from there.
This article is written against NestJS 12.x (verified against @nestjs/core's npm dist-tags and the nestjs/nest GitHub release history: latest is 12.2.0, v12.0.0 shipped August 27, 2026, and the 11.x line has moved to the legacy npm tag at 11.2.7). The point releases since v12.0.0 (through 12.2.0) fix shutdown-sequence and startup-failure edge cases but don't change the onModuleInit/onApplicationBootstrap ordering model described below. Everything here is core-container behavior — it works identically whether your HttpAdapter is Express or Fastify, since lifecycle hooks fire before Nest ever hands a request to either one.
Table of contents
- [The problem: a hook order nobody promised](#the-problem-a-hook-order-nobody-promised)
- [The mental model: constructed order vs. hook order](#the-mental-model-constructed-order-vs-hook-order)
- [Stage 1: the five lifecycle hooks](#stage-1-the-five-lifecycle-hooks)
- [Stage 2: the order Nest does guarantee](#stage-2-the-order-nest-does-guarantee)
- [Stage 3: the order it never guaranteed — and v12 changed](#stage-3-the-order-it-never-guaranteed-and-v12-changed)
- [Stage 4: the fix is DI, not a delay](#stage-4-the-fix-is-di-not-a-delay)
- [Stage 5: shutdown hooks and async hooks](#stage-5-shutdown-hooks-and-async-hooks)
- [Edge cases and gotchas](#edge-cases-and-gotchas)
- [Best practices](#best-practices)
- [FAQ](#faq)
- [Cheat sheet](#cheat-sheet)
The problem: a hook order nobody promised
Here's the setup, trimmed to the part that matters:
// metrics.module.ts
let requestCounterSeed = 0;
@Injectable()
export class MetricsService implements OnModuleInit {
onModuleInit() {
requestCounterSeed = loadSeedFromDisk(); // populates module-scope state
}
}
// reports.service.ts
@Injectable()
export class ReportsService implements OnModuleInit {
private baseline = 0;
onModuleInit() {
this.baseline = requestCounterSeed; // reads it back — no DI involved
}
}
ReportsService never injects MetricsService. There's no constructor(private metrics: MetricsService) anywhere — the two talk through a module-scope variable instead, and the code "works" as long as MetricsService's onModuleInit happens to run before ReportsService's. Under Nest 10 and 11, it reliably did — because hook order followed each module's position in AppModule's imports array, and MetricsModule happened to be listed first.
It is not a coincidence that this looks fragile. It is fragile — it was fragile before the upgrade too. The v12 release note doesn't introduce the bug; it removes the accident that was hiding it. Once hook invocation moved to being ordered "by component hierarchy level" instead of import position, ReportsService.onModuleInit can run first, and requestCounterSeed is still 0 when it reads it.
The mental model: constructed order vs. hook order
The mental model: NestJS gives you exactly one hard guarantee about initialization order, and it comes from the dependency graph, not from where a hook happens to live. If provider B is constructor-injected into provider A, Nest must fully construct B — and run B's own onModuleInit — before it can finish constructing A and run A's onModuleInit. That ordering is real, structural, and the same in every version, because it falls directly out of how constructor injection works: you cannot hand A a B that doesn't exist yet.
Everything else — the order of two providers that share no dependency edge, sitting in sibling modules that never import each other — has never been part of that contract. It happened to be stable for a long time because Nest's internal bootstrap walked the module tree in a specific, observable order. "Observable" is not "guaranteed": nothing in Nest's public API ever promised that two unrelated providers would always initialize in the same relative order, which is exactly why the v12 release notes can change it and call it a breaking change rather than a bug fix — it was breaking an assumption, not a contract.
Key concept: if two providers' initialization order matters to your code, that requirement has to be expressed as a dependency — an injected constructor parameter — not inferred from import position. If there's no edge in the graph, there's no promise about the order.
<!-- playground:start -->
🎮 Try it yourself
▶️ Open the interactive playground →
_Runs right in your browser — poke at it and watch the concept react live._
<!-- playground:end -->
Stage 1: the five lifecycle hooks
Nest exposes five lifecycle interfaces a provider (or module class) can implement. Each is a plain TypeScript interface with one method, and implementing it is how you opt in:
import {
OnModuleInit,
OnApplicationBootstrap,
OnModuleDestroy,
BeforeApplicationShutdown,
OnApplicationShutdown,
} from '@nestjs/common';
- onModuleInit() — called once the host module's own dependencies have been resolved and constructed. This is the hook for "set up something that needs this provider's injected dependencies to already exist."
- onApplicationBootstrap() — called once every module in the app has finished onModuleInit, but before Nest starts listening for connections. Use it for work that should only start once the whole graph is up, not just this provider's own corner of it.
- onModuleDestroy() — the mirror of onModuleInit, run during teardown.
- beforeApplicationShutdown(signal?: string) — runs after onModuleDestroy, with access to the OS signal (SIGTERM, SIGINT) that triggered shutdown, if there was one.
- onApplicationShutdown(signal?: string) — the last hook to run, immediately before the process would otherwise exit.
onModuleInit and onApplicationBootstrap only fire if you actually call app.init() or app.listen() — a TestingModule you only compile() but never init() skips both, which is a common source of "why didn't my hook run in this test" confusion.
Stage 2: the order Nest does guarantee
The one ordering rule worth memorizing: a provider's onModuleInit runs after the onModuleInit of every provider it depends on, transitively. This is a direct consequence of constructor injection being resolved depth-first.
@Injectable()
export class CacheService implements OnModuleInit {
onModuleInit() {
console.log('CacheService ready');
}
}
@Injectable()
export class OrdersService implements OnModuleInit {
constructor(private readonly cache: CacheService) {}
onModuleInit() {
console.log('OrdersService ready'); // always logs after CacheService
}
}
Because OrdersService injects CacheService, "CacheService ready" is guaranteed to print before "OrdersService ready" — in Nest 10, 11, 12, and every future version, because changing it would mean handing OrdersService a CacheService whose own setup hasn't run yet. That's not an implementation detail Nest could casually break; it's the definition of what constructor injection means.
onApplicationBootstrap extends the same guarantee one level further: because it only fires after every module's onModuleInit has completed, anything you do there can safely assume the entire app's onModuleInit work — dependency-ordered or not — is already done.
Stage 3: the order it never guaranteed — and v12 changed
What Nest has never promised is the relative order between two providers that share no dependency edge — like MetricsService and ReportsService above. Before v12, that order was a side effect of how Nest walked the module tree (roughly, each module's imports in declaration order, depth-first). It was deterministic in practice, which is exactly what made it easy to depend on by accident.
The v12.0.0 release notes list the actual change under breaking changes, verbatim: "Lifecycle hooks are now invoked by component hierarchy level." The notes don't spell out the full new algorithm step by step — their own guidance is to "review ordering assumptions between related providers/modules in init, teardown, and tests" rather than to assume your old mental model of "import order" still predicts anything. That's the honest, practical takeaway: don't try to reverse-engineer the new implicit order and depend on that instead — it's exactly the same mistake with a different answer.
Stage 4: the fix is DI, not a delay
The wrong fixes are tempting and all fragile: reordering imports in AppModule (back to relying on an undocumented detail, just a different one), adding a setTimeout before reading the shared state (a race condition with extra steps), or introducing a global "ready" flag with a polling loop. All three fix today's symptom and leave the actual defect — an implicit dependency — in place.
The real fix is to make the dependency explicit, the same way you would for any other cross-service call:
@Injectable()
export class ReportsService implements OnModuleInit {
private baseline = 0;
constructor(private readonly metrics: MetricsService) {}
onModuleInit() {
this.baseline = this.metrics.getSeed(); // guaranteed: metrics already initialized
}
}
@Injectable()
export class MetricsService implements OnModuleInit {
private seed = 0;
onModuleInit() {
this.seed = loadSeedFromDisk();
}
getSeed() {
return this.seed;
}
}
Once ReportsService constructor-injects MetricsService, Stage 2's guarantee applies again: Nest cannot construct ReportsService without MetricsService already existing, and MetricsService.onModuleInit is guaranteed to have already run. The module-scope variable is gone, replaced by a method call on an instance Nest already knows how to order correctly — because now there's an edge in the graph for it to order by.
Key concept: a lifecycle-ordering bug is almost always a missing edge in the dependency graph wearing a disguise. Add the edge (inject the thing), and the ordering problem disappears on its own — you don't have to solve it, Nest already does.
Stage 5: shutdown hooks and async hooks
Every hook in Stage 1 can be async — return a Promise, and Nest waits for it before moving to the next stage. An async onModuleInit() that awaits a database connection genuinely blocks app.listen() until it resolves, which is usually exactly what you want (don't accept traffic before you can serve it) but is worth knowing explicitly, since a slow connection now shows up as a slow boot, not a slow first request.
Teardown hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) only run on app.close() or an OS shutdown signal — and only if you've opted in:
// main.ts
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // without this, SIGTERM/SIGINT skip teardown hooks entirely
await app.listen(3000);
Forgetting enableShutdownHooks() is the most common reason a service that's supposed to flush a queue or close a connection pool on shutdown silently doesn't — the process just exits, and no teardown hook ever runs.
<!-- quiz:start -->
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._
<!-- quiz:end -->
Edge cases and gotchas
- Tests that only compile(), never init(). Test.createTestingModule({...}).compile() builds the module graph but never calls onModuleInit or onApplicationBootstrap. If a hook-driven side effect doesn't appear to run in a test, check whether the test ever called .init() on the result.
- Circular dependencies with forwardRef(). Two providers that depend on each other via forwardRef(() => OtherService) can't have a clean "B before A" order, because there is no clean order — Nest resolves the cycle, but a hook in either one that assumes the other is fully initialized can still race. Prefer breaking the cycle over patching around it.
- Global modules aren't a shortcut around ordering. A module marked @Global() is visible everywhere without importing it, but it is still initialized according to the same rules — "global" changes visibility, not hook order.
- onApplicationBootstrap running late in a slow-starting app. Because it waits for every module's onModuleInit, one slow async hook anywhere in the graph delays onApplicationBootstrap for the whole app, not just its own module.
- Upgrading from 11.x to 12.x with hidden cross-module hook assumptions. This is the v12-specific case this article opened with. If a test suite starts flaking right after the upgrade, treat it as a real latent bug the version bump exposed, not a Nest regression to work around.
Best practices
- Never let a hook in one provider assume another unrelated provider already ran unless that assumption is backed by a real constructor-injection edge. If it isn't, add the edge.
- Use onApplicationBootstrap for anything that needs the whole app ready, not just its own module's dependencies — don't reach for a hack to delay onModuleInit work instead.
- Call app.enableShutdownHooks() in production if any provider relies on onModuleDestroy or onApplicationShutdown to flush state or close connections cleanly.
- When you upgrade a major Nest version, read the release's breaking-changes list before you read anything else — a change like this one produces no compile error and no obvious runtime error, only an intermittent one.
- If you genuinely need to verify ordering empirically, log each hook with its provider's name during boot rather than assuming either the "old" or "new" order from memory — the release notes don't spell out the full new algorithm, so the only way to know your app's actual order is to look at it.
FAQ
Does onModuleInit run in constructor order?
Only where a real dependency edge exists. If A injects B, B's onModuleInit is guaranteed to run first. Between providers with no such edge, there's no guaranteed order, and NestJS 12's release notes confirm that relative order can and did change.
Why didn't my onModuleDestroy fire when I sent SIGTERM?
Almost always because app.enableShutdownHooks() was never called. Without it, Nest doesn't attach the OS signal listeners that trigger the teardown hooks at all.
Does an async onModuleInit block app.listen()?
Yes. Nest awaits every onModuleInit (and then every onApplicationBootstrap) before it starts accepting connections, so a slow one delays the whole app's startup, not just the part that's slow.
Is this different for Fastify vs Express?
No. Lifecycle hooks fire during module bootstrap, entirely before Nest hands anything to the underlying HTTP adapter, so the behavior is identical regardless of which one you're using.
Will NestJS 13 reorder hooks again?
There's no way to know in advance, and that uncertainty is the actual point of this article: code that depends only on guaranteed ordering (real dependency edges) can't be broken by a future reshuffle of the unguaranteed part, because it never relied on it.
Cheat sheet
| Hook | Fires when | Guaranteed relative to |
| --- | --- | --- |
| onModuleInit() | Host module's own dependencies constructed | After every injected dependency's onModuleInit |
| onApplicationBootstrap() | Every module's onModuleInit has finished | After all onModuleInit hooks app-wide |
| onModuleDestroy() | app.close() or shutdown signal (needs enableShutdownHooks()) | Mirrors onModuleInit's dependency order, in reverse |
| beforeApplicationShutdown(signal) | After all onModuleDestroy hooks | After every onModuleDestroy |
| onApplicationShutdown(signal) | Last hook before process exit | After beforeApplicationShutdown |
| Two providers, no dependency edge | — | Not guaranteed, by design — don't rely on it |
| Opt in to teardown hooks | app.enableShutdownHooks() in main.ts | Must be called explicitly |
| Fix an ordering bug | Constructor-inject the provider you actually depend on | Turns an assumption into a real graph edge |
Key takeaways
- NestJS guarantees hook order along the dependency graph: if A injects B, B's hooks always run first. That rule has never changed and never will, because it's a consequence of constructor injection, not a scheduling choice.
- It has never guaranteed order between providers with no dependency edge — that was always an implementation detail, and NestJS 12's release notes confirm it changed.
- A hook-ordering bug is a missing dependency edge in disguise. The fix is to inject the thing you depend on, not to chase the new implicit order.
- Teardown hooks need app.enableShutdownHooks() to run at all — a very common silent gap.
The report service from the top of this article didn't start being wrong the day it was upgraded to Nest 12 — it was wrong from the day someone reached for a module-scope variable instead of a constructor parameter. The version bump just stopped covering for it. Once you're reading "hook order" as "the dependency graph, and nothing else," that whole bug class stops being a mystery you debug after an upgrade and starts being something you can rule out on sight.
Has a major-version upgrade ever exposed an ordering assumption you didn't know your code was making? Drop it in the comments.
<!-- related:start -->
📚 Read next
- NestJS Dependency Injection Explained (with Cheat Sheet)
- NestJS Testing Module: Provider Overrides (with Cheat Sheet)
- NestJS Module Encapsulation Explained (with Cheat Sheet)
<!-- related:end -->
---
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg
- 📸 Instagram — frontend best practices, daily: @bestpractice___
5 reactions
NestJS 12 Migration Guide: Upgrading from v11, Tested and Benchmarked
NestJS 12 is the first major release of the Node.js framework since v11. It was released on August 27, 2026, and 12.1.3 is the current version. The headline changes are ESM-only packages, built-in Standard Schema validation (so Zod, Valibot and ArkType work without a custom pipe), a rebuilt CLI with a nest upgrade command, Vitest and oxlint as defaults for new projects, and the new @nestjs/observe SDK. Existing CommonJS apps keep working.
The release notes cover what changed. This guide covers what happened when I actually upgraded. I built a small but realistic NestJS 11 API with class-validator DTOs, Joi config validation, lifecycle hooks, an @Optional() dependency and e2e tests. I ran nest upgrade on it, fixed everything that broke, and benchmarked v11 against v12 for startup, throughput and memory. I also measured Zod against class-validator, Jest against Vitest, ESLint against oxlint, TypeScript 6 against TypeScript 7, and NestJS 12 on Bun. Every number below comes from those runs.
TL;DR: should I upgrade to NestJS 12?
Yes, but budget an hour rather than a minute. nest upgrade bumped every package and rewrote my config module correctly, but the build and the test suite both failed afterwards. TypeScript 6 needs three tsconfig fixes, and Jest needs --experimental-vm-modules to load the ESM-only packages. Once those are fixed, request throughput is the same as v11 (within 5%), cold start is about 90 ms slower, and you can move to Zod validation, which was 28% faster than class-validator in my test.
| | NestJS 11.2.7 | NestJS 12.1.3 |
| --- | --- | --- |
| Package format | CommonJS | ESM only (CJS apps work via require(esm)) |
| Minimum Node.js to run | 20 | 20.19+ or 22.12+ |
| TypeScript in a new project | 5.x | 6.x (7.0 not yet supported by nest build) |
| Validation | class-validator ValidationPipe | class-validator or Standard Schema (Zod, Valibot, ArkType) |
| New-project defaults | Jest, ESLint, CommonJS | Vitest, oxlint, ESM |
| Cold start, my API (median) | 296 ms | 386 ms (CJS), 363 ms (ESM) |
| Hello route, req/s | 36,777 | 34,866 (CJS), 36,789 (ESM) |
| POST with validation, req/s | 17,173 | 16,237 class-validator, 20,804 Zod |
What's new in NestJS 12?
The NestJS v12.0.0 release groups the changes into five areas:
- ESM packages. Every core @nestjs/* package now ships as ESM. A CommonJS app loads them through Node's require(esm), so you don't have to convert your own code.
- Standard Schema. @Body(), @Query(), @Param() and @RawBody() take a schema option. The new StandardSchemaValidationPipe and StandardSchemaSerializerInterceptor validate requests and responses with any Standard Schema library. @nestjs/config now accepts a Zod schema as validationSchema too.
- A rebuilt CLI. It adds nest upgrade (with --dry-run), nest deploy, Rspack as the default bundler for monorepos, and bun as a package manager. nest new scaffolds ESM, Vitest and oxlint by default.
- Native observability. @nestjs/observe hooks into Nest's own request lifecycle instead of patching the HTTP server like a generic APM agent.
- Smaller additions. These include routeConflictPolicy and routeResolutionStrategy for route shadowing, machine-readable errorCode on HTTP exceptions, structured logging params, and graceful shutdown in the Express adapter.
The breaking changes that matter most for typical REST APIs are listed in the official migration guide: ESM packages, lifecycle hook ordering by component hierarchy, @Optional() inheritance, @nestjs/config moving to Standard Schema (Joi must be v18+), NATS v3, graphql-ws-only subscriptions, and the removal of the legacy Terminus health indicator API.
How I tested the NestJS 12 upgrade
- App: a NestJS 11.2.7 project scaffolded with CLI 11.0.24 (Express adapter), plus a users module. It has a class-validator DTO, @nestjs/config 4 with a Joi 17 schema and custom validationOptions, onModuleInit and onApplicationBootstrap hooks, a repository subclass that inherits an @Optional() constructor dependency, a GET /users/me route declared after GET /users/:id, and Jest e2e tests with supertest.
- Upgrade: CLI 12.0.8 nest upgrade, which installed NestJS 12.1.3, @nestjs/config 12.0.1 and Joi 18.3.0.
- Runtime: Node.js 24.22.0 on an 8 vCPU Xeon VM. Node.js 26.12.0 and Bun 1.4.3 were used for the cross-runtime checks.
- Benchmarks: hyperfine for startup and tooling times (20 runs for startup, 5 to 8 for tools). For HTTP I used oha with 64 connections, taking the median of three 10-second runs after a warm-up, with the server pinned to 2 cores and oha on the other 6.
"Cold start" means the wall time to start the process, boot Nest, bind the port and close it again. Inside the process, NestFactory.create() plus listen() took 70 ms on v11 and 84 to 93 ms on v12. The rest is Node starting and loading modules.
How do I upgrade to NestJS 12?
The upgrade has five steps. The first trips almost everyone coming from a v11 project.
Step 1: upgrade the CLI (and TypeScript with it)
The guide says to upgrade the CLI first, both globally and in your project. On my v11 project the local install failed:
$ npm i -D @nestjs/cli@latest @nestjs/schematics@latest
npm error ERESOLVE unable to resolve dependency tree
npm error Found: typescript@5.9.3
npm error peer typescript@">=6.0.0" from @nestjs/schematics@12.0.7
@nestjs/schematics 12 needs TypeScript 6 or newer as a peer, and a v11 project has 5.x. Skipping the local install doesn't help either. The global v12 binary hands off to the project's local v11 CLI, which answers Invalid command: upgrade. The fix is to bump TypeScript in the same command:
npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6
Use TypeScript 6, not 7. typescript@latest is 7.0.2 now, and the Nest CLI can't build with it yet (more on that below). The schematics also need Node.js 22.22.3+, 24.15+ or 26+, which is a higher floor than running the app.
Step 2: run nest upgrade --dry-run
npx nest upgrade --dry-run
On my app, the dry run printed this report and changed nothing:
Changes applied:
✔ Updated NestJS packages: @nestjs/common@^12.0.0, @nestjs/core@^12.0.0, @nestjs/testing@^12.0.0,
@nestjs/platform-express@^12.0.0, @nestjs/cli@^12.0.0, @nestjs/schematics@^12.0.0, @nestjs/config@^12.0.0
✔ Updated "ts-jest" from "^29.2.5" to "^29.4.0" (first release supporting Jest 30)
✔ /src/app.module.ts: moved "validationOptions" settings under "validationOptions.libraryOptions"
✔ Updated "joi" from "^17.13.8" to "^18.0.0" (Joi v18 implements Standard Schema, required by @nestjs/config v12)
Notes:
ℹ Lifecycle hooks are now invoked by component hierarchy level (found in /src/users/users.service.ts)
Please review:
⚠ Jest: the NestJS 12 packages are ESM-only and Jest can only require() ESM modules on Node.js v24.9+
Action required:
➜ tsconfig.build.json does not set "rootDir". TypeScript 6 requires it explicitly (error TS5011)
It's a good report. It found the Joi schema, the config option that needs to move, and the file that uses lifecycle hooks. Add --no-observe (or --observe) to skip the prompt about setting up @nestjs/observe.
Step 3: run nest upgrade
npx nest upgrade --no-observe
This took 6 seconds including the install. It changed 3 files: package.json, package-lock.json and one line in app.module.ts:
- validationOptions: { allowUnknown: true, abortEarly: true },
+ validationOptions: { libraryOptions: { allowUnknown: true, abortEarly: true } },
It doesn't touch your module format, so a CommonJS app stays CommonJS. It doesn't switch you to Vitest or oxlint either.
Step 4: fix the TypeScript 6 errors
After the upgrade, nest build failed with five errors:
error TS5011: The common source directory of 'tsconfig.build.json' is './src'. The 'rootDir' setting must be explicitly set
error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.
src/users/create-user.dto.ts:3:14 - error TS2564: Property 'email' has no initializer and is not definitely assigned in the constructor.
These come from TypeScript 6, not from NestJS. TypeScript 6 turns strict on by default, which brings in strictPropertyInitialization, and that flags every class-validator DTO property. It also deprecates baseUrl and makes rootDir explicit. The v12 project template sets these options:
// tsconfig.json
{
"compilerOptions": {
"rootDir": ".",
"strictPropertyInitialization": false
// and delete "baseUrl": "./" unless you use path aliases
}
}
// tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "rootDir": "./src" },
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
The upgrade report only mentioned tsconfig.build.json. My e2e tests use the base tsconfig.json through ts-jest, and they failed with the same TS5011 error until I added "rootDir": "." there too. If you use path aliases, replace baseUrl with paths entries relative to the tsconfig, or set "ignoreDeprecations": "6.0" for now. My TypeScript 7 upgrade guide covers the rest of the 6.0 and 7.0 changes.
Step 5: make Jest load the ESM packages
Once it compiled, every Jest suite failed:
Must use import to load ES Module: node_modules/@nestjs/testing/index.js
- Use Node v24.9+ where Jest supports require(esm) natively
I was already on Node 24.22, and Node 26.12 failed the same way. Jest 30.5.2 only takes its ESM path when vm.SyntheticModule exists, and Node only exposes it behind a flag. Setting that flag fixed all six tests:
"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"
(Use cross-env if you need Windows support.) The alternative is to move to Vitest, which is what new v12 projects use. More on that below.
Which NestJS 12 breaking changes actually bit?
@Optional() inheritance depends on the exact v12 version
The migration guide says a subclass without its own constructor no longer inherits its parent's @Optional() markers, and that Nest now throws UnknownDependenciesException where v11 injected undefined. I tested this directly with @nestjs/testing:
| Case | 11.2.7 | 12.0.0 to 12.0.4 | 12.1.0 to 12.1.3 |
| --- | --- | --- | --- |
| Subclass with no constructor | resolves | throws | resolves |
| Subclass that redeclares the constructor without @Optional() | resolves | throws | throws |
On v11, both subclasses silently got undefined for the missing dependency. 12.1.0 added a lookup that finds the class which actually declares the constructor, which brought the inherited markers back for subclasses without a constructor. A subclass that redeclares its constructor without @Optional() throws on every v12 release, and on early 12.0.x releases the constructor-less subclass throws too. Declare the marker again on the subclass:
@Injectable()
class UsersRepo extends BaseRepo {
constructor(@Optional() @Inject(AUDIT) audit?: AuditLog) {
super(audit);
}
}
Lifecycle hook order
nest upgrade flagged my hooks file. The order of onModuleInit and onApplicationBootstrap across my repository and service was identical on v11 and v12. The change only affects providers at different levels of the component hierarchy. If your teardown or startup code depends on another provider's hook having run, add a test that checks the order.
Route shadowing is still opt-in
GET /users/me declared after GET /users/:id still returned the :id handler's 400 error on v12, the same as v11. The new options are off by default. Turn them on to get warnings and literal-before-parameter ordering:
const app = await NestFactory.create(AppModule, {
routeConflictPolicy: { duplicate: 'error', shadow: 'warn' },
routeResolutionStrategy: 'specificity',
});
With both set, /users/me returned {"me":true}, and Nest logged a WARN line for each literal and parameter route pair that overlaps. I recommend turning both on in new apps.
How do I use Zod for validation in NestJS 12?
Add a schema to the parameter decorator and register StandardSchemaValidationPipe. No custom pipe or extra package is needed:
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(2),
age: z.number().int().min(13).max(120),
});
export type CreateUser = z.infer<typeof createUserSchema>;
@Controller('zusers')
export class ZUsersController {
constructor(private users: UsersService) {}
@Post()
create(@Body({ schema: createUserSchema }) body: CreateUser) {
return this.users.create(body);
}
@Get(':id')
find(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
return this.users.find(id);
}
}
// main.ts: both pipes can run side by side during a migration
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
new StandardSchemaValidationPipe(),
);
With both pipes registered globally, class-validator DTO routes and Zod routes work in the same app. Each pipe only acts on parameters it recognises, so you can migrate one controller at a time. Invalid input returns the usual 400 response, with messages prefixed by field path:
{"message":["email: Invalid email address","name: Too small: expected string to have >=2 characters","age: Too small: expected number to be >=13"],"error":"Bad Request","statusCode":400}
Unknown keys are stripped by default, as with whitelist: true, because z.object drops them.
On the same app, server and load, POST /zusers with Zod 4.6.7 handled 20,804 req/s, and POST /users with class-validator handled 16,237 req/s. That makes Zod 28% faster, with p99 latency of 6.2 ms against 7.3 ms. Zod also gives you a single source for the TypeScript type, the validation and the OpenAPI schema. On v12 I'd use it for new endpoints. class-validator stays fully supported, so there's no need to rewrite working DTOs.
!NestJS 11 vs 12 benchmark: cold start 296 ms on v11, 386 ms on v12 CommonJS, 363 ms on v12 ESM; requests per second for hello, GET user and POST with validation, with Zod at 20,804 vs class-validator at 16,237
Machine-readable error codes come with v12 too. throw new BadRequestException('Password is too weak', { errorCode: 'WEAK_PASSWORD' }) returned {"message":"Password is too weak","error":"Bad Request","statusCode":400,"errorCode":"WEAK_PASSWORD"}, so clients can branch on a stable code instead of parsing the message.
Is NestJS 12 slower than NestJS 11?
Request handling is not slower. Startup is.
| Metric (Node 24.22) | v11.2.7 | v12.1.3 CJS | v12.1.3 ESM |
| --- | --- | --- | --- |
| Cold start, median | 296 ms | 386 ms | 363 ms |
| Peak RSS during start | 94 MB | 108 MB | 108 MB |
| Import core + common + platform-express | 212 ms | 262 ms | 256 ms |
| GET / hello, req/s | 36,777 | 34,866 | 36,789 |
| GET /users/:id, req/s | 29,998 | 28,387 | 30,251 |
| POST /users + class-validator, req/s | 17,173 | 16,237 | 16,921 |
Throughput moved by 5% or less in either direction, which is within the run-to-run variation. Cold start grew by about 90 ms (30%) in a CommonJS app, and most of that is the framework's own import cost: 50 ms more just to load the three core packages. The CJS app's require(esm) path is not the main cause, because the same app converted to ESM was only 23 ms faster. Adding Zod and a second controller added another 44 ms.
For a long-running API, 90 ms at boot doesn't matter. For serverless and scale-to-zero it does, and two cheap fixes help:
- Enable Node's compile cache. With NODE_COMPILE_CACHE=/tmp/node-cache, v12 started in 338 ms instead of 386 ms, and v11 in 257 ms.
- Run on Bun. The same v12 ESM build started in 264 ms on Bun 1.4.3, faster than v11 on Node.
Node 26.12 started the v12 app in 355 ms, close to Node 24 with the compile cache.
Should I convert my NestJS app to ESM?
You don't have to: nest upgrade leaves CommonJS apps alone, and they run fine. Converting gave me 23 ms of startup and nothing else measurable. Do it when a dependency you need is ESM-only and uses top-level await (which require(esm) can't load), or when you want Vitest without extra configuration.
The conversion took me ten minutes on a small app:
1. Add "type": "module" to package.json. Projects generated with @nestjs/schematics 11.0.6+ already use "module": "nodenext", so tsconfig needs no other change.
2. Add .js to every relative import ('./app.module.js'). A one-line regex changed 9 files.
3. Replace __dirname with import.meta.dirname and call await bootstrap().
4. Fix namespace imports of CommonJS packages. My build passed, but startup crashed with TypeError: Joi.string is not a function until I changed import * as Joi from 'joi' to import Joi from 'joi'. TypeScript doesn't catch this one, so start the app after converting.
Jest vs Vitest and ESLint vs oxlint in NestJS 12
A new v12 project (nest new, which defaulted to ESM when run without a TTY) gets this package.json: "type": "module", vitest 4 with vite 8, oxlint plus oxlint-tsgolint for type-aware rules, and TypeScript 6. There's no SWC plugin. I copied the template's Vitest config into my converted app, and all six e2e tests passed unchanged, including DI that depends on decorator metadata.
| Task (same tests or same files) | Old default | New default |
| --- | --- | --- |
| e2e suite, NestJS 11 + Jest 30 | 1.22 s | |
| e2e suite, NestJS 12 | 1.62 s (Jest + vm-modules) | 0.98 s (Vitest 4.1) |
| Lint src/ and test/ | 2.14 s (ESLint 9 + typescript-eslint) | 0.39 s (oxlint 1.88, type-aware) |
| Type-check (tsc --noEmit) | 0.98 s (TypeScript 6.0.3) | 0.31 s (TypeScript 7.0.2) |
Jest got 33% slower on v12 because of the ESM loading path. Vitest was 40% faster than that, and 20% faster than Jest on v11. oxlint was 5.5 times faster than ESLint on this small codebase, and the gap grows with more files. It's not a drop-in replacement, though. ESLint reported 61 problems (35 of them Prettier formatting and 25 no-unsafe-* rules), while oxlint reported one warning that ESLint didn't (no-misused-spread). Keep Prettier as a separate step if you move to oxlint.
Does NestJS 12 support TypeScript 7?
You can't build NestJS 12 with TypeScript 7 through the Nest CLI yet. With typescript@7.0.2 installed, nest build stops with:
Error The installed TypeScript version (7.0.2) does not expose the programmatic compiler API that the Nest CLI requires.
TypeScript 7.0 ships the "tsc" executable only; the compiler API is expected to return in 7.1.
Please install TypeScript 6 (e.g. "npm i -D typescript@^6") until then.
nest build -b swc stops with the same error. Plain tsc -p tsconfig.build.json from TypeScript 7 compiled the app correctly, and the output booted normally. So if you want the 3x faster type-check now, run tsc 7 for type-checking and builds in CI, and keep TypeScript 6 for nest start --watch until 7.1.
Can I run NestJS 12 on Bun?
Yes. The compiled ESM app ran unchanged with bun dist/main.js, including class-validator, Joi config and Express. Under the same load test, Bun 1.4.3 beat Node 24.22:
| NestJS 12.1.3 ESM app | Node 24.22 | Bun 1.4.3 |
| --- | --- | --- |
| Cold start | 361 ms | 264 ms |
| GET / hello, req/s | 36,789 | 54,867 (1.49x) |
| GET /users/:id, req/s | 30,251 | 48,553 (1.61x) |
| POST /users + class-validator, req/s | 16,921 | 22,158 (1.31x) |
The v12 CLI also accepts bun as a package manager. For the broader trade-offs (memory over time, APM agents, native addons), see my Bun vs Node benchmark.
!NestJS 12 upgrade path: bump CLI with TypeScript 6, nest upgrade --dry-run, nest upgrade, fix tsconfig rootDir and strictPropertyInitialization, set Jest vm-modules or move to Vitest, then adopt Zod and route conflict policy
NestJS 12 upgrade checklist
1. Make sure you're on Node.js 22.22.3+, 24.15+ or 26 for the CLI. The app itself needs only 20.19+ or 22.12+.
2. Run npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6.
3. Run npx nest upgrade --dry-run and read the report.
4. Run npx nest upgrade --no-observe and review the diff.
5. tsconfig: add rootDir to both files, remove baseUrl, and set strictPropertyInitialization: false if you use class DTOs.
6. Tests: add NODE_OPTIONS=--experimental-vm-modules for Jest, or move to Vitest.
7. Search for subclasses that redeclare a constructor and add @Optional() where needed.
8. Run the app and hit every route, especially if you use NATS, GraphQL subscriptions, Terminus or custom pipes.
9. Optionally turn on routeConflictPolicy and routeResolutionStrategy: 'specificity', write new endpoints with Zod, and set NODE_COMPILE_CACHE in production.
On AWS Lambda's Node 20, 22 and 24 runtimes, require(esm) is off by default. Set NODE_OPTIONS=--experimental-require-module there for a CommonJS app.
Common mistakes when upgrading to NestJS 12
- Installing typescript@latest. That's 7.0, and the Nest CLI refuses to build with it. Pin ^6 until TypeScript 7.1.
- Upgrading only the global CLI. The global binary hands off to the project's local v11 CLI, which doesn't know upgrade.
- Trusting the "Node 24.9+" Jest note. On Node 24.22 and 26.12, Jest 30 still needed --experimental-vm-modules to load @nestjs/testing.
- Fixing only tsconfig.build.json. Tests compile with tsconfig.json, which needs rootDir too.
- Converting to ESM and keeping import * as X for CommonJS packages. It compiles and then crashes at startup.
- Missing redeclared @Optional() markers. A subclass that redeclares its constructor now throws UnknownDependenciesException instead of injecting undefined, and 12.0.0 to 12.0.4 also throw for subclasses without a constructor. Use 12.1.x and re-mark optional parameters.
- Rewriting every DTO to Zod at once. Both pipes run side by side. Migrate one controller at a time.
Related: if you're choosing a database layer for your Nest API, read Drizzle vs Prisma in 2026. If you're exposing the API to AI agents, read MCP vs API.
Related: NestJS on the Fastify adapter was 1.45 to 1.66× faster than on Express in my Hono vs Express vs Fastify benchmark. And since nest new now scaffolds Oxlint, see Oxlint vs Biome vs ESLint: Oxlint ran this site's ESLint rules 81× faster.
Key takeaways
- NestJS 12 shipped on August 27, 2026, with ESM-only packages, Standard Schema validation, a rebuilt CLI and @nestjs/observe. 12.1.3 is current.
- nest upgrade --dry-run gives an accurate report, but upgrade the local CLI together with typescript@^6 or npm fails with ERESOLVE.
- After the upgrade, fix TypeScript 6's rootDir (in both tsconfigs), baseUrl and strict DTO errors, and give Jest --experimental-vm-modules.
- Throughput stayed within 5% of v11. Cold start grew by about 90 ms, and NODE_COMPILE_CACHE or Bun wins most of it back.
- Zod through @Body({ schema }) was 28% faster than class-validator, 20,804 vs 16,237 req/s, and both pipes can run in one app.
- Vitest ran the same e2e suite 40% faster than Jest on v12, and oxlint linted 5.5 times faster than ESLint.
- TypeScript 7 can type-check and build a Nest 12 app with plain tsc, but nest build needs TypeScript 6 until 7.1.
---
Originally published on fabwaseem.com.
N8N: Fair-code workflow automation platform, Neovim, Supabase | GitHub Curated Picks | 2026-10-15
GitHub Curated Picks reviews 11 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption.
Chapters:
00:00 2026-10-15 | GitHub Curated Picks
00:05 Pick #1 N8N
00:33 Pick #2 Neovim
00:48 Pick #3 Supabase
01:03 Pick #4 Ruff
01:13 Pick #5 Nest
01:27 Pick #6 Compose
01:40 Pick #7 Transformers
01:56 Pick #8 Svelte
02:08 Pick #9 Paperless Ngx
02:23 Pick #10 Sqlfluff
02:37 Pick #11 Megatron LM
Source ledger:
github.com/trending
api.github.com
github.com/n8n-io/n8n/blob/master/README.md
github.com/n8n-io/n8n/commit/50c4ced2241430e13fa7…
github.com/n8n-io/n8n/releases/tag/stable
api.github.com
github.com/neovim/neovim/blob/master/README.md
github.com/neovim/neovim/commit/f505b0636e0e0010f…
api.github.com
github.com/supabase/supabase/blob/master/README.md
github.com/supabase/supabase/commit/4215b6ca32a3b…
api.github.com
github.com/astral-sh/ruff/blob/main/README.md
github.com/astral-sh/ruff/commit/83a3afc3156d73ae…
api.github.com
github.com/nestjs/nest/blob/master/Readme.md
github.com/nestjs/nest/commit/b461186f20c245d5238…
api.github.com
github.com/docker/compose/blob/main/README.md
github.com/docker/compose/commit/89e818966a5c2110…
api.github.com
huggingface/transformers
github.com/huggingface/transformers/commit/e24eb5…
api.github.com
github.com/sveltejs/svelte/blob/main/README.md
github.com/sveltejs/svelte/commit/79e7c3bd346bec0…
api.github.com
paperless-ngx/paperless-ngx
github.com/paperless-ngx/paperless-ngx/commit/14a…
api.github.com
sqlfluff/sqlfluff
github.com/sqlfluff/sqlfluff/commit/8b68456e30eec…
api.github.com
NVIDIA/Megatron-LM
github.com/NVIDIA/Megatron-LM/commit/9131593673d0…
n8n-io/n8n
neovim/neovim
supabase/supabase
astral-sh/ruff
nestjs/nest
docker/compose
sveltejs/svelte
Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance.
14 views
How Cabloy/Vona Avoids NestJS Circular Dependencies: A Source-Code Walkthrough of Lazy Property Injection
---
title: How Cabloy/Vona Avoids NestJS Circular Dependencies: A Source-Code Walkthrough of Lazy Property Injection
published: true
description: From constructor injection to lazy property getters — a shift in resolution timing turns circular dependencies from "must work around" to "naturally safe."
tags:
- NestJS
- DependencyInjection
- CircularDependency
- TypeScript
cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com
---
> Circular dependency is not about "can it be resolved?" — it is about "when does resolution happen?"
NestJS circular dependencies are a frequently discussed topic: two Services inject each other through their constructors, and the instantiation moment falls into a "chicken-or-egg" deadlock that can only be broken with forwardRef(). Cabloy/Vona does not have this problem at the architectural level — not because it implements smarter cycle detection, but because it defers dependency resolution to first access.
This article uses Cabloy/Vona's BeanContainer and @Use decorator source code to break down the complete implementation of lazy property injection.
---
The Short Conclusion
| Dimension | NestJS Constructor Injection | Vona Lazy Property Injection |
| --- | --- | --- |
| Injection timing | Synchronous during instantiation | Deferred to first property access |
| Circular dependency | Requires forwardRef() + deferred cycle resolution | Naturally safe, no special handling |
| Constructor parameters | Dependencies passed as constructor arguments | Zero constructor dependencies, only getters |
| Resolution entry | Container recursively resolves during resolve() | Getter triggers container cache lookup |
| Caching strategy | Stored in container after instantiation | Entered into container cache before __init__ |
In one sentence: NestJS is "inject at construction time, resolve eagerly" — cycles deadlock during instantiation. Vona is "register at declaration time, resolve on access" — the instantiation phase never touches dependencies, and circular dependencies are dissolved by timing.
---
The Root of NestJS Circular Dependencies
First, let us understand where NestJS's problem lies.
The Timing Dilemma of Constructor Injection
NestJS dependency injection happens during the constructor phase. When the container resolves A:
@Injectable()
class ServiceA {
constructor(private readonly b: ServiceB) {} // Constructor parameter = dependency
}
The container needs to create B before it can create A. But if B's constructor also depends on A:
@Injectable()
class ServiceB {
constructor(private readonly a: ServiceA) {}
}
The resolution chain becomes: new A() → needs B → new B() → needs A → A is not yet constructed → deadlock.
forwardRef Is a Workaround, Not an Elimination
NestJS uses forwardRef() and InjectionToken to defer the resolution order, letting one side complete instantiation first, then injecting the other side afterward. But this is merely a workaround:
- Developers must manually identify cycles and annotate with forwardRef;
- Over-long cycle chains may still encounter "instance not ready" problems;
- It adds implicit complexity to the code.
Vona's design chose a different path: it does not give circular dependencies any opportunity to arise in the first place.
---
Vona's Three Lines of Defense
Vona structurally does not have an "instantiation-phase" circular dependency problem. The core difference is that dependency resolution is moved from instantiation time to first access time. Three mechanisms ensure this:
1. No Constructor Injection
Vona's Service/Bean always extends BeanBase, and the constructor accepts no dependencies. After instance creation, the container fills in app / ctx references, and dependencies are declared on properties via the @Use(...) decorator.
2. Lazy Property Injection (the Key Mechanism)
Dependencies declared with @Use are not injected at Bean creation time. Instead, a getter is installed using Object.defineProperty. On first property access, the getter resolves the target Bean from the container and caches the result.
3. Container Itself Is Runtime Lookup
BeanContainer.create returns a lazy Proxy. this.bean.xxxService, this.scope('module-x') all resolve from the container by bean full name at access time, producing no static wiring.
---
Source Code Breakdown: Five Stages
Below, we break down the source code across five stages: declaration → storage → mounting → resolution → caching.
Stage 1: Declaration — @Use Decorator Does Only Two Things
// use.ts
export function Use(options?: IDecoratorUseOptions | string): PropertyDecorator & MethodDecorator {
return function (target: object, prop: MetadataKey, descriptor?: PropertyDescriptor) {
// ① Read property type from TS design:type metadata
const beanClass = appMetadata.getDesignType(target, prop) as Constructable;
// ② Class → bean full name mapping
let beanFullName: string | undefined = options.beanFullName;
if (!beanFullName) {
beanFullName = appResource.getBeanFullName(beanClass);
}
// ③ Register metadata — no instantiation happens here
appResource.addUse(target, { ...options, prop, beanFullName, descriptor });
};
}
- It reads the property type from the design:type metadata generated by TypeScript's emitDecoratorMetadata, so @Use() without arguments can still determine the target Bean;
- The class → bean full name (module-name.scene.name) mapping must exist, otherwise it throws directly;
- It calls appResource.addUse to register metadata — no instantiation occurs.
Stage 2: Storage — Metadata Map Keyed by Property Name, Inherited Along the Prototype Chain
// resource.ts
addUse(target: object, options: IDecoratorUseOptionsBase) {
registerMappedClassMetadataKey(target, SymbolDecoratorUse);
const uses = appMetadata.getOwnMetadataMap(true, SymbolDecoratorUse, target);
uses[options.prop] = options;
}
getUses(target: object): Record<MetadataKey, IDecoratorUseOptionsBase> | undefined {
return appMetadata.getMetadata(SymbolDecoratorUse, target); // Note: getMetadata
}
Writing uses getOwnMetadataMap (attached to the class prototype, keyed by prop), reading uses getMetadata (traverses up the prototype chain) — this means subclasses automatically inherit all @Use dependencies declared by the parent class.
Stage 3: Mounting — Only Install Getter After Instantiation, No Resolution
The strict ordering in the Bean instance creation flow:
// beanContainer.ts
private _createBeanInstance(...) {
const beanInstance = this._prepareBeanInstance(...); // new + attach app/ctx
this[SymbolBeanContainerInstances][key] = beanInstance; // ① Enter container cache first
this._initBeanInstance(beanFullName, beanInstance, args); // ② Inject getter → __init__
}
This ordering ensures: even if a Bean accesses a dependency property during __init__, triggering reverse resolution, the container can directly hit the cache and return the already-registered instance.
The core implementation of injection:
// beanContainer.ts
private _injectBeanInstancePropLazy(beanInstance, prop, targetBeanFullName, useOptions) {
const self = this;
Object.defineProperty(beanInstance, prop, {
enumerable: true, // Property is enumerable (visible in serialization/spread)
configurable: true, // Redefinable for HMR
get() {
if (!beanInstance[SymbolBeanInstancePropsLazy])
beanInstance[SymbolBeanInstancePropsLazy] = {};
if (!beanInstance[SymbolBeanInstancePropsLazy][prop]) {
// First access: actually resolve, then cache to instance-private slot
beanInstance[SymbolBeanInstancePropsLazy][prop] =
self._injectBeanInstanceProp(beanInstance, targetBeanFullName, useOptions);
// App-level container additionally registers this dependency edge to HMR
self.containerType === 'app' &&
self.app.meta.hmr.addBeanInstanceProp(beanInstance, prop, targetBeanFullName);
}
return beanInstance[SymbolBeanInstancePropsLazy][prop];
},
});
}
Three implementation highlights:
1. The getter closure captures the container that installed it (self) — properties installed by the app container always go through app resolution, while those installed by the ctx (request-level) container go through ctx resolution;
2. The cache slot is an instance-level plain object SymbolBeanInstancePropsLazy — after first access, the property degrades to "pseudo-static" with zero lookup cost thereafter;
3. The enumerable: true / configurable: true combination is deliberate: enumerable ensures consistent instance export behavior, configurable lets HMR tear down and reinstall.
Stage 4: Resolution — Three Injection Scopes
First access triggers _injectBeanInstanceProp:
// beanContainer.ts
private _injectBeanInstanceProp(
beanInstance,
targetBeanFullName: string,
useOptions: IDecoratorUseOptionsBase,
) {
const injectionScope = useOptions.injectionScope ?? 'app';
if (injectionScope === 'app') {
targetInstance = this.app.bean._getBeanSelectorInner(...); // Application-level singleton
} else if (injectionScope === 'ctx') {
targetInstance = this._getBeanSelectorInner(...); // Request-level singleton
} else if (injectionScope === 'new') {
targetInstance = this._newBeanInner(...); // New instance each time
}
return targetInstance;
}
- 'app' / 'ctx' fall into _getBeanSelectorInner: the container instance cache key is beanFullName or beanFullName#selector, creating only if === undefined;
- 'new' creates a new instance each time, but since the outer getter already caches, only one resolution happens per property lifecycle;
- The selector parameter is taken from three sources by priority: the decorated property's descriptor getter → useOptions.init static parameters → useOptions.selector string expression (evaluation context includes self/app/ctx).
Stage 5: Caching — Container Instance Cache
The caching logic of _getBeanSelectorInner:
// beanContainer.ts
_getBeanSelectorInner<T>(
beanFullName: Constructable<T> | string,
withSelector?: boolean,
...args
): T {
const beanOptions = appResource.getBean(beanFullName as any);
const fullName = beanOptions.beanFullName;
const key = __getSelectorKey(fullName, withSelector, args[0]);
if (this[SymbolBeanContainerInstances][key] === undefined) {
this._newBeanInner(true, fullName, withSelector, ...args);
}
return this[SymbolBeanContainerInstances][key] as T;
}
Cache key generation:
function __getSelectorKey(beanFullName: string, withSelector?: boolean, selector?: any) {
if (!withSelector) return beanFullName;
const isSelectorValid = !isNilOrEmptyString(selector);
return !isSelectorValid ? beanFullName : `${beanFullName}#${selector}`;
}
---
Complete Timing of A↔B Circular Dependency
Using two Services A and B that @Use each other as an example, here is the complete timing:
| Moment | Action | Result |
| --- | --- | --- |
| Declaration | @Use registers A→B, B→A mappings | No instance interaction |
| Business triggers _getBean('A') | new A(), install lazy getter, enter container cache | A fully exists |
| First access a.serviceB | Getter executes → container creates B (B's construction and getter installation also do not touch A), enters cache | B fully exists |
| B internally accesses b.serviceA | Getter executes → container cache hit → returns existing A | Cycle closes, no reentrance |
Two invariants guarantee cycle safety:
1. Getters never execute during the instantiation phase (no mutual touching);
2. The dependency target is guaranteed to be registered in the container cache at resolution time (the "enter cache before init" ordering covers even the aggressive case of accessing dependencies during __init__).
---
Real Code Example
Vona's @Use Usage
import { BeanBase, Use } from 'zova';
import { Service } from 'zova-module-a-bean';
@Service()
export class BeanRouter extends BeanBase {
@Use()
$$sysRouter: SysRouter;
@Use()
$$modelPageData: ModelPageData;
}
Note: zero constructor parameters; all dependencies are declared on properties via @Use. Resolution is triggered only when this.$$sysRouter is accessed.
Equivalent Path in Vona Backend Business Code
In the current repository, backend business code more commonly uses this.scope('module-x').service.yyy and this.bean.xxx instead of property-level @Use — these go through the same container's runtime lookup path:
// BeanContainer.create returns a Proxy
static create(app: VonaApplication, ctx: VonaContext | undefined) {
const beanContainer = new BeanContainer(app, ctx);
return new Proxy(beanContainer, {
get(obj, prop) {
if (typeof prop === 'symbol') return obj[prop];
if (obj[prop]) return obj[prop];
return obj._getBean(prop as any); // Resolves on access
},
});
}
This is essentially the "method-call version" of the same lazy resolution mechanism. The two paths differ only in declaration style (decorator metadata vs string lookup); the resolution and caching mechanisms are fully shared.
---
Failure Boundary and HMR
Lazy property injection is not without boundaries to be aware of:
_removeBean only destroys the instance in the container cache and calls its __dispose__; the cached reference in other Bean instances' SymbolBeanInstancePropsLazy is not automatically cleared — this is exactly why HMR needs addBeanInstanceProp to register dependency edges for rewiring.
So in hot-reload scenarios, the framework can use the registered dependency edges to find all properties that reference the replaced Bean, tear down old getters, install new ones, clear cache slots, and complete dependency rewiring.
---
Module-Level Circular Dependencies
Beyond instance-level circular dependencies, Vona also makes design-level provisions at the module level. Per the documentation:
> Using this.$scope.<fixedModule>, app.scope(...), or this.app.scope(...) does not by itself require adding the target module to the caller's vonaModule.dependencies. Lookup does not create a module dependency edge, so lookup alone cannot create a circular dependency.
This means module-level circular dependencies are also avoided by design: cross-module lookups do not produce dependency declarations and do not constitute module dependency edges, so modules cannot form circular dependencies merely by "using each other's services."
---
Summary
The root cause of NestJS's circular dependency problem lies in its design choice of "constructor injection + eager resolution": dependencies must be resolved at instantiation time, circular references inevitably lead to deadlock, and forwardRef can only defer the resolution order.
Vona's lazy property injection mechanism dissolves this problem through three design decisions:
1. Zero constructor dependencies — the instantiation phase touches no dependencies;
2. Lazy property getter — dependency resolution is deferred to first access;
3. Enter cache before init — guarantees the dependency target is always ready at resolution time.
Circular dependencies in Vona require no forwardRef, no special annotation, no developer identification of cycles — they are naturally safe by timing. This is not smarter cycle detection; it is an architectural choice that gives circular dependencies no opportunity to arise.
---
References and Source Code
NestJS
- NestJS Circular Dependency Documentation
- NestJS forwardRef Source Code
CabloyJS / Vona
- Vona BeanContainer Source Code
- Vona @Use Decorator Source Code
- Vona AppResource Source Code
- Vona Foundation Documentation: Module Scope and Dependencies
- Vona Module Dependencies Documentation
2 comments
I've contributed to @nestframework Idempotency by making lock TTL configurable and fixing flaky tests.
Thanks to NestJS creator @kammysliwiec
idempotency#28 t.co
11 likes · 179 views
@MrBye32 Here're the reproduction steps and code for all libs/frameworks:
nest#18063
10 likes · 1,161 views
@saltyAom Reproduction steps and code for all libs/frameworks:
nest#18063
13 likes · 1,135 views
Mentions
@nestframework 📈 Stateless, so it scales behind any load balancer 🔄 Clients of the 2026 and 2025 protocol revisions on one endpoint 🔑 OAuth 2.1 built in: your identity provider's tokens, scopes per tool 🧪 An in-memory MCP client for your tests nestjs/mcp
TypeScript 7.1 beta's native compiler checks a 353,000-line project in 2.8 seconds instead of 12.4 --- title: "TypeScript 7.1 beta's native compiler checks a 353,000-line project in 2.8 seconds instead of 12.4" published: true published_at: 2026-10-31 13:00 +0200 description: "I benchmarked TypeScript 7's Go-based compiler against 6.0.3 on a real NestJS checkout: 2.5x to 4.5x faster, not Microsoft's claimed 8-12x, and 7.0's stable release used more memory than the old compiler while dropping the API ts-node needs." tags: typescript, javascript, performance, devops ai_disclosure_level: ai_assisted --- TypeScript 7.0 replaced the compiler's JavaScript implementation with a native port written in Go, and Microsoft's own numbers describe a compile that dropped from 125.7 seconds to 10.6 seconds, a gain of roughly 12x. I wanted to know whether that holds on a codebase I didn't choose for a marketing slide, so I checked out a real project and ran all three compilers against it myself: TypeScript 6.0.3, the last release before the rewrite, 7.0.2, the first stable native release, and 7.1.0-beta, which shipped eleven days ago. I used nestjs/nest, cloned fresh and with its real dependencies installed. Its root tsconfig.json pulls in the packages, the integration tests, and the samples: 2,934 files once @types/node and the rest of the dependency graph are counted, about 353,000 lines. tsc --noEmit against that tree is a reasonable stand-in for what a CI job or a pre-commit hook does every day. The headline number Three runs each, fastest reported, same machine, same checkout: | Compiler | Fastest run | Peak memory | |---|---|---| | TypeScript 6.0.3 | 12.42s | 1.13 GB | | TypeScript 7.0.2 | 4.95s | 1.86 GB | | TypeScript 7.1.0-beta | 2.76s | 1.06 GB | 7.0.2 is 2.5x faster than 6.0.3. 7.1.0-beta is 4.5x faster. Both are real, both are worth having, and neither is the 8-12x Microsoft advertises. The VS Code benchmark that produced that number is presumably a bigger, more type-heavy codebase than a mid-sized Node framework, and benchmarks run on your own code will usually look more modest than the one in the release post. If your project looks more like NestJS than like the TypeScript compiler's own source, plan around 2.5x to 4.5x, not 12x. The number that argues against the headline Peak memory went the wrong way for the first stable release. 7.0.2 used more memory than the compiler it replaced, not less, consistently across three runs: 6.0.3: Maximum resident set size: 1,119,044 – 1,128,916 KB 7.0.2: Maximum resident set size: 1,778,072 – 1,863,628 KB 7.1.0-beta: Maximum resident set size: 1,013,640 – 1,056,968 KB A native rewrite is supposed to use less memory than a JavaScript one doing the same job, and in 7.1.0-beta it does, coming in slightly under the old compiler. But 7.0.2, the release most people will actually install because it's the one tagged latest, used about 65% more memory than 6.0.3 at the same task. A CI runner sized around TypeScript 6's memory footprint could OOM on 7.0.2 and recover on 7.1 once that's out of beta. I don't have an explanation for the regression beyond what I could observe: it happened, consistently, on this codebase, and it reversed itself in the next beta. What it refuses The old compiler exposed a JavaScript API: require('typescript') gave you createProgram, transpileModule, createSourceFile, and anything built on top of those. That's gone in 7.0.2. The package's default export is now this: const { version } = require("../package.json"); exports.version = version; exports.versionMajorMinor = "7.0"; That's it. No createProgram. I confirmed it wasn't a module resolution problem on my end by installing ts-node 10.9.2 and pointing it at TypeScript 7.0.2: TypeError: Cannot read properties of undefined (reading 'fileExists') at readConfig (ts-node/dist/configuration.js:91:33) ts.sys doesn't exist, because ts is just { version, versionMajorMinor }. Any tool that calls into the compiler programmatically rather than shelling out to tsc breaks the same way: ts-node, ts-jest, and the type-aware rules in typescript-eslint all depend on createProgram. I expected 7.1.0-beta to fix this, because the beta's headline feature is a stabilized API, and coverage of the release describes it as unblocking Vue, Svelte, and Angular tooling. It does ship a new API, under typescript/sync and typescript/async rather than 7.0's typescript/unstable/sync. I checked the package's export map directly: 7.0.2: "./unstable/sync": "./dist/api/sync/api.js" 7.1.0-beta: "./sync": "./dist/api/sync/api.js" But the default . export is still just version.cjs in 7.1.0-beta too. I reran the exact same ts-node test against it and got the identical crash. "Stabilizes the API" means a new, differently-shaped API exists at a new import path. It does not mean require('typescript').createProgram starts working again. Anything written against the classic API has to be rewritten against the new entry points; nothing becomes compatible by just bumping a version number. Config files break too, in a smaller but more common way. I fed both compilers a set of deprecated tsconfig.json options. Most of them, like target: "ES3" or out, were already hard errors under 6.0.3, just with a different error code. One wasn't: compilerOptions: { "importsNotUsedAsValues": "remove" } 6.0.3: (accepted silently, no output, exit 0) 7.0.2: error TS5023: Unknown compiler option 'importsNotUsedAsValues'. importsNotUsedAsValues has been deprecated in favour of verbatimModuleSyntax for several releases, but 6.0.3 still tolerated it quietly. 7.0 doesn't. Anyone upgrading a tsconfig that was last touched two or three TypeScript versions ago should grep it for options that used to be ignorable warnings, because some of them are now a build that won't start. Under concurrency The release notes mention new flags for parallel project-reference builds, and tsc --help --all on 7.0.2 lists --singleThreaded, meaning parallel is now the default. I built a small composite project, four independent packages referenced from a root tsconfig.json, each containing 400 generated generic type definitions, enough that the per-project check takes a fraction of a second rather than nothing: | Mode | Wall time (3 runs) | |---|---| | 7.0.2, default (parallel) | 0.75s, 0.79s, 0.83s | | 7.0.2, --singleThreaded | 1.38s, 1.40s, 1.48s | | 6.0.3 (no parallel option exists) | 4.26s | Parallelism alone is worth about 1.8x on four independent projects on this four-core machine, separate from the raw speed of the native checker. The two effects stack: the full build goes from 4.26 seconds on the old compiler to under a second on the new one by default, with no flag to set. On a bigger monorepo with more independent project references and more cores, I'd expect that gap to widen rather than narrow, because the whole point of --singleThreaded existing as an escape hatch is that someone will eventually need to turn the parallelism off, not that most people will want to. Watching it in production tsc --extendedDiagnostics still works, and the breakdown shows where the time actually went on the NestJS checkout: 6.0.3 7.0.2 7.1.0-beta Parse time: 1.16s 0.419s 0.291s Bind time: 0.61s 0.093s 0.093s Check time: 9.91s 4.603s 2.502s Total time: 12.21s 5.146s 2.910s Check time is where almost all of the wall clock goes in every version, and it's also where the native compiler's advantage is largest proportionally, not just absolutely. If you're deciding whether upgrading is worth the breakage above, this is the number to look at in your own --extendedDiagnostics output before and after: a codebase that spends most of its time in parsing rather than checking (lots of files, not much generic-heavy type logic) will see a smaller win than NestJS did here. I'd treat the Symbols and Types counts in that same output with caution, though: they roughly doubled between 6.0.3 and 7.0.2 on an identical checkout, which almost certainly reflects a different internal counting convention in the rewrite rather than the new compiler creating twice as many actual symbols, and I have no way to confirm which from the outside. What I got wrong on the way My first run of the headline benchmark gave suspiciously good numbers: all three compilers finished in about a quarter of a second, uniformly, with exactly one error each. I almost wrote that down as "TypeScript 7 is absurdly fast even before you account for anything else," which should have been the first clue, because a result that flattering is usually the harness, not the finding. The one error was TS2688: Cannot find type definition file for 'node', because I'd installed NestJS's dependencies with a plain npm install that hit a peer-dependency conflict and silently produced no node_modules directory at all. Every compiler was failing to even construct the program, immediately, before checking a single file, and reporting an identical fast "success." Reinstalling with --legacy-peer-deps fixed it and pushed every run from a quarter of a second to several seconds, which is when the real differences between the three versions showed up. Run it yourself This installs three versions of TypeScript side by side without touching a global install, clones NestJS, and runs the headline comparison: mkdir -p tsbench/engines && cd tsbench for v in 6.0.3 7.0.2 7.1.0-beta; do npm install --prefix "engines/ts_${v//[.-]/_}" --silent "typescript@$v" done git clone --depth 1 https://github.com/nestjs/nest.git target/nest npm install --prefix target/nest --ignore-scripts --legacy-peer-deps --silent for v in 6_0_3 7_0_2 7_1_0_beta; do echo "=== $v ===" TSC="engines/ts_$v/node_modules/.bin/tsc" time "$TSC" --noEmit -p target/nest/tsconfig.json done The --legacy-peer-deps flag matters: without it, npm install can fail outright on a conflicting peer dependency, or in the worse case leave you with no node_modules and every compiler reporting the same hollow "success" I got on my first attempt. Check the file count and error count match across versions before you trust a timing number from this, not just the time itself. What to do with this If you're on TypeScript 6 and your build is dominated by type-checking rather than I/O, 7.0 is worth the upgrade on speed alone, even at 2.5x rather than 12x. Before you do it, grep your tsconfig files for options your team hasn't touched in a while, because some of yesterday's warnings are today's build failures. And if your toolchain includes ts-node, ts-jest, or anything doing type-aware linting, check whether it has shipped a release that targets TypeScript 7's new API paths before you bump the version in CI; the error if it hasn't is a flat crash, not a graceful fallback, and 7.1's "stabilized API" doesn't change that for code still written against the old one.
Tracing one NestJS request: handlers, response wrapping and test boundaries An illustrative walkthrough of GET /cats in Nest’s official public 01-cats-app sample. It follows registered modules, the route-specific guard decision, the in-memory service and global response transformation, then distinguishes the HTTP test’s service override from real data behavior. This is not an analysis of anyone’s private application; the test source was inspected, not executed. Pinned sample: github.com/nestjs/nest/tree/060fae304f9cec0677110… Request lifecycle: docs.nestjs.com Produced with Armature: app.armature.ai reddit.com
NestJS 12 Lifecycle Hooks: The Order You Can't Assume A NestJS app upgrades from v11 to v12. No application code changes — just npm i @nestjs/core@latest @nestjs/common@latest. The test suite, which has been green for months, starts failing intermittently: a report service reads a cache that a metrics service is supposed to have already populated, and about one run in three, it hasn't been. Nothing in the diff touches either service. The bug was always there — it just used to be invisible, because the old, undocumented order happened to keep working. The official v12.0.0 release notes list the cause in one line, under breaking changes: "Lifecycle hooks are now invoked by component hierarchy level." The fix isn't a workaround for v12 — it's realizing that "hook order" was never a promise Nest made for providers like these two. What you'll learn By the end of this article you'll be able to: - Name every NestJS lifecycle hook and state precisely when each one fires - Explain which part of hook ordering Nest actually guarantees — and which part it has always reserved the right to change - Recognize the v12 breaking change for what it is, and know why it broke code that looked correct - Fix a hook-ordering bug the right way: with dependency injection, not a delay or a flag - Enable and reason about shutdown hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) correctly Who this is for You've written at least one onModuleInit() in a NestJS service and injected a provider into another provider's constructor. You don't need any prior lifecycle-hook experience beyond that — this article builds the full model from there. This article is written against NestJS 12.x (verified against @nestjs/core's npm dist-tags and the nestjs/nest GitHub release history: latest is 12.2.0, v12.0.0 shipped August 27, 2026, and the 11.x line has moved to the legacy npm tag at 11.2.7). The point releases since v12.0.0 (through 12.2.0) fix shutdown-sequence and startup-failure edge cases but don't change the onModuleInit/onApplicationBootstrap ordering model described below. Everything here is core-container behavior — it works identically whether your HttpAdapter is Express or Fastify, since lifecycle hooks fire before Nest ever hands a request to either one. Table of contents - [The problem: a hook order nobody promised](#the-problem-a-hook-order-nobody-promised) - [The mental model: constructed order vs. hook order](#the-mental-model-constructed-order-vs-hook-order) - [Stage 1: the five lifecycle hooks](#stage-1-the-five-lifecycle-hooks) - [Stage 2: the order Nest does guarantee](#stage-2-the-order-nest-does-guarantee) - [Stage 3: the order it never guaranteed — and v12 changed](#stage-3-the-order-it-never-guaranteed-and-v12-changed) - [Stage 4: the fix is DI, not a delay](#stage-4-the-fix-is-di-not-a-delay) - [Stage 5: shutdown hooks and async hooks](#stage-5-shutdown-hooks-and-async-hooks) - [Edge cases and gotchas](#edge-cases-and-gotchas) - [Best practices](#best-practices) - [FAQ](#faq) - [Cheat sheet](#cheat-sheet) The problem: a hook order nobody promised Here's the setup, trimmed to the part that matters: // metrics.module.ts let requestCounterSeed = 0; @Injectable() export class MetricsService implements OnModuleInit { onModuleInit() { requestCounterSeed = loadSeedFromDisk(); // populates module-scope state } } // reports.service.ts @Injectable() export class ReportsService implements OnModuleInit { private baseline = 0; onModuleInit() { this.baseline = requestCounterSeed; // reads it back — no DI involved } } ReportsService never injects MetricsService. There's no constructor(private metrics: MetricsService) anywhere — the two talk through a module-scope variable instead, and the code "works" as long as MetricsService's onModuleInit happens to run before ReportsService's. Under Nest 10 and 11, it reliably did — because hook order followed each module's position in AppModule's imports array, and MetricsModule happened to be listed first. It is not a coincidence that this looks fragile. It is fragile — it was fragile before the upgrade too. The v12 release note doesn't introduce the bug; it removes the accident that was hiding it. Once hook invocation moved to being ordered "by component hierarchy level" instead of import position, ReportsService.onModuleInit can run first, and requestCounterSeed is still 0 when it reads it. The mental model: constructed order vs. hook order The mental model: NestJS gives you exactly one hard guarantee about initialization order, and it comes from the dependency graph, not from where a hook happens to live. If provider B is constructor-injected into provider A, Nest must fully construct B — and run B's own onModuleInit — before it can finish constructing A and run A's onModuleInit. That ordering is real, structural, and the same in every version, because it falls directly out of how constructor injection works: you cannot hand A a B that doesn't exist yet. Everything else — the order of two providers that share no dependency edge, sitting in sibling modules that never import each other — has never been part of that contract. It happened to be stable for a long time because Nest's internal bootstrap walked the module tree in a specific, observable order. "Observable" is not "guaranteed": nothing in Nest's public API ever promised that two unrelated providers would always initialize in the same relative order, which is exactly why the v12 release notes can change it and call it a breaking change rather than a bug fix — it was breaking an assumption, not a contract. Key concept: if two providers' initialization order matters to your code, that requirement has to be expressed as a dependency — an injected constructor parameter — not inferred from import position. If there's no edge in the graph, there's no promise about the order. <!-- playground:start --> 🎮 Try it yourself ▶️ Open the interactive playground → _Runs right in your browser — poke at it and watch the concept react live._ <!-- playground:end --> Stage 1: the five lifecycle hooks Nest exposes five lifecycle interfaces a provider (or module class) can implement. Each is a plain TypeScript interface with one method, and implementing it is how you opt in: import { OnModuleInit, OnApplicationBootstrap, OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown, } from '@nestjs/common'; - onModuleInit() — called once the host module's own dependencies have been resolved and constructed. This is the hook for "set up something that needs this provider's injected dependencies to already exist." - onApplicationBootstrap() — called once every module in the app has finished onModuleInit, but before Nest starts listening for connections. Use it for work that should only start once the whole graph is up, not just this provider's own corner of it. - onModuleDestroy() — the mirror of onModuleInit, run during teardown. - beforeApplicationShutdown(signal?: string) — runs after onModuleDestroy, with access to the OS signal (SIGTERM, SIGINT) that triggered shutdown, if there was one. - onApplicationShutdown(signal?: string) — the last hook to run, immediately before the process would otherwise exit. onModuleInit and onApplicationBootstrap only fire if you actually call app.init() or app.listen() — a TestingModule you only compile() but never init() skips both, which is a common source of "why didn't my hook run in this test" confusion. Stage 2: the order Nest does guarantee The one ordering rule worth memorizing: a provider's onModuleInit runs after the onModuleInit of every provider it depends on, transitively. This is a direct consequence of constructor injection being resolved depth-first. @Injectable() export class CacheService implements OnModuleInit { onModuleInit() { console.log('CacheService ready'); } } @Injectable() export class OrdersService implements OnModuleInit { constructor(private readonly cache: CacheService) {} onModuleInit() { console.log('OrdersService ready'); // always logs after CacheService } } Because OrdersService injects CacheService, "CacheService ready" is guaranteed to print before "OrdersService ready" — in Nest 10, 11, 12, and every future version, because changing it would mean handing OrdersService a CacheService whose own setup hasn't run yet. That's not an implementation detail Nest could casually break; it's the definition of what constructor injection means. onApplicationBootstrap extends the same guarantee one level further: because it only fires after every module's onModuleInit has completed, anything you do there can safely assume the entire app's onModuleInit work — dependency-ordered or not — is already done. Stage 3: the order it never guaranteed — and v12 changed What Nest has never promised is the relative order between two providers that share no dependency edge — like MetricsService and ReportsService above. Before v12, that order was a side effect of how Nest walked the module tree (roughly, each module's imports in declaration order, depth-first). It was deterministic in practice, which is exactly what made it easy to depend on by accident. The v12.0.0 release notes list the actual change under breaking changes, verbatim: "Lifecycle hooks are now invoked by component hierarchy level." The notes don't spell out the full new algorithm step by step — their own guidance is to "review ordering assumptions between related providers/modules in init, teardown, and tests" rather than to assume your old mental model of "import order" still predicts anything. That's the honest, practical takeaway: don't try to reverse-engineer the new implicit order and depend on that instead — it's exactly the same mistake with a different answer. Stage 4: the fix is DI, not a delay The wrong fixes are tempting and all fragile: reordering imports in AppModule (back to relying on an undocumented detail, just a different one), adding a setTimeout before reading the shared state (a race condition with extra steps), or introducing a global "ready" flag with a polling loop. All three fix today's symptom and leave the actual defect — an implicit dependency — in place. The real fix is to make the dependency explicit, the same way you would for any other cross-service call: @Injectable() export class ReportsService implements OnModuleInit { private baseline = 0; constructor(private readonly metrics: MetricsService) {} onModuleInit() { this.baseline = this.metrics.getSeed(); // guaranteed: metrics already initialized } } @Injectable() export class MetricsService implements OnModuleInit { private seed = 0; onModuleInit() { this.seed = loadSeedFromDisk(); } getSeed() { return this.seed; } } Once ReportsService constructor-injects MetricsService, Stage 2's guarantee applies again: Nest cannot construct ReportsService without MetricsService already existing, and MetricsService.onModuleInit is guaranteed to have already run. The module-scope variable is gone, replaced by a method call on an instance Nest already knows how to order correctly — because now there's an edge in the graph for it to order by. Key concept: a lifecycle-ordering bug is almost always a missing edge in the dependency graph wearing a disguise. Add the edge (inject the thing), and the ordering problem disappears on its own — you don't have to solve it, Nest already does. Stage 5: shutdown hooks and async hooks Every hook in Stage 1 can be async — return a Promise, and Nest waits for it before moving to the next stage. An async onModuleInit() that awaits a database connection genuinely blocks app.listen() until it resolves, which is usually exactly what you want (don't accept traffic before you can serve it) but is worth knowing explicitly, since a slow connection now shows up as a slow boot, not a slow first request. Teardown hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) only run on app.close() or an OS shutdown signal — and only if you've opted in: // main.ts const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); // without this, SIGTERM/SIGINT skip teardown hooks entirely await app.listen(3000); Forgetting enableShutdownHooks() is the most common reason a service that's supposed to flush a queue or close a connection pool on shutdown silently doesn't — the process just exits, and no teardown hook ever runs. <!-- quiz:start --> 🧠 Test yourself Think it clicked? Take the 8-question quiz → _Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._ <!-- quiz:end --> Edge cases and gotchas - Tests that only compile(), never init(). Test.createTestingModule({...}).compile() builds the module graph but never calls onModuleInit or onApplicationBootstrap. If a hook-driven side effect doesn't appear to run in a test, check whether the test ever called .init() on the result. - Circular dependencies with forwardRef(). Two providers that depend on each other via forwardRef(() => OtherService) can't have a clean "B before A" order, because there is no clean order — Nest resolves the cycle, but a hook in either one that assumes the other is fully initialized can still race. Prefer breaking the cycle over patching around it. - Global modules aren't a shortcut around ordering. A module marked @Global() is visible everywhere without importing it, but it is still initialized according to the same rules — "global" changes visibility, not hook order. - onApplicationBootstrap running late in a slow-starting app. Because it waits for every module's onModuleInit, one slow async hook anywhere in the graph delays onApplicationBootstrap for the whole app, not just its own module. - Upgrading from 11.x to 12.x with hidden cross-module hook assumptions. This is the v12-specific case this article opened with. If a test suite starts flaking right after the upgrade, treat it as a real latent bug the version bump exposed, not a Nest regression to work around. Best practices - Never let a hook in one provider assume another unrelated provider already ran unless that assumption is backed by a real constructor-injection edge. If it isn't, add the edge. - Use onApplicationBootstrap for anything that needs the whole app ready, not just its own module's dependencies — don't reach for a hack to delay onModuleInit work instead. - Call app.enableShutdownHooks() in production if any provider relies on onModuleDestroy or onApplicationShutdown to flush state or close connections cleanly. - When you upgrade a major Nest version, read the release's breaking-changes list before you read anything else — a change like this one produces no compile error and no obvious runtime error, only an intermittent one. - If you genuinely need to verify ordering empirically, log each hook with its provider's name during boot rather than assuming either the "old" or "new" order from memory — the release notes don't spell out the full new algorithm, so the only way to know your app's actual order is to look at it. FAQ Does onModuleInit run in constructor order? Only where a real dependency edge exists. If A injects B, B's onModuleInit is guaranteed to run first. Between providers with no such edge, there's no guaranteed order, and NestJS 12's release notes confirm that relative order can and did change. Why didn't my onModuleDestroy fire when I sent SIGTERM? Almost always because app.enableShutdownHooks() was never called. Without it, Nest doesn't attach the OS signal listeners that trigger the teardown hooks at all. Does an async onModuleInit block app.listen()? Yes. Nest awaits every onModuleInit (and then every onApplicationBootstrap) before it starts accepting connections, so a slow one delays the whole app's startup, not just the part that's slow. Is this different for Fastify vs Express? No. Lifecycle hooks fire during module bootstrap, entirely before Nest hands anything to the underlying HTTP adapter, so the behavior is identical regardless of which one you're using. Will NestJS 13 reorder hooks again? There's no way to know in advance, and that uncertainty is the actual point of this article: code that depends only on guaranteed ordering (real dependency edges) can't be broken by a future reshuffle of the unguaranteed part, because it never relied on it. Cheat sheet | Hook | Fires when | Guaranteed relative to | | --- | --- | --- | | onModuleInit() | Host module's own dependencies constructed | After every injected dependency's onModuleInit | | onApplicationBootstrap() | Every module's onModuleInit has finished | After all onModuleInit hooks app-wide | | onModuleDestroy() | app.close() or shutdown signal (needs enableShutdownHooks()) | Mirrors onModuleInit's dependency order, in reverse | | beforeApplicationShutdown(signal) | After all onModuleDestroy hooks | After every onModuleDestroy | | onApplicationShutdown(signal) | Last hook before process exit | After beforeApplicationShutdown | | Two providers, no dependency edge | — | Not guaranteed, by design — don't rely on it | | Opt in to teardown hooks | app.enableShutdownHooks() in main.ts | Must be called explicitly | | Fix an ordering bug | Constructor-inject the provider you actually depend on | Turns an assumption into a real graph edge | Key takeaways - NestJS guarantees hook order along the dependency graph: if A injects B, B's hooks always run first. That rule has never changed and never will, because it's a consequence of constructor injection, not a scheduling choice. - It has never guaranteed order between providers with no dependency edge — that was always an implementation detail, and NestJS 12's release notes confirm it changed. - A hook-ordering bug is a missing dependency edge in disguise. The fix is to inject the thing you depend on, not to chase the new implicit order. - Teardown hooks need app.enableShutdownHooks() to run at all — a very common silent gap. The report service from the top of this article didn't start being wrong the day it was upgraded to Nest 12 — it was wrong from the day someone reached for a module-scope variable instead of a constructor parameter. The version bump just stopped covering for it. Once you're reading "hook order" as "the dependency graph, and nothing else," that whole bug class stops being a mystery you debug after an upgrade and starts being something you can rule out on sight. Has a major-version upgrade ever exposed an ordering assumption you didn't know your code was making? Drop it in the comments. <!-- related:start --> 📚 Read next - NestJS Dependency Injection Explained (with Cheat Sheet) - NestJS Testing Module: Provider Overrides (with Cheat Sheet) - NestJS Module Encapsulation Explained (with Cheat Sheet) <!-- related:end --> --- 🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you. Thanks for reading! Let's stay connected: - ⭐ GitHub — follow me and star the projects: github.com/parsajiravand - 💬 Discord — join the frontend best-practices community: discord.gg - 📸 Instagram — frontend best practices, daily: @bestpractice___
NestJS 12 Migration Guide: Upgrading from v11, Tested and Benchmarked NestJS 12 is the first major release of the Node.js framework since v11. It was released on August 27, 2026, and 12.1.3 is the current version. The headline changes are ESM-only packages, built-in Standard Schema validation (so Zod, Valibot and ArkType work without a custom pipe), a rebuilt CLI with a nest upgrade command, Vitest and oxlint as defaults for new projects, and the new @nestjs/observe SDK. Existing CommonJS apps keep working. The release notes cover what changed. This guide covers what happened when I actually upgraded. I built a small but realistic NestJS 11 API with class-validator DTOs, Joi config validation, lifecycle hooks, an @Optional() dependency and e2e tests. I ran nest upgrade on it, fixed everything that broke, and benchmarked v11 against v12 for startup, throughput and memory. I also measured Zod against class-validator, Jest against Vitest, ESLint against oxlint, TypeScript 6 against TypeScript 7, and NestJS 12 on Bun. Every number below comes from those runs. TL;DR: should I upgrade to NestJS 12? Yes, but budget an hour rather than a minute. nest upgrade bumped every package and rewrote my config module correctly, but the build and the test suite both failed afterwards. TypeScript 6 needs three tsconfig fixes, and Jest needs --experimental-vm-modules to load the ESM-only packages. Once those are fixed, request throughput is the same as v11 (within 5%), cold start is about 90 ms slower, and you can move to Zod validation, which was 28% faster than class-validator in my test. | | NestJS 11.2.7 | NestJS 12.1.3 | | --- | --- | --- | | Package format | CommonJS | ESM only (CJS apps work via require(esm)) | | Minimum Node.js to run | 20 | 20.19+ or 22.12+ | | TypeScript in a new project | 5.x | 6.x (7.0 not yet supported by nest build) | | Validation | class-validator ValidationPipe | class-validator or Standard Schema (Zod, Valibot, ArkType) | | New-project defaults | Jest, ESLint, CommonJS | Vitest, oxlint, ESM | | Cold start, my API (median) | 296 ms | 386 ms (CJS), 363 ms (ESM) | | Hello route, req/s | 36,777 | 34,866 (CJS), 36,789 (ESM) | | POST with validation, req/s | 17,173 | 16,237 class-validator, 20,804 Zod | What's new in NestJS 12? The NestJS v12.0.0 release groups the changes into five areas: - ESM packages. Every core @nestjs/* package now ships as ESM. A CommonJS app loads them through Node's require(esm), so you don't have to convert your own code. - Standard Schema. @Body(), @Query(), @Param() and @RawBody() take a schema option. The new StandardSchemaValidationPipe and StandardSchemaSerializerInterceptor validate requests and responses with any Standard Schema library. @nestjs/config now accepts a Zod schema as validationSchema too. - A rebuilt CLI. It adds nest upgrade (with --dry-run), nest deploy, Rspack as the default bundler for monorepos, and bun as a package manager. nest new scaffolds ESM, Vitest and oxlint by default. - Native observability. @nestjs/observe hooks into Nest's own request lifecycle instead of patching the HTTP server like a generic APM agent. - Smaller additions. These include routeConflictPolicy and routeResolutionStrategy for route shadowing, machine-readable errorCode on HTTP exceptions, structured logging params, and graceful shutdown in the Express adapter. The breaking changes that matter most for typical REST APIs are listed in the official migration guide: ESM packages, lifecycle hook ordering by component hierarchy, @Optional() inheritance, @nestjs/config moving to Standard Schema (Joi must be v18+), NATS v3, graphql-ws-only subscriptions, and the removal of the legacy Terminus health indicator API. How I tested the NestJS 12 upgrade - App: a NestJS 11.2.7 project scaffolded with CLI 11.0.24 (Express adapter), plus a users module. It has a class-validator DTO, @nestjs/config 4 with a Joi 17 schema and custom validationOptions, onModuleInit and onApplicationBootstrap hooks, a repository subclass that inherits an @Optional() constructor dependency, a GET /users/me route declared after GET /users/:id, and Jest e2e tests with supertest. - Upgrade: CLI 12.0.8 nest upgrade, which installed NestJS 12.1.3, @nestjs/config 12.0.1 and Joi 18.3.0. - Runtime: Node.js 24.22.0 on an 8 vCPU Xeon VM. Node.js 26.12.0 and Bun 1.4.3 were used for the cross-runtime checks. - Benchmarks: hyperfine for startup and tooling times (20 runs for startup, 5 to 8 for tools). For HTTP I used oha with 64 connections, taking the median of three 10-second runs after a warm-up, with the server pinned to 2 cores and oha on the other 6. "Cold start" means the wall time to start the process, boot Nest, bind the port and close it again. Inside the process, NestFactory.create() plus listen() took 70 ms on v11 and 84 to 93 ms on v12. The rest is Node starting and loading modules. How do I upgrade to NestJS 12? The upgrade has five steps. The first trips almost everyone coming from a v11 project. Step 1: upgrade the CLI (and TypeScript with it) The guide says to upgrade the CLI first, both globally and in your project. On my v11 project the local install failed: $ npm i -D @nestjs/cli@latest @nestjs/schematics@latest npm error ERESOLVE unable to resolve dependency tree npm error Found: typescript@5.9.3 npm error peer typescript@">=6.0.0" from @nestjs/schematics@12.0.7 @nestjs/schematics 12 needs TypeScript 6 or newer as a peer, and a v11 project has 5.x. Skipping the local install doesn't help either. The global v12 binary hands off to the project's local v11 CLI, which answers Invalid command: upgrade. The fix is to bump TypeScript in the same command: npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6 Use TypeScript 6, not 7. typescript@latest is 7.0.2 now, and the Nest CLI can't build with it yet (more on that below). The schematics also need Node.js 22.22.3+, 24.15+ or 26+, which is a higher floor than running the app. Step 2: run nest upgrade --dry-run npx nest upgrade --dry-run On my app, the dry run printed this report and changed nothing: Changes applied: ✔ Updated NestJS packages: @nestjs/common@^12.0.0, @nestjs/core@^12.0.0, @nestjs/testing@^12.0.0, @nestjs/platform-express@^12.0.0, @nestjs/cli@^12.0.0, @nestjs/schematics@^12.0.0, @nestjs/config@^12.0.0 ✔ Updated "ts-jest" from "^29.2.5" to "^29.4.0" (first release supporting Jest 30) ✔ /src/app.module.ts: moved "validationOptions" settings under "validationOptions.libraryOptions" ✔ Updated "joi" from "^17.13.8" to "^18.0.0" (Joi v18 implements Standard Schema, required by @nestjs/config v12) Notes: ℹ Lifecycle hooks are now invoked by component hierarchy level (found in /src/users/users.service.ts) Please review: ⚠ Jest: the NestJS 12 packages are ESM-only and Jest can only require() ESM modules on Node.js v24.9+ Action required: ➜ tsconfig.build.json does not set "rootDir". TypeScript 6 requires it explicitly (error TS5011) It's a good report. It found the Joi schema, the config option that needs to move, and the file that uses lifecycle hooks. Add --no-observe (or --observe) to skip the prompt about setting up @nestjs/observe. Step 3: run nest upgrade npx nest upgrade --no-observe This took 6 seconds including the install. It changed 3 files: package.json, package-lock.json and one line in app.module.ts: - validationOptions: { allowUnknown: true, abortEarly: true }, + validationOptions: { libraryOptions: { allowUnknown: true, abortEarly: true } }, It doesn't touch your module format, so a CommonJS app stays CommonJS. It doesn't switch you to Vitest or oxlint either. Step 4: fix the TypeScript 6 errors After the upgrade, nest build failed with five errors: error TS5011: The common source directory of 'tsconfig.build.json' is './src'. The 'rootDir' setting must be explicitly set error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. src/users/create-user.dto.ts:3:14 - error TS2564: Property 'email' has no initializer and is not definitely assigned in the constructor. These come from TypeScript 6, not from NestJS. TypeScript 6 turns strict on by default, which brings in strictPropertyInitialization, and that flags every class-validator DTO property. It also deprecates baseUrl and makes rootDir explicit. The v12 project template sets these options: // tsconfig.json { "compilerOptions": { "rootDir": ".", "strictPropertyInitialization": false // and delete "baseUrl": "./" unless you use path aliases } } // tsconfig.build.json { "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "./src" }, "include": ["src"], "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } The upgrade report only mentioned tsconfig.build.json. My e2e tests use the base tsconfig.json through ts-jest, and they failed with the same TS5011 error until I added "rootDir": "." there too. If you use path aliases, replace baseUrl with paths entries relative to the tsconfig, or set "ignoreDeprecations": "6.0" for now. My TypeScript 7 upgrade guide covers the rest of the 6.0 and 7.0 changes. Step 5: make Jest load the ESM packages Once it compiled, every Jest suite failed: Must use import to load ES Module: node_modules/@nestjs/testing/index.js - Use Node v24.9+ where Jest supports require(esm) natively I was already on Node 24.22, and Node 26.12 failed the same way. Jest 30.5.2 only takes its ESM path when vm.SyntheticModule exists, and Node only exposes it behind a flag. Setting that flag fixed all six tests: "test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json" (Use cross-env if you need Windows support.) The alternative is to move to Vitest, which is what new v12 projects use. More on that below. Which NestJS 12 breaking changes actually bit? @Optional() inheritance depends on the exact v12 version The migration guide says a subclass without its own constructor no longer inherits its parent's @Optional() markers, and that Nest now throws UnknownDependenciesException where v11 injected undefined. I tested this directly with @nestjs/testing: | Case | 11.2.7 | 12.0.0 to 12.0.4 | 12.1.0 to 12.1.3 | | --- | --- | --- | --- | | Subclass with no constructor | resolves | throws | resolves | | Subclass that redeclares the constructor without @Optional() | resolves | throws | throws | On v11, both subclasses silently got undefined for the missing dependency. 12.1.0 added a lookup that finds the class which actually declares the constructor, which brought the inherited markers back for subclasses without a constructor. A subclass that redeclares its constructor without @Optional() throws on every v12 release, and on early 12.0.x releases the constructor-less subclass throws too. Declare the marker again on the subclass: @Injectable() class UsersRepo extends BaseRepo { constructor(@Optional() @Inject(AUDIT) audit?: AuditLog) { super(audit); } } Lifecycle hook order nest upgrade flagged my hooks file. The order of onModuleInit and onApplicationBootstrap across my repository and service was identical on v11 and v12. The change only affects providers at different levels of the component hierarchy. If your teardown or startup code depends on another provider's hook having run, add a test that checks the order. Route shadowing is still opt-in GET /users/me declared after GET /users/:id still returned the :id handler's 400 error on v12, the same as v11. The new options are off by default. Turn them on to get warnings and literal-before-parameter ordering: const app = await NestFactory.create(AppModule, { routeConflictPolicy: { duplicate: 'error', shadow: 'warn' }, routeResolutionStrategy: 'specificity', }); With both set, /users/me returned {"me":true}, and Nest logged a WARN line for each literal and parameter route pair that overlaps. I recommend turning both on in new apps. How do I use Zod for validation in NestJS 12? Add a schema to the parameter decorator and register StandardSchemaValidationPipe. No custom pipe or extra package is needed: import { z } from 'zod'; export const createUserSchema = z.object({ email: z.email(), name: z.string().min(2), age: z.number().int().min(13).max(120), }); export type CreateUser = z.infer<typeof createUserSchema>; @Controller('zusers') export class ZUsersController { constructor(private users: UsersService) {} @Post() create(@Body({ schema: createUserSchema }) body: CreateUser) { return this.users.create(body); } @Get(':id') find(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) { return this.users.find(id); } } // main.ts: both pipes can run side by side during a migration app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true }), new StandardSchemaValidationPipe(), ); With both pipes registered globally, class-validator DTO routes and Zod routes work in the same app. Each pipe only acts on parameters it recognises, so you can migrate one controller at a time. Invalid input returns the usual 400 response, with messages prefixed by field path: {"message":["email: Invalid email address","name: Too small: expected string to have >=2 characters","age: Too small: expected number to be >=13"],"error":"Bad Request","statusCode":400} Unknown keys are stripped by default, as with whitelist: true, because z.object drops them. On the same app, server and load, POST /zusers with Zod 4.6.7 handled 20,804 req/s, and POST /users with class-validator handled 16,237 req/s. That makes Zod 28% faster, with p99 latency of 6.2 ms against 7.3 ms. Zod also gives you a single source for the TypeScript type, the validation and the OpenAPI schema. On v12 I'd use it for new endpoints. class-validator stays fully supported, so there's no need to rewrite working DTOs. !NestJS 11 vs 12 benchmark: cold start 296 ms on v11, 386 ms on v12 CommonJS, 363 ms on v12 ESM; requests per second for hello, GET user and POST with validation, with Zod at 20,804 vs class-validator at 16,237 Machine-readable error codes come with v12 too. throw new BadRequestException('Password is too weak', { errorCode: 'WEAK_PASSWORD' }) returned {"message":"Password is too weak","error":"Bad Request","statusCode":400,"errorCode":"WEAK_PASSWORD"}, so clients can branch on a stable code instead of parsing the message. Is NestJS 12 slower than NestJS 11? Request handling is not slower. Startup is. | Metric (Node 24.22) | v11.2.7 | v12.1.3 CJS | v12.1.3 ESM | | --- | --- | --- | --- | | Cold start, median | 296 ms | 386 ms | 363 ms | | Peak RSS during start | 94 MB | 108 MB | 108 MB | | Import core + common + platform-express | 212 ms | 262 ms | 256 ms | | GET / hello, req/s | 36,777 | 34,866 | 36,789 | | GET /users/:id, req/s | 29,998 | 28,387 | 30,251 | | POST /users + class-validator, req/s | 17,173 | 16,237 | 16,921 | Throughput moved by 5% or less in either direction, which is within the run-to-run variation. Cold start grew by about 90 ms (30%) in a CommonJS app, and most of that is the framework's own import cost: 50 ms more just to load the three core packages. The CJS app's require(esm) path is not the main cause, because the same app converted to ESM was only 23 ms faster. Adding Zod and a second controller added another 44 ms. For a long-running API, 90 ms at boot doesn't matter. For serverless and scale-to-zero it does, and two cheap fixes help: - Enable Node's compile cache. With NODE_COMPILE_CACHE=/tmp/node-cache, v12 started in 338 ms instead of 386 ms, and v11 in 257 ms. - Run on Bun. The same v12 ESM build started in 264 ms on Bun 1.4.3, faster than v11 on Node. Node 26.12 started the v12 app in 355 ms, close to Node 24 with the compile cache. Should I convert my NestJS app to ESM? You don't have to: nest upgrade leaves CommonJS apps alone, and they run fine. Converting gave me 23 ms of startup and nothing else measurable. Do it when a dependency you need is ESM-only and uses top-level await (which require(esm) can't load), or when you want Vitest without extra configuration. The conversion took me ten minutes on a small app: 1. Add "type": "module" to package.json. Projects generated with @nestjs/schematics 11.0.6+ already use "module": "nodenext", so tsconfig needs no other change. 2. Add .js to every relative import ('./app.module.js'). A one-line regex changed 9 files. 3. Replace __dirname with import.meta.dirname and call await bootstrap(). 4. Fix namespace imports of CommonJS packages. My build passed, but startup crashed with TypeError: Joi.string is not a function until I changed import * as Joi from 'joi' to import Joi from 'joi'. TypeScript doesn't catch this one, so start the app after converting. Jest vs Vitest and ESLint vs oxlint in NestJS 12 A new v12 project (nest new, which defaulted to ESM when run without a TTY) gets this package.json: "type": "module", vitest 4 with vite 8, oxlint plus oxlint-tsgolint for type-aware rules, and TypeScript 6. There's no SWC plugin. I copied the template's Vitest config into my converted app, and all six e2e tests passed unchanged, including DI that depends on decorator metadata. | Task (same tests or same files) | Old default | New default | | --- | --- | --- | | e2e suite, NestJS 11 + Jest 30 | 1.22 s | | | e2e suite, NestJS 12 | 1.62 s (Jest + vm-modules) | 0.98 s (Vitest 4.1) | | Lint src/ and test/ | 2.14 s (ESLint 9 + typescript-eslint) | 0.39 s (oxlint 1.88, type-aware) | | Type-check (tsc --noEmit) | 0.98 s (TypeScript 6.0.3) | 0.31 s (TypeScript 7.0.2) | Jest got 33% slower on v12 because of the ESM loading path. Vitest was 40% faster than that, and 20% faster than Jest on v11. oxlint was 5.5 times faster than ESLint on this small codebase, and the gap grows with more files. It's not a drop-in replacement, though. ESLint reported 61 problems (35 of them Prettier formatting and 25 no-unsafe-* rules), while oxlint reported one warning that ESLint didn't (no-misused-spread). Keep Prettier as a separate step if you move to oxlint. Does NestJS 12 support TypeScript 7? You can't build NestJS 12 with TypeScript 7 through the Nest CLI yet. With typescript@7.0.2 installed, nest build stops with: Error The installed TypeScript version (7.0.2) does not expose the programmatic compiler API that the Nest CLI requires. TypeScript 7.0 ships the "tsc" executable only; the compiler API is expected to return in 7.1. Please install TypeScript 6 (e.g. "npm i -D typescript@^6") until then. nest build -b swc stops with the same error. Plain tsc -p tsconfig.build.json from TypeScript 7 compiled the app correctly, and the output booted normally. So if you want the 3x faster type-check now, run tsc 7 for type-checking and builds in CI, and keep TypeScript 6 for nest start --watch until 7.1. Can I run NestJS 12 on Bun? Yes. The compiled ESM app ran unchanged with bun dist/main.js, including class-validator, Joi config and Express. Under the same load test, Bun 1.4.3 beat Node 24.22: | NestJS 12.1.3 ESM app | Node 24.22 | Bun 1.4.3 | | --- | --- | --- | | Cold start | 361 ms | 264 ms | | GET / hello, req/s | 36,789 | 54,867 (1.49x) | | GET /users/:id, req/s | 30,251 | 48,553 (1.61x) | | POST /users + class-validator, req/s | 16,921 | 22,158 (1.31x) | The v12 CLI also accepts bun as a package manager. For the broader trade-offs (memory over time, APM agents, native addons), see my Bun vs Node benchmark. !NestJS 12 upgrade path: bump CLI with TypeScript 6, nest upgrade --dry-run, nest upgrade, fix tsconfig rootDir and strictPropertyInitialization, set Jest vm-modules or move to Vitest, then adopt Zod and route conflict policy NestJS 12 upgrade checklist 1. Make sure you're on Node.js 22.22.3+, 24.15+ or 26 for the CLI. The app itself needs only 20.19+ or 22.12+. 2. Run npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6. 3. Run npx nest upgrade --dry-run and read the report. 4. Run npx nest upgrade --no-observe and review the diff. 5. tsconfig: add rootDir to both files, remove baseUrl, and set strictPropertyInitialization: false if you use class DTOs. 6. Tests: add NODE_OPTIONS=--experimental-vm-modules for Jest, or move to Vitest. 7. Search for subclasses that redeclare a constructor and add @Optional() where needed. 8. Run the app and hit every route, especially if you use NATS, GraphQL subscriptions, Terminus or custom pipes. 9. Optionally turn on routeConflictPolicy and routeResolutionStrategy: 'specificity', write new endpoints with Zod, and set NODE_COMPILE_CACHE in production. On AWS Lambda's Node 20, 22 and 24 runtimes, require(esm) is off by default. Set NODE_OPTIONS=--experimental-require-module there for a CommonJS app. Common mistakes when upgrading to NestJS 12 - Installing typescript@latest. That's 7.0, and the Nest CLI refuses to build with it. Pin ^6 until TypeScript 7.1. - Upgrading only the global CLI. The global binary hands off to the project's local v11 CLI, which doesn't know upgrade. - Trusting the "Node 24.9+" Jest note. On Node 24.22 and 26.12, Jest 30 still needed --experimental-vm-modules to load @nestjs/testing. - Fixing only tsconfig.build.json. Tests compile with tsconfig.json, which needs rootDir too. - Converting to ESM and keeping import * as X for CommonJS packages. It compiles and then crashes at startup. - Missing redeclared @Optional() markers. A subclass that redeclares its constructor now throws UnknownDependenciesException instead of injecting undefined, and 12.0.0 to 12.0.4 also throw for subclasses without a constructor. Use 12.1.x and re-mark optional parameters. - Rewriting every DTO to Zod at once. Both pipes run side by side. Migrate one controller at a time. Related: if you're choosing a database layer for your Nest API, read Drizzle vs Prisma in 2026. If you're exposing the API to AI agents, read MCP vs API. Related: NestJS on the Fastify adapter was 1.45 to 1.66× faster than on Express in my Hono vs Express vs Fastify benchmark. And since nest new now scaffolds Oxlint, see Oxlint vs Biome vs ESLint: Oxlint ran this site's ESLint rules 81× faster. Key takeaways - NestJS 12 shipped on August 27, 2026, with ESM-only packages, Standard Schema validation, a rebuilt CLI and @nestjs/observe. 12.1.3 is current. - nest upgrade --dry-run gives an accurate report, but upgrade the local CLI together with typescript@^6 or npm fails with ERESOLVE. - After the upgrade, fix TypeScript 6's rootDir (in both tsconfigs), baseUrl and strict DTO errors, and give Jest --experimental-vm-modules. - Throughput stayed within 5% of v11. Cold start grew by about 90 ms, and NODE_COMPILE_CACHE or Bun wins most of it back. - Zod through @Body({ schema }) was 28% faster than class-validator, 20,804 vs 16,237 req/s, and both pipes can run in one app. - Vitest ran the same e2e suite 40% faster than Jest on v12, and oxlint linted 5.5 times faster than ESLint. - TypeScript 7 can type-check and build a Nest 12 app with plain tsc, but nest build needs TypeScript 6 until 7.1. --- Originally published on fabwaseem.com.
N8N: Fair-code workflow automation platform, Neovim, Supabase | GitHub Curated Picks | 2026-10-15 GitHub Curated Picks reviews 11 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption. Chapters: 00:00 2026-10-15 | GitHub Curated Picks 00:05 Pick #1 N8N 00:33 Pick #2 Neovim 00:48 Pick #3 Supabase 01:03 Pick #4 Ruff 01:13 Pick #5 Nest 01:27 Pick #6 Compose 01:40 Pick #7 Transformers 01:56 Pick #8 Svelte 02:08 Pick #9 Paperless Ngx 02:23 Pick #10 Sqlfluff 02:37 Pick #11 Megatron LM Source ledger: github.com/trending api.github.com github.com/n8n-io/n8n/blob/master/README.md github.com/n8n-io/n8n/commit/50c4ced2241430e13fa7… github.com/n8n-io/n8n/releases/tag/stable api.github.com github.com/neovim/neovim/blob/master/README.md github.com/neovim/neovim/commit/f505b0636e0e0010f… api.github.com github.com/supabase/supabase/blob/master/README.md github.com/supabase/supabase/commit/4215b6ca32a3b… api.github.com github.com/astral-sh/ruff/blob/main/README.md github.com/astral-sh/ruff/commit/83a3afc3156d73ae… api.github.com github.com/nestjs/nest/blob/master/Readme.md github.com/nestjs/nest/commit/b461186f20c245d5238… api.github.com github.com/docker/compose/blob/main/README.md github.com/docker/compose/commit/89e818966a5c2110… api.github.com huggingface/transformers github.com/huggingface/transformers/commit/e24eb5… api.github.com github.com/sveltejs/svelte/blob/main/README.md github.com/sveltejs/svelte/commit/79e7c3bd346bec0… api.github.com paperless-ngx/paperless-ngx github.com/paperless-ngx/paperless-ngx/commit/14a… api.github.com sqlfluff/sqlfluff github.com/sqlfluff/sqlfluff/commit/8b68456e30eec… api.github.com NVIDIA/Megatron-LM github.com/NVIDIA/Megatron-LM/commit/9131593673d0… n8n-io/n8n neovim/neovim supabase/supabase astral-sh/ruff nestjs/nest docker/compose sveltejs/svelte Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance.
How Cabloy/Vona Avoids NestJS Circular Dependencies: A Source-Code Walkthrough of Lazy Property Injection --- title: How Cabloy/Vona Avoids NestJS Circular Dependencies: A Source-Code Walkthrough of Lazy Property Injection published: true description: From constructor injection to lazy property getters — a shift in resolution timing turns circular dependencies from "must work around" to "naturally safe." tags: - NestJS - DependencyInjection - CircularDependency - TypeScript cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com --- > Circular dependency is not about "can it be resolved?" — it is about "when does resolution happen?" NestJS circular dependencies are a frequently discussed topic: two Services inject each other through their constructors, and the instantiation moment falls into a "chicken-or-egg" deadlock that can only be broken with forwardRef(). Cabloy/Vona does not have this problem at the architectural level — not because it implements smarter cycle detection, but because it defers dependency resolution to first access. This article uses Cabloy/Vona's BeanContainer and @Use decorator source code to break down the complete implementation of lazy property injection. --- The Short Conclusion | Dimension | NestJS Constructor Injection | Vona Lazy Property Injection | | --- | --- | --- | | Injection timing | Synchronous during instantiation | Deferred to first property access | | Circular dependency | Requires forwardRef() + deferred cycle resolution | Naturally safe, no special handling | | Constructor parameters | Dependencies passed as constructor arguments | Zero constructor dependencies, only getters | | Resolution entry | Container recursively resolves during resolve() | Getter triggers container cache lookup | | Caching strategy | Stored in container after instantiation | Entered into container cache before __init__ | In one sentence: NestJS is "inject at construction time, resolve eagerly" — cycles deadlock during instantiation. Vona is "register at declaration time, resolve on access" — the instantiation phase never touches dependencies, and circular dependencies are dissolved by timing. --- The Root of NestJS Circular Dependencies First, let us understand where NestJS's problem lies. The Timing Dilemma of Constructor Injection NestJS dependency injection happens during the constructor phase. When the container resolves A: @Injectable() class ServiceA { constructor(private readonly b: ServiceB) {} // Constructor parameter = dependency } The container needs to create B before it can create A. But if B's constructor also depends on A: @Injectable() class ServiceB { constructor(private readonly a: ServiceA) {} } The resolution chain becomes: new A() → needs B → new B() → needs A → A is not yet constructed → deadlock. forwardRef Is a Workaround, Not an Elimination NestJS uses forwardRef() and InjectionToken to defer the resolution order, letting one side complete instantiation first, then injecting the other side afterward. But this is merely a workaround: - Developers must manually identify cycles and annotate with forwardRef; - Over-long cycle chains may still encounter "instance not ready" problems; - It adds implicit complexity to the code. Vona's design chose a different path: it does not give circular dependencies any opportunity to arise in the first place. --- Vona's Three Lines of Defense Vona structurally does not have an "instantiation-phase" circular dependency problem. The core difference is that dependency resolution is moved from instantiation time to first access time. Three mechanisms ensure this: 1. No Constructor Injection Vona's Service/Bean always extends BeanBase, and the constructor accepts no dependencies. After instance creation, the container fills in app / ctx references, and dependencies are declared on properties via the @Use(...) decorator. 2. Lazy Property Injection (the Key Mechanism) Dependencies declared with @Use are not injected at Bean creation time. Instead, a getter is installed using Object.defineProperty. On first property access, the getter resolves the target Bean from the container and caches the result. 3. Container Itself Is Runtime Lookup BeanContainer.create returns a lazy Proxy. this.bean.xxxService, this.scope('module-x') all resolve from the container by bean full name at access time, producing no static wiring. --- Source Code Breakdown: Five Stages Below, we break down the source code across five stages: declaration → storage → mounting → resolution → caching. Stage 1: Declaration — @Use Decorator Does Only Two Things // use.ts export function Use(options?: IDecoratorUseOptions | string): PropertyDecorator & MethodDecorator { return function (target: object, prop: MetadataKey, descriptor?: PropertyDescriptor) { // ① Read property type from TS design:type metadata const beanClass = appMetadata.getDesignType(target, prop) as Constructable; // ② Class → bean full name mapping let beanFullName: string | undefined = options.beanFullName; if (!beanFullName) { beanFullName = appResource.getBeanFullName(beanClass); } // ③ Register metadata — no instantiation happens here appResource.addUse(target, { ...options, prop, beanFullName, descriptor }); }; } - It reads the property type from the design:type metadata generated by TypeScript's emitDecoratorMetadata, so @Use() without arguments can still determine the target Bean; - The class → bean full name (module-name.scene.name) mapping must exist, otherwise it throws directly; - It calls appResource.addUse to register metadata — no instantiation occurs. Stage 2: Storage — Metadata Map Keyed by Property Name, Inherited Along the Prototype Chain // resource.ts addUse(target: object, options: IDecoratorUseOptionsBase) { registerMappedClassMetadataKey(target, SymbolDecoratorUse); const uses = appMetadata.getOwnMetadataMap(true, SymbolDecoratorUse, target); uses[options.prop] = options; } getUses(target: object): Record<MetadataKey, IDecoratorUseOptionsBase> | undefined { return appMetadata.getMetadata(SymbolDecoratorUse, target); // Note: getMetadata } Writing uses getOwnMetadataMap (attached to the class prototype, keyed by prop), reading uses getMetadata (traverses up the prototype chain) — this means subclasses automatically inherit all @Use dependencies declared by the parent class. Stage 3: Mounting — Only Install Getter After Instantiation, No Resolution The strict ordering in the Bean instance creation flow: // beanContainer.ts private _createBeanInstance(...) { const beanInstance = this._prepareBeanInstance(...); // new + attach app/ctx this[SymbolBeanContainerInstances][key] = beanInstance; // ① Enter container cache first this._initBeanInstance(beanFullName, beanInstance, args); // ② Inject getter → __init__ } This ordering ensures: even if a Bean accesses a dependency property during __init__, triggering reverse resolution, the container can directly hit the cache and return the already-registered instance. The core implementation of injection: // beanContainer.ts private _injectBeanInstancePropLazy(beanInstance, prop, targetBeanFullName, useOptions) { const self = this; Object.defineProperty(beanInstance, prop, { enumerable: true, // Property is enumerable (visible in serialization/spread) configurable: true, // Redefinable for HMR get() { if (!beanInstance[SymbolBeanInstancePropsLazy]) beanInstance[SymbolBeanInstancePropsLazy] = {}; if (!beanInstance[SymbolBeanInstancePropsLazy][prop]) { // First access: actually resolve, then cache to instance-private slot beanInstance[SymbolBeanInstancePropsLazy][prop] = self._injectBeanInstanceProp(beanInstance, targetBeanFullName, useOptions); // App-level container additionally registers this dependency edge to HMR self.containerType === 'app' && self.app.meta.hmr.addBeanInstanceProp(beanInstance, prop, targetBeanFullName); } return beanInstance[SymbolBeanInstancePropsLazy][prop]; }, }); } Three implementation highlights: 1. The getter closure captures the container that installed it (self) — properties installed by the app container always go through app resolution, while those installed by the ctx (request-level) container go through ctx resolution; 2. The cache slot is an instance-level plain object SymbolBeanInstancePropsLazy — after first access, the property degrades to "pseudo-static" with zero lookup cost thereafter; 3. The enumerable: true / configurable: true combination is deliberate: enumerable ensures consistent instance export behavior, configurable lets HMR tear down and reinstall. Stage 4: Resolution — Three Injection Scopes First access triggers _injectBeanInstanceProp: // beanContainer.ts private _injectBeanInstanceProp( beanInstance, targetBeanFullName: string, useOptions: IDecoratorUseOptionsBase, ) { const injectionScope = useOptions.injectionScope ?? 'app'; if (injectionScope === 'app') { targetInstance = this.app.bean._getBeanSelectorInner(...); // Application-level singleton } else if (injectionScope === 'ctx') { targetInstance = this._getBeanSelectorInner(...); // Request-level singleton } else if (injectionScope === 'new') { targetInstance = this._newBeanInner(...); // New instance each time } return targetInstance; } - 'app' / 'ctx' fall into _getBeanSelectorInner: the container instance cache key is beanFullName or beanFullName#selector, creating only if === undefined; - 'new' creates a new instance each time, but since the outer getter already caches, only one resolution happens per property lifecycle; - The selector parameter is taken from three sources by priority: the decorated property's descriptor getter → useOptions.init static parameters → useOptions.selector string expression (evaluation context includes self/app/ctx). Stage 5: Caching — Container Instance Cache The caching logic of _getBeanSelectorInner: // beanContainer.ts _getBeanSelectorInner<T>( beanFullName: Constructable<T> | string, withSelector?: boolean, ...args ): T { const beanOptions = appResource.getBean(beanFullName as any); const fullName = beanOptions.beanFullName; const key = __getSelectorKey(fullName, withSelector, args[0]); if (this[SymbolBeanContainerInstances][key] === undefined) { this._newBeanInner(true, fullName, withSelector, ...args); } return this[SymbolBeanContainerInstances][key] as T; } Cache key generation: function __getSelectorKey(beanFullName: string, withSelector?: boolean, selector?: any) { if (!withSelector) return beanFullName; const isSelectorValid = !isNilOrEmptyString(selector); return !isSelectorValid ? beanFullName : `${beanFullName}#${selector}`; } --- Complete Timing of A↔B Circular Dependency Using two Services A and B that @Use each other as an example, here is the complete timing: | Moment | Action | Result | | --- | --- | --- | | Declaration | @Use registers A→B, B→A mappings | No instance interaction | | Business triggers _getBean('A') | new A(), install lazy getter, enter container cache | A fully exists | | First access a.serviceB | Getter executes → container creates B (B's construction and getter installation also do not touch A), enters cache | B fully exists | | B internally accesses b.serviceA | Getter executes → container cache hit → returns existing A | Cycle closes, no reentrance | Two invariants guarantee cycle safety: 1. Getters never execute during the instantiation phase (no mutual touching); 2. The dependency target is guaranteed to be registered in the container cache at resolution time (the "enter cache before init" ordering covers even the aggressive case of accessing dependencies during __init__). --- Real Code Example Vona's @Use Usage import { BeanBase, Use } from 'zova'; import { Service } from 'zova-module-a-bean'; @Service() export class BeanRouter extends BeanBase { @Use() $$sysRouter: SysRouter; @Use() $$modelPageData: ModelPageData; } Note: zero constructor parameters; all dependencies are declared on properties via @Use. Resolution is triggered only when this.$$sysRouter is accessed. Equivalent Path in Vona Backend Business Code In the current repository, backend business code more commonly uses this.scope('module-x').service.yyy and this.bean.xxx instead of property-level @Use — these go through the same container's runtime lookup path: // BeanContainer.create returns a Proxy static create(app: VonaApplication, ctx: VonaContext | undefined) { const beanContainer = new BeanContainer(app, ctx); return new Proxy(beanContainer, { get(obj, prop) { if (typeof prop === 'symbol') return obj[prop]; if (obj[prop]) return obj[prop]; return obj._getBean(prop as any); // Resolves on access }, }); } This is essentially the "method-call version" of the same lazy resolution mechanism. The two paths differ only in declaration style (decorator metadata vs string lookup); the resolution and caching mechanisms are fully shared. --- Failure Boundary and HMR Lazy property injection is not without boundaries to be aware of: _removeBean only destroys the instance in the container cache and calls its __dispose__; the cached reference in other Bean instances' SymbolBeanInstancePropsLazy is not automatically cleared — this is exactly why HMR needs addBeanInstanceProp to register dependency edges for rewiring. So in hot-reload scenarios, the framework can use the registered dependency edges to find all properties that reference the replaced Bean, tear down old getters, install new ones, clear cache slots, and complete dependency rewiring. --- Module-Level Circular Dependencies Beyond instance-level circular dependencies, Vona also makes design-level provisions at the module level. Per the documentation: > Using this.$scope.<fixedModule>, app.scope(...), or this.app.scope(...) does not by itself require adding the target module to the caller's vonaModule.dependencies. Lookup does not create a module dependency edge, so lookup alone cannot create a circular dependency. This means module-level circular dependencies are also avoided by design: cross-module lookups do not produce dependency declarations and do not constitute module dependency edges, so modules cannot form circular dependencies merely by "using each other's services." --- Summary The root cause of NestJS's circular dependency problem lies in its design choice of "constructor injection + eager resolution": dependencies must be resolved at instantiation time, circular references inevitably lead to deadlock, and forwardRef can only defer the resolution order. Vona's lazy property injection mechanism dissolves this problem through three design decisions: 1. Zero constructor dependencies — the instantiation phase touches no dependencies; 2. Lazy property getter — dependency resolution is deferred to first access; 3. Enter cache before init — guarantees the dependency target is always ready at resolution time. Circular dependencies in Vona require no forwardRef, no special annotation, no developer identification of cycles — they are naturally safe by timing. This is not smarter cycle detection; it is an architectural choice that gives circular dependencies no opportunity to arise. --- References and Source Code NestJS - NestJS Circular Dependency Documentation - NestJS forwardRef Source Code CabloyJS / Vona - Vona BeanContainer Source Code - Vona @Use Decorator Source Code - Vona AppResource Source Code - Vona Foundation Documentation: Module Scope and Dependencies - Vona Module Dependencies Documentation
I've contributed to @nestframework Idempotency by making lock TTL configurable and fixing flaky tests. Thanks to NestJS creator @kammysliwiec idempotency#28 t.co
@MrBye32 Here're the reproduction steps and code for all libs/frameworks: nest#18063
@saltyAom Reproduction steps and code for all libs/frameworks: nest#18063