After five years building production systems—from e-commerce platforms to ride-hailing backends—I've watched countless Node teams hit a wall with Express. They start fast, ship features quickly, and then spend months retrofitting structure. That's why I use NestJS for serious Node backends. It's not about being "better" than Express; it's about giving your team a shared architecture that scales.
The Freedom vs. Structure Tradeoff
Express is deliberately minimal. You can organize your codebase however you want: folder-by-feature, folder-by-layer, decorators on files, or no pattern at all. That's powerful for prototypes and small services. But I've seen this play out in real teams: three developers, three mental models, and six months later, nobody agrees where the validation logic should live.
NestJS takes a different bet. It says: here's how you structure your application. Here's where business logic goes. Here's where guards and pipes fit. If you're building a trading platform or CRM where five engineers need to onboard in parallel, that shared mental model is worth more than the freedom you're trading away.
When I joined a team working on a microservices platform, the first thing we did was establish NestJS conventions. Every service had the same folder layout, every service used dependency injection the same way, every controller was decorated consistently. A new engineer could open src/modules/users in any service and immediately know what they were looking at.
Modules, Dependency Injection, and Decorators—Familiar Territory
If you've worked with .NET or Angular, NestJS will feel like home. The module system, constructor-based dependency injection, and decorator patterns are nearly identical to what you already know. That's deliberate. The framework was built with these communities in mind.
Here's what this looks like in practice:
// users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService], // Makes UsersService available to other modules
})
export class UsersModule {}You declare what a module needs (imports), what it provides (providers), what it exposes publicly (exports), and what handles HTTP (controllers). No guessing, no convention debates. If a module declares its dependencies upfront, the framework can tell you at startup if something's missing.
Dependency injection works the same way as .NET:
// users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async findById(id: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { id } });
}
}The @Injectable() decorator tells NestJS this is a service that can be injected. The @InjectRepository(User) decorator tells it which repository to inject. The framework handles instantiation, lifecycle, and dependency resolution. Your code stays focused on business logic.
Decorators extend to controllers too. In a recent e-commerce build, we used them to keep our HTTP layer clean:
// users.controller.ts
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
async getUser(@Param('id') id: string) {
return this.usersService.findById(id);
}
@Post()
@UseGuards(JwtAuthGuard)
async createUser(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}All the HTTP metadata—the route, the verb, the guards, the parameters—lives right there in the decorators. No separate routing file, no guessing where to add middleware. If you've used attribute routing in .NET, this will feel immediately familiar.
Opinionated Structure That Scales Across Large Teams
I've worked on systems with 10+ engineers touching the same codebase. The biggest risk isn't individual performance; it's cognitive overhead. When every service has a different structure, every code review becomes a teaching moment about why someone put validation in the wrong place.
NestJS's conventions eliminate that friction. Here's the folder layout I use on every serious project:
| Folder | Purpose |
|---|---|
src/modules |
Feature modules—one folder per domain (users, orders, payments) |
src/modules/{feature}/entities |
Database entities and TypeORM definitions |
src/modules/{feature}/dto |
Data transfer objects for request/response validation |
src/modules/{feature}/{feature}.controller.ts |
HTTP handlers—route decorators only |
src/modules/{feature}/{feature}.service.ts |
Business logic and database queries |
src/modules/{feature}/{feature}.module.ts |
Module declaration with imports/exports |
src/common/guards |
Authentication, authorization, RBAC |
src/common/pipes |
Validation and transformation |
src/common/interceptors |
Logging, error handling, response formatting |
Every engineer on the team knows this structure cold. When someone needs to add a new feature, they create a module, generate a controller and service, and they're done. No philosophical debates about where middleware belongs.
This structure also makes testing simpler. Since services are injectable, you can mock them in tests. Guards and pipes are testable in isolation. I've seen NestJS projects with 70-80% test coverage because the architecture makes testing feel natural, not like an afterthought.
First-Class TypeScript Support
NestJS is built for TypeScript. Not as an afterthought, not as "you can use TypeScript if you want"—it's the primary language. Every decorator, every module, every guard assumes you're using types.
This matters on large teams. Type safety catches entire classes of bugs at compile time. When you refactor a database field from string to uuid, TypeScript tells you everywhere that field is used. No more hunting through logs to find the place you forgot to update.
I used this heavily on a ride-hailing backend. We had payment DTOs, booking DTOs, and driver DTOs that all needed to stay in sync. Strong typing meant that when a payment structure changed, TypeScript forced us to update every place it was consumed. No silent failures, no production bugs from mismatched shapes.
Testing and Observability Built In
NestJS ships with Jest integration and a testing module that makes it easy to isolate what you're testing:
// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
describe('UsersService', () => {
let service: UsersService;
let mockRepository;
beforeEach(async () => {
mockRepository = {
findOne: jest.fn(),
save: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: mockRepository,
},
],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should find a user by id', async () => {
const user = { id: '1', name: 'Alice' };
mockRepository.findOne.mockResolvedValue(user);
const result = await service.findById('1');
expect(result).toEqual(user);
});
});The testing module lets you swap out real dependencies for mocks. This is powerful because it means your tests run in milliseconds, not seconds, and they test your code, not your database.
Microservices Support Without Rewrites
Building microservices with NestJS is where the architecture really shines. The framework has built-in support for message queues, gRPC, and other transports. If you start with a monolith and later need to split into services, NestJS can handle both patterns.
On a trading platform, we started with a single NestJS service. As we grew, we split out order processing into a separate service. Instead of rewriting, we just moved the module to a new service and connected them via Kafka. The module stayed the same; only the transport layer changed.
Still Express (or Fastify) Underneath
NestJS doesn't reinvent the HTTP server. It's a framework layer on top of Express (or Fastify if you prefer). This means you get the best of both worlds: the familiar Express ecosystem and the structured conventions of NestJS.
If you need raw Express middleware for some reason, you can still use it. If you want to switch to Fastify for performance, you can do that too. NestJS is opinionated about structure, not about the HTTP server itself.
Common Mistakes
Putting business logic in controllers: Controllers should only handle HTTP concerns—extracting parameters, calling services, returning responses. Business logic belongs in services. I've seen controllers with hundreds of lines of database queries; that's a sign it's time to move code to a service.
Over-using global middleware: It's tempting to attach guards and pipes globally. But local decorators are usually clearer. If only two endpoints need authentication, decorate those endpoints instead of guessing what the global guard applies to.
Creating too many modules: Modules are powerful, but every module adds mental overhead. Create modules around features (users, orders, payments), not around patterns (controllers, services). A module should contain one cohesive piece of business logic.
Ignoring the dependency injection container: The most common mistake I see is engineers new to NestJS using new Service() instead of letting the DI container handle it. Let NestJS inject what you need; that's what the container is for.
Key Takeaways
- NestJS trades Express's freedom for structured conventions that scale across teams. If you're building a serious backend with more than two engineers, that trade is worth it.
- The module system, dependency injection, and decorators will feel familiar to .NET and Angular developers. You're not learning a new pattern; you're applying patterns you already know.
- Opinionated structure means code reviews focus on logic, not architecture debates. Everyone knows where validation goes, where business logic lives, and what a test looks like.
- TypeScript is first-class, not an add-on. This catches refactoring mistakes and makes large codebases manageable.
- Testing, microservices, and observability are built in. You get decent defaults without reaching for a dozen extra libraries.
- NestJS is still Express underneath. You're not locked in; you're just building on top of a foundation you're already familiar with.






Comments
Loading comments…