← Back to blogBackend

Moving from Express to NestJS: the switch we made and why

June 17, 2025·6 min read·2 comments

Our API started as an Express app. Single file, five routes, one developer. Over two years it grew to 120 routes, 15 middleware functions, 40 service files, and six developers. The flexibility that made Express easy to start with became a liability.

The specific problems:

Inconsistent middleware ordering. Each developer added middleware in different places. Auth middleware was applied in the route file in some cases, in a shared router in others, and as a global middleware with route-specific exceptions in others. Debugging which middleware ran for which route required reading five files.

No standard structure. Some routes had their logic inline. Some called a service. Some called a service that called another service. The directory structure was a flat list of files. Finding the code for a specific feature required searching by keyword.

No dependency injection. Services instantiated their own dependencies. Database connections were imported directly. Swapping a dependency for testing required mocking at the module level, which was fragile and coupled tests to implementation details.

Why NestJS

NestJS is an opinionated framework built on Express (or Fastify). It enforces a modular structure, uses decorators for route handling and middleware, and provides dependency injection out of the box.

The framework's opinion is the point. When every developer follows the same structure, the codebase is navigable by convention rather than by documentation.

The module system

NestJS organises code into modules. Each module encapsulates a domain:

// sessions/sessions.module.ts
@Module({
  imports: [UsersModule, NotificationsModule],
  controllers: [SessionsController],
  providers: [SessionsService, SessionsRepository],
  exports: [SessionsService],
})
export class SessionsModule {}

The module declares what it needs (imports), what handles requests (controllers), what provides business logic (providers), and what it makes available to other modules (exports).

This means you can look at a module's declaration and immediately understand its dependencies and boundaries. No searching through import statements across files.

Decorators for routes

Express:

router.post('/sessions', authMiddleware, validateBody(sessionSchema), async (req, res) => {
  try {
    const session = await sessionService.create(req.body);
    res.status(201).json(session);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

NestJS:

@Controller('sessions')
export class SessionsController {
  constructor(private readonly sessionsService: SessionsService) {}

  @Post()
  @UseGuards(AuthGuard)
  @UsePipes(new ValidationPipe())
  async create(@Body() dto: CreateSessionDto): Promise<Session> {
    return this.sessionsService.create(dto);
  }
}

The route handler is a method with decorators that declare the HTTP method, guards (middleware), and validation. The controller's constructor declares its dependencies. No explicit try/catch because NestJS has a global exception filter.

The CreateSessionDto class defines the expected request body with validation rules:

export class CreateSessionDto {
  @IsString()
  @IsNotEmpty()
  language: string;

  @IsEnum(Priority)
  priority: Priority;

  @IsOptional()
  @IsString()
  notes?: string;
}

The ValidationPipe automatically validates incoming requests against the DTO and returns a 400 with specific error messages if validation fails. No manual validation code.

Dependency injection

The dependency injection container is what makes NestJS testable:

@Injectable()
export class SessionsService {
  constructor(
    private readonly sessionsRepo: SessionsRepository,
    private readonly notificationService: NotificationsService,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  async create(dto: CreateSessionDto): Promise<Session> {
    const session = await this.sessionsRepo.create(dto);
    await this.notificationService.notifyInterpreters(session);
    this.eventEmitter.emit('session.created', session);
    return session;
  }
}

In tests, you provide mock implementations:

const module = await Test.createTestingModule({
  providers: [
    SessionsService,
    { provide: SessionsRepository, useValue: mockRepo },
    { provide: NotificationsService, useValue: mockNotifications },
    { provide: EventEmitter2, useValue: mockEmitter },
  ],
}).compile();

const service = module.get(SessionsService);

Each dependency is replaceable without changing the service code. No mocking require or replacing module-level imports.

OpenAPI generation

NestJS generates OpenAPI (Swagger) documentation from decorators:

@ApiTags('sessions')
@Controller('sessions')
export class SessionsController {
  @Post()
  @ApiOperation({ summary: 'Create a new session' })
  @ApiResponse({ status: 201, type: Session })
  @ApiResponse({ status: 400, description: 'Invalid input' })
  async create(@Body() dto: CreateSessionDto): Promise<Session> {
    return this.sessionsService.create(dto);
  }
}

The Swagger UI is available at /api/docs with no additional work. The documentation stays in sync with the code because it's generated from the same decorators that define the routes.

For a mobile team consuming the API, this is invaluable. They can see every endpoint, its parameters, and its response format without reading the backend code or asking a backend developer.

The migration path

We didn't rewrite the entire API at once. We migrated module by module over three months:

  1. Set up NestJS alongside Express using NestJS's NestExpressApplication
  2. Move one domain (sessions) to a NestJS module
  3. Verify in production
  4. Move the next domain
  5. Repeat until Express is empty

NestJS sits on top of Express, so both frameworks can serve routes from the same process. Old Express routes continue to work while new NestJS modules are added.

What we gave up

Simplicity for small features. Adding a quick health check endpoint in Express is three lines. In NestJS, it's a controller class with a decorator. For small features, the overhead of the framework isn'ticeable.

Community middleware. Express has the largest middleware ecosystem. Some Express middleware doesn't work with NestJS's abstraction layer. We had to find NestJS-specific alternatives for a few packages.

Learning curve. Developers unfamiliar with dependency injection and decorators needed a week to become productive. Express's lack of structure is also its low barrier to entry.

The trade-off was clear for our team size and codebase. Express was the right choice when the project was small and the team was one person. NestJS is the right choice now that the project is large and the team is six people. The opinionated structure makes the codebase navigable, the code reviewable, and the tests reliable without relying on individual developer discipline to maintain consistency.

RESPONSES
Nina JohanssonJul 1, 2025

The point about opinionated frameworks winning at team size is something I've experienced but management never wants to hear because it sounds like adding overhead. This is a useful framing for making the case.

John M.Mar 16, 2026

Learned a lot from reading this, man

Leave a response