Migrating from Express.js to NestJS: Lessons Learned
Lessons learned from migrating a production Express.js API to NestJS, covering incremental migration strategy, module decomposition, dependency injection adoption, and zero-downtime cutover.
Tags
Migrating from Express.js to NestJS: Lessons Learned
TL;DR
I migrated a production Express.js API with 40+ endpoints to NestJS over six weeks using an incremental strategy — running both frameworks side by side through NestJS's Express adapter. The migration eliminated scattered middleware chains, replaced manual dependency wiring with NestJS's DI container, and introduced module boundaries that made the codebase navigable for new developers. This post covers why we migrated, the incremental approach, the gotchas, and what I would change if I did it again.
The Challenge
The Express.js API had been built over two years by multiple developers. It started clean — a handful of routes, a few middleware functions, a simple folder structure. By the time I joined, it had grown to over 40 endpoints across a dozen resource types, and the codebase reflected every decision (and indecision) made along the way.
The specific pain points:
- ›No dependency injection. Services were imported directly using
require()orimport, creating implicit dependency chains. Testing a single service meant mocking half the application because everything was tightly coupled through module-level imports. - ›Middleware soup. Authentication, authorization, validation, logging, and error handling were implemented as middleware functions chained in unpredictable orders. Some routes had five middleware functions, others had two, and the only way to know which middleware applied to which route was to read the route file.
- ›No module boundaries. The folder structure was organized by technical layer (
controllers/,services/,models/), not by domain. Finding all the code related to "orders" meant looking in five different directories. - ›Inconsistent error handling. Some routes used
try/catchwithres.status().json(), others usednext(err)to an error middleware, and a few just let unhandled rejections crash the process. - ›Difficult onboarding. New developers couldn't understand the request flow without tracing through middleware chains. There was no way to look at a route and know what authentication, validation, and authorization it required without reading the Express route registration code.
The decision to migrate to NestJS was driven by the need for structure, not performance. Express itself is fine — the problem was that Express doesn't impose any architecture, and over time, our codebase had drifted into an unmaintainable state.
The Architecture
Why NestJS and Not Just "Better Express"
We considered staying on Express and just reorganizing the code. The argument was: "NestJS uses Express under the hood anyway, so why add a framework on top?" But reorganizing Express code doesn't give you dependency injection, doesn't give you decorators for clean route definitions, and doesn't give you guards and pipes for declarative request processing. You'd be building half of NestJS yourself.
NestJS provides:
- ›Modules that encapsulate related controllers, services, and providers
- ›Dependency injection that makes services testable and loosely coupled
- ›Decorators that make route definitions self-documenting
- ›Guards for authentication and authorization that apply declaratively
- ›Pipes for validation and data transformation
- ›Exception filters for consistent error handling
- ›Interceptors for cross-cutting concerns like logging and caching
These aren't features we'd build ourselves — they're structural constraints that prevent the codebase from degrading over time.
The Incremental Migration Strategy
The key insight that made incremental migration possible: NestJS uses Express as its HTTP layer by default. This means you can mount existing Express routes inside a NestJS application and migrate them one at a time.
Step 1: Create the NestJS application shell alongside the existing Express app:
// main.ts
import { NestFactory } from "@nestjs/core";
import { ExpressAdapter } from "@nestjs/platform-express";
import express from "express";
import { AppModule } from "./app.module";
import { legacyRoutes } from "./legacy/routes";
async function bootstrap() {
const server = express();
// Mount legacy Express routes first
server.use("/api/v1", legacyRoutes);
// Create NestJS app using the same Express instance
const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
// NestJS middleware and global pipes
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
})
);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();This single Express instance serves both legacy routes and new NestJS controllers. From the client's perspective, nothing changes — same URL, same port, same responses.
Step 2: Migrate routes one module at a time. I started with the simplest, most isolated resource — "categories" — because it had few dependencies and low traffic:
// Before: Express route
// routes/categories.js
const router = express.Router();
const CategoryService = require("../services/categoryService");
router.get("/", async (req, res, next) => {
try {
const categories = await CategoryService.findAll();
res.json({ data: categories });
} catch (err) {
next(err);
}
});
router.post(
"/",
authMiddleware,
adminMiddleware,
validateBody(categorySchema),
async (req, res, next) => {
try {
const category = await CategoryService.create(req.body);
res.status(201).json({ data: category });
} catch (err) {
next(err);
}
}
);
module.exports = router;// After: NestJS controller
// categories/categories.controller.ts
@Controller("api/v1/categories")
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
async findAll() {
return { data: await this.categoriesService.findAll() };
}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("admin")
async create(@Body() dto: CreateCategoryDto) {
return { data: await this.categoriesService.create(dto) };
}
}The NestJS version is self-documenting. Looking at the create method, you can immediately see: it requires JWT authentication, it requires the admin role, and the body is validated against CreateCategoryDto. In the Express version, you had to trace through authMiddleware, adminMiddleware, and validateBody to understand the same thing.
Step 3: Remove the legacy route from the Express router after verifying the NestJS controller handles it correctly. Integration tests (which I'll cover below) verified behavioral parity.
Migrating Services and Dependency Injection
The biggest refactoring effort was converting services from module-level singletons to injectable classes:
// Before: Express service (module singleton)
// services/orderService.js
const db = require("../db");
const UserService = require("./userService");
const EmailService = require("./emailService");
const PaymentService = require("./paymentService");
class OrderService {
async createOrder(userId, items) {
const user = await UserService.findById(userId);
const order = await db("orders").insert({ userId, items, status: "pending" });
await PaymentService.charge(user, order.total);
await EmailService.sendOrderConfirmation(user.email, order);
return order;
}
}
module.exports = new OrderService();// After: NestJS injectable service
// orders/orders.service.ts
@Injectable()
export class OrdersService {
constructor(
@Inject("DATABASE") private db: Knex,
private usersService: UsersService,
private emailService: EmailService,
private paymentService: PaymentService,
) {}
async createOrder(userId: string, items: OrderItem[]) {
const user = await this.usersService.findById(userId);
const order = await this.db("orders").insert({
userId,
items,
status: "pending",
});
await this.paymentService.charge(user, order.total);
await this.emailService.sendOrderConfirmation(user.email, order);
return order;
}
}The logic is identical. The difference is that dependencies are injected through the constructor instead of imported at the module level. This is a fundamental shift for testing:
// Testing the Express version required mocking module imports
jest.mock("../services/userService");
jest.mock("../services/emailService");
jest.mock("../services/paymentService");
// Testing the NestJS version uses standard constructor injection
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: UsersService, useValue: mockUsersService },
{ provide: EmailService, useValue: mockEmailService },
{ provide: PaymentService, useValue: mockPaymentService },
{ provide: "DATABASE", useValue: mockDb },
],
}).compile();
const service = module.get(OrdersService);No jest.mock(), no module-level side effects, no import order issues. Each test creates its own service instance with explicit mock dependencies.
Module Boundaries
NestJS modules enforce boundaries that Express never had. Each module declares its imports, providers, and exports:
// orders/orders.module.ts
@Module({
imports: [UsersModule, EmailModule, PaymentModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService], // Only if other modules need it
})
export class OrdersModule {}If OrdersService tries to inject a service from a module that isn't imported, NestJS throws an error at startup. This is a feature, not a limitation — it makes dependency relationships explicit. In the Express codebase, any file could import any other file, and the dependency graph was a web with no visible boundaries.
Validation with Class-Validator and DTOs
Express validation was handled by a custom middleware that used Joi schemas:
// Before: Joi validation middleware
const validateBody = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
req.body = value;
next();
};NestJS uses class-validator decorators on DTO classes:
// orders/dto/create-order.dto.ts
export class CreateOrderDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
@IsString()
@IsOptional()
couponCode?: string;
@IsEnum(DeliveryMethod)
deliveryMethod: DeliveryMethod;
}
export class OrderItemDto {
@IsUUID()
productId: string;
@IsInt()
@Min(1)
@Max(100)
quantity: number;
}The DTO serves triple duty: it defines the shape, validates the input, and provides TypeScript types. The global ValidationPipe applies validation automatically to every endpoint — no need to remember to add validation middleware to each route.
Error Handling Consolidation
Express error handling was the most inconsistent part of the codebase. I consolidated everything into a single NestJS exception filter:
// common/filters/http-exception.filter.ts
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
let status = 500;
let message = "Internal server error";
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === "string"
? exceptionResponse
: (exceptionResponse as any).message;
}
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} - ${status}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}Every error, whether thrown as throw new NotFoundException('Order not found') or as an unhandled exception, flows through this filter. The response format is consistent, logging is centralized, and no route needs its own try/catch.
Testing Improvements
The migration's biggest payoff was in testing. Before the migration, the test suite had low coverage because setting up tests was painful — every test file started with a block of jest.mock() calls. After migration, unit tests were straightforward:
describe("OrdersService", () => {
let service: OrdersService;
let mockUsersService: jest.Mocked<UsersService>;
let mockPaymentService: jest.Mocked<PaymentService>;
beforeEach(async () => {
mockUsersService = {
findById: jest.fn(),
} as any;
mockPaymentService = {
charge: jest.fn(),
} as any;
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: UsersService, useValue: mockUsersService },
{ provide: PaymentService, useValue: mockPaymentService },
{ provide: EmailService, useValue: { sendOrderConfirmation: jest.fn() } },
{ provide: "DATABASE", useValue: mockDb },
],
}).compile();
service = module.get(OrdersService);
});
it("should charge the user when creating an order", async () => {
mockUsersService.findById.mockResolvedValue(mockUser);
await service.createOrder(mockUser.id, mockItems);
expect(mockPaymentService.charge).toHaveBeenCalledWith(
mockUser,
expect.any(Number),
);
});
});Integration tests used NestJS's supertest integration to test full request/response cycles:
describe("Orders API (e2e)", () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider("DATABASE")
.useValue(testDb)
.compile();
app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
it("should reject invalid order body", () => {
return request(app.getHttpServer())
.post("/api/v1/orders")
.set("Authorization", `Bearer ${testToken}`)
.send({ items: "not-an-array" })
.expect(400);
});
});Key Decisions & Trade-offs
Incremental migration vs. full rewrite: A full rewrite would have been faster in calendar time but required a risky big-bang cutover. The incremental approach let us ship migrated modules to production continuously with zero downtime. The trade-off was six weeks of maintaining two patterns in the codebase simultaneously.
Keeping Express as the HTTP adapter vs. switching to Fastify: NestJS supports both Express and Fastify adapters. Since the entire point of the incremental migration was Express compatibility, we stayed on the Express adapter. Switching to Fastify would have broken legacy middleware compatibility and negated the incremental strategy.
class-validator DTOs vs. Zod schemas: I chose class-validator because it integrates natively with NestJS's ValidationPipe and doesn't require additional configuration. Zod would have been more composable and better for shared validation, but class-validator was the path of least resistance for this migration.
Migrating one module at a time vs. migrating by layer: I could have migrated all controllers first, then all services. Instead, I migrated vertically — all of "categories" (controller, service, DTOs, tests), then all of "products," and so on. Vertical migration meant each migrated module was fully functional in NestJS without depending on legacy code.
Keeping the same API contract: I deliberately did not change any API request/response shapes during the migration. The migration was about internal architecture, not external behavior. Changing the API simultaneously would have doubled the risk and required frontend changes in parallel.
Results & Outcomes
After six weeks, all 40+ endpoints were running on NestJS controllers with injected services, DTO validation, and declarative guards. The test suite grew substantially because writing tests was no longer a chore — dependency injection made mocking natural rather than painful. New developers could understand a controller's requirements by reading its decorators instead of tracing through middleware chains. The module structure made it obvious where to add new features: "this is about orders, so it goes in the orders module." Error handling became consistent across every endpoint without individual try/catch blocks. The deployment was entirely uneventful — which is exactly what you want from a migration.
What I'd Do Differently
Write integration tests for every endpoint before starting the migration. I wrote behavioral tests as I migrated each module, but having them before the migration would have provided a safety net from day one. Any behavioral regression during migration would have been caught immediately.
Use NestJS's hybrid application mode instead of manual Express mounting. I manually mounted legacy Express routes on the same Express instance, but NestJS's app.connectMicroservice() and hybrid application patterns provide a more structured way to run multiple transports. This would have been cleaner for the transition period.
Migrate the database layer to a proper ORM simultaneously. The Express app used raw Knex queries. I kept Knex during the migration to limit scope, but migrating to Drizzle ORM or Prisma at the same time would have been more efficient than doing a separate ORM migration later.
Set up a feature flag per module for traffic routing. I used manual route removal to cut over modules, but a feature flag per module would have allowed instant rollback without code changes if any migrated module misbehaved in production.
FAQ
Can you run Express and NestJS side by side?
Yes, and this is the enabler for incremental migration. NestJS uses Express (or Fastify) as its underlying HTTP server. By passing an existing Express instance to NestJS via new ExpressAdapter(expressApp), both frameworks share the same HTTP server and port. Existing Express middleware, routes, and error handlers continue working unchanged. New NestJS controllers register their routes on the same Express instance. From the perspective of clients making HTTP requests, there is no difference — a request to /api/v1/categories hits the NestJS controller if it's been migrated, or the legacy Express route if it hasn't. Route priority is determined by registration order, so you remove the legacy Express route when the NestJS controller is ready. This approach means you can migrate one endpoint per day or one module per week, deploying continuously without any downtime or client-facing changes.
What are the main benefits of NestJS over Express?
NestJS provides a structured module system that groups related controllers, services, and providers into cohesive units with explicit dependency declarations. Built-in dependency injection means services receive their dependencies through constructor parameters, making them testable without module-level mocking hacks. Decorators like @Get(), @Post(), @UseGuards(), and @Roles() make route definitions self-documenting — you can read a controller method and immediately understand its HTTP method, path, authentication requirements, and authorization rules without tracing through middleware chains. Validation pipes automatically validate incoming data against DTO class definitions, eliminating manual validation middleware. Exception filters centralize error handling so every endpoint returns errors in a consistent format. First-class TypeScript support means the framework itself is written in TypeScript with complete type definitions, so IDE autocompletion and type checking work throughout the stack. These features reduce boilerplate, enforce architectural consistency, and make the codebase navigable for teams of any size.
How do you handle the migration without downtime?
We ran both the legacy Express routes and the new NestJS controllers on the same Express server instance, meaning there was never a moment where endpoints were unavailable. The migration proceeded one module at a time: first, integration tests were written against the existing Express endpoint to capture its exact behavior (request shapes, response shapes, status codes, error cases). Then, the NestJS controller and service were built to replicate that behavior. The same integration tests were run against the NestJS endpoint to verify behavioral parity. Once tests passed, the legacy Express route was removed from the router, and the NestJS controller took over. This happened in a standard deployment — no special cutover process, no maintenance window. If a migrated module showed issues in production, the rollback was equally simple: re-register the legacy Express route and remove the NestJS controller from the module. In practice, we never needed to roll back because the integration tests caught every discrepancy before deployment.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
Optimizing Core Web Vitals for e-Commerce
Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.
Building an AI-Powered Interview Feedback System
How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.
Migrating from Pages to App Router
A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.