I updated an educational NestJS microservice application to the latest Node and framework versions
What I had to change in nestjs-microservices-example when I moved it from NestJS 8, Mongoose 5 and Node 14 to NestJS 12, Mongoose 9 and Node 24.
In 2019 I made nestjs-microservices-example. It is a small API for a task manager, split into six services: a gateway, users, tokens, permissions, tasks and a mailer. The services talk to each other over TCP. I made it when NestJS was still new, and some people used it to learn how microservices work in NestJS.
The last update was in 2021. Since then it stayed on NestJS 8, Mongoose 5, TypeScript 3.9 and Node 14. This week I moved it to NestJS 12, Mongoose 9, TypeScript 6 and Node 24. I am not sure that everything I did is the best way, so please read this as notes from one project.
ESM
NestJS 12 works with CommonJS and with ESM. I chose ESM, because I think new projects will use it, and an example should look like a new project. In package.json every service got "type": "module", and in tsconfig.json the module setting is now nodenext.
The biggest part of the diff came from this. With nodenext, every relative import needs the .js extension, also in .ts files:
import { UserModule } from './user.module.js';There are about 130 such lines. The change is boring, but tsc shows every missing one, so it was not hard.
Imports like import * as mongoose from 'mongoose' also had to change to default imports. The same for bcrypt and supertest.
Mongoose
From version 5 to 9 there are many changes, but in this project only a few places were affected.
Middleware does not get the next callback anymore. A hook is finished when its function returns:
UserSchema.pre('save', async function () {
if (!this.isModified('password')) return;
this.password = await this.getEncryptedPassword(this.password);
});updateOne returns an UpdateResult and not a document. My old code said that it returns ITask or IUser, which was never true, but TypeScript 3.9 did not see it. Now I changed the return types.
useNewUrlParser and other old connection options are gone. mongoose.connect takes only the address now.
NestJS
The NestJS part was smaller than I expected. listenAsync() was removed, and listen() returns a promise now:
await app.listen();toPromise() from RxJS is replaced by firstValueFrom. The mailer package moved from @nest-modules/mailer to @nestjs-modules/mailer.
NestJS 12 uses Express 5. One difference in Express 5 is that req.body is undefined when a request has no body. Before it was an empty object. Some of my handlers read fields from the body without a check, so I added a small global pipe:
return metadata.type === 'body' && value === undefined ? {} : value;Maybe it is better to check the body in every handler. But for an example I think one pipe is easier to read.
Old bugs
While I tested the update, I found some old bugs. I think I should write about them too.
The confirmation link was the same for every user. The schema had default: generateLink(), with brackets, so the function ran only one time, when the schema was created. Now it is default: generateLink, and Mongoose calls it for every document.
The user service crashed when the mailer did not answer. The result of send was not handled, so one failed email stopped the whole service. Now the error is caught.
The mailer never sent emails at all. MAILER_DISABLED=0 came from the env file as the string "0", and a non empty string is true in JavaScript.
A restarted container did not start. TypeScript keeps a .tsbuildinfo file for incremental builds. The build deletes dist, but this file was outside of dist, so tsc thought that nothing had changed and did not write any output. I moved the file into dist.
Other things
- Jest is replaced with Vitest for the end to end tests. The tests stayed almost the same. I only removed the
donecallbacks and returned the promises. .eslintrcandtslint.jsonare replaced with ESLint flat config.dotenvis removed. Node 24 can read the env file withnode --env-file, and the tests useprocess.loadEnvFile.- The Dockerfiles use
node:24-alpineandnpm ciwith lockfiles. MongoDB is version 8 instead of 3.7, and the init script usesmongosh. - The JWT secret is now read from the
JWT_SECRETvariable. Before it was written in the code.
How I checked it
All 45 end to end tests in the gateway pass. They start the whole application and go through sign up, email confirmation, sign in, sign out and all task operations.
The whole update is in the master branch already. If somebody still uses this example, I hope it is useful again. And if somebody knows a better way to do some of these things, I would be glad to hear it.