The kind of lessons the docs can’t teach you. Only your users can.
I’ve been building B2B SaaS applications with NestJS and TypeScript for four years. In that time, I’ve shipped features I’m proud of, written code I’d rather forget, and caused at least three production incidents that I will never live down.
This isn’t a “best practices” article. This is a confession. These are the mistakes I made after reading the docs, after watching the courses, after thinking I knew what I was doing.
If you’re a mid-level NestJS dev, there’s a decent chance you’re about to feel personally attacked. Sorry in advance.
1. I Let Tenant Data Leak Between Requests Because I Didn't Understand Scoping
What I did wrong
I was building a multi-tenant SaaS app. Each request came with a tenant ID in the header. I wrote a neat little TenantService that extracted the tenant ID and made it available throughout the request lifecycle.
The problem? I left it as the default scope → a singleton.
I didn’t think about it. @Injectable() defaults to DEFAULT scope, which means one instance shared across every request. My TenantService was storing the tenant ID as an instance property, and every concurrent request was overwriting it.
What actually happened
For months, nothing. We had low traffic. One tenant per request, no overlaps, everything looked fine. Then we onboarded a bigger client and traffic went up.
Customer A started seeing Customer B’s data.
We got a support ticket at 11 PM: "Why am I seeing invoices from a company I’ve never heard of?" I aged five years that night.
The fix
Before:
@Injectable() // DEFAULT scope = singleton = disaster
export class TenantService {
private tenantId: string; // shared across ALL requests
setTenant(id: string) {
this.tenantId = id;
}
getTenant(): string {
return this.tenantId;
}
}
After:
@Injectable({ scope: Scope.REQUEST }) // new instance per request
export class TenantService {
private tenantId: string;
setTenant(id: string) {
this.tenantId = id;
}
getTenant(): string {
return this.tenantId;
}
}
One word: Scope.REQUEST. That’s it. That’s what stood between us and a data breach.
The lesson: If a provider holds request-specific state, it cannot be a singleton. The default scope is not always the right scope. The docs mention this. I read it. I just didn’t understand it until Customer A saw Customer B’s invoices.
2. I Created a Circular Dependency and Blamed NestJS for Being “Buggy”
What I did wrong
As our app grew, I split logic into modules that made sense to me: OrdersModule and PaymentsModule. Orders needed to check payment status. Payments needed to update order state. So OrdersModule imported PaymentsModule, and PaymentsModule imported OrdersModule.
Seemed reasonable. NestJS disagreed.
What actually happened
The app booted fine in development for a while. Then one day, after a refactor, I got this at startup:
Nest cannot create the PaymentsModule instance.
The module at index [0] of the PaymentsModule "imports" array is undefined.
“The module is undefined.” I stared at that error for two hours. I thought it was a TypeScript compilation bug. I deleted node_modules three times. I restarted my computer.
It was a circular dependency. OrdersModule → PaymentsModule → OrdersModule → infinite loop → NestJS resolves one of them as undefined.
The fix
I extracted the shared logic into a SharedOrderPaymentModule that both could import without importing each other. For the cases where I genuinely needed cross-module provider access, I used forwardRef:
// orders.module.ts
@Module({
imports: [forwardRef(() => PaymentsModule)],
})
export class OrdersModule {}
// payments.module.ts
@Module({
imports: [forwardRef(() => OrdersModule)],
})
export class PaymentsModule {}
But honestly? The real fix was rethinking my module boundaries. If two modules can’t exist without each other, they’re probably one module --> or there’s a third module hiding in between.
The lesson: Circular dependencies aren’t NestJS being buggy. They’re your architecture telling you something is wrong. Listen to it.
3. I Put Auth Logic in a Middleware Instead of a Guard, and Paid for It for a Year
What I did wrong
When I first implemented authentication, I wrote an AuthMiddleware that verified JWTs and attached the user to the request object. It worked. I moved on.
What I didn’t realize is that I’d built authentication in the one layer that doesn’t have access to NestJS’s execution context —-> meaning no access to reflectors, decorators, or route metadata.
What actually happened
Six months later, I needed role-based access control. "Easy," I thought. "I’ll just add a @Roles('admin') decorator and check it in my middleware."
Except middleware doesn’t know which controller method is about to execute. It doesn’t have access to Reflector. It can’t read custom decorators. It runs before the routing layer even decides which handler to call.
I spent a week hacking around this limitation —-> parsing the URL manually, maintaining a hardcoded map of routes to roles. It was horrifying. It was also in production for a year before I fixed it.
The fix
I moved authentication into a Guard, where it belongs:
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
'roles',
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
Guards have access to the ExecutionContext. They know which handler is about to run. They can read decorator metadata via Reflector. They’re designed for exactly this.
The execution order in NestJS is:
Middleware → Guards → Interceptors (before) → Pipes → Handler → Interceptors (after) → Exception Filters
Knowing this order isn’t optional. It’s structural.
The lesson: Middleware is for request-level concerns that don’t care about routes —-> logging, CORS, compression. The moment you need to know which endpoint is being called, you need a Guard or an Interceptor.
4. I Used process.env Everywhere and My App Silently Ran With Missing Config
What I did wrong
Instead of using NestJS’s ConfigModule and ConfigService, I accessed environment variables directly with process.env throughout my codebase. It was faster to type. It felt simpler. I had a .env file. What could go wrong?
What actually happened
Everything. Everything could go wrong.
A colleague deployed to staging without setting the REDIS_URL environment variable. The app started successfully. No errors. No warnings. It just silently used undefined as the Redis connection string, fell back to some default behavior, and cached nothing for two weeks.
We only found out because a customer complained about slow responses. Two weeks of “why is staging slow?” tickets. The answer was a missing env var that nobody noticed because nothing complained.
The fix
I switched to ConfigModule with Joi validation. The app now refuses to start if required config is missing:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
DATABASE_URL: Joi.string().uri().required(),
REDIS_URL: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(32).required(),
STRIPE_API_KEY: Joi.string().required(),
NODE_ENV: Joi.string()
.valid('development', 'staging', 'production')
.default('development'),
}),
}),
],
})
export class AppModule {}
Now if you deploy without REDIS_URL, the app crashes immediately at startup with a clear validation error. Loud failures at deploy time beat silent failures at 3 AM.
The lesson:
process.envis a bag of untyped, unvalidated strings. Treat configuration as a dependency that can fail, validate it at startup, and inject it throughConfigService—-> never reach into the environment directly.
5. I Booted the Entire App in Every Unit Test and Wondered Why CI Took 14 Minutes
What I did wrong
The NestJS docs show you Test.createTestingModule() for testing. What they don’t emphasize enough is that you don’t need to import your entire module tree for a unit test.
But that’s exactly what I did. Every test file created the full AppModule with database connections, Redis, third-party API clients —-> the whole thing. I wanted “realistic” tests.
What actually happened
Our test suite grew to 400+ tests. CI time ballooned to 14 minutes. Tests became flaky because they depended on real database state. Running tests locally required a running Postgres instance, a Redis server, and three env files.
Nobody wanted to write tests anymore. Nobody wanted to run them either. We’d push code and pray the CI passed. Very professional.
The fix
For unit tests, I stopped importing modules entirely. I provide only the class under test and mock its dependencies:
// Before: importing the entire universe
const module = await Test.createTestingModule({
imports: [AppModule], // boots DB, Redis, everything
}).compile();
// After: surgical, fast, isolated
const module = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: PaymentsService,
useValue: { charge: jest.fn().mockResolvedValue({ id: 'pay_123' }) },
},
{
provide: getRepositoryToken(Order),
useValue: { save: jest.fn(), findOne: jest.fn() },
},
],
}).compile();
CI went from 14 minutes to 90 seconds. Tests stopped being flaky. Developers started writing them again because they actually ran fast enough to be useful.
I kept a small number of true integration tests that boot the full module —-> but those run in a separate CI stage, against a real database, and there are only about 30 of them. The other 400 are fast, isolated unit tests that mock their dependencies.
The lesson: Unit tests should test units, not systems. Save
Test.createTestingModulewith full imports for integration tests. For everything else, mock ruthlessly and your future self will thank you.
The Pattern Behind All 5 Mistakes
Looking back, every one of these mistakes has the same root cause: I understood the API but not the architecture. I knew how to use @Injectable() but not when to change its scope. I knew how to write middleware but not why Guards exist. I knew how to access process.env but not why that’s a liability.
NestJS gives you a powerful architecture. But it also gives you enough rope to hang yourself if you don’t understand why the architecture is shaped the way it is.
The docs teach you the what. Production teaches you the why.
And production is a very expensive teacher.
If this saved you from making even one of these mistakes, feel free to follow along! I write about the stuff you only learn after things break in production, backend architecture, AI workflows, and the lessons nobody puts in the README.
Which NestJS mistake cost you the most sleep? I’d love to hear your war stories in the comments!
Top comments (0)