NestJS Isn't Just Express with Decorators — It's a Factory That Assembles Your Backend
When you first open a NestJS project and see not a messy
app.jsbut a bunch of@Module,@Controller, and@Injectabledecorators, you might think, "This is way too convoluted." But once you understand the design patterns behind it, you'll find that this structure is precisely the key to keeping large projects maintainable.
What is NestJS
NestJS is a framework for building Node.js backend services, using TypeScript by default. Its design goal is enterprise-grade — when you're dealing with dozens of endpoints, multiple business modules, and team collaboration, NestJS's modular thinking prevents the codebase from turning into a tangled mess.
It's built on top of Express (and can also switch to Fastify), but provides a complete set of architectural conventions on top of Express. The core consists of three things:
| Feature | Role | Corresponding Concept |
|---|---|---|
| Modularity | Split independent units by business domain | @Module() |
| Dependency Injection | Automatically wire Services into Controllers | @Injectable() |
| Decorators | Dynamically add capabilities like routing and validation to classes | @Get(), @Controller(), etc. |
Before diving into these, let's start with a fundamental design pattern — the Factory Pattern.
Factory Pattern: Inspiration from Mixue Ice Cream & Tea
NestJS's entry function NestFactory.create() is essentially an application of the Factory Pattern. To understand it, let's first look at a more relatable example.
A Milk Tea Factory
Imagine you go to Mixue to order. You don't need to know how the ice cream is made or how the lemon tea is brewed — you just tell the counter, "One ice cream, please." The factory handles production internally, and you get the finished product.
Expressed in code, it looks like this:
// Product classes: each product has the same interface (show method)
class IceCream {
constructor() {
this.name = 'Ice Cream';
this.price = 3;
}
show() {
console.log(this.name, this.price + ' yuan');
}
}
class LemonTea {
constructor() {
this.name = 'Lemon Tea';
this.price = 4;
}
show() {
console.log(this.name, this.price + ' yuan');
}
}
class MilkTea {
constructor() {
this.name = 'Pearl Milk Tea';
this.price = 8;
}
show() {
console.log(this.name, this.price + ' yuan');
}
}
// Factory class: only exposes a single create method to the outside
class MixueFactory {
static create(type) {
switch (type) {
case 'ice': return new IceCream();
case 'lemon': return new LemonTea();
case 'milk': return new MilkTea();
}
}
}
// Consumer: doesn't need to understand the factory's internal details, just calls it directly
const drink1 = MixueFactory.create('ice');
drink1.show(); // Ice Cream 3 yuan
const drink2 = MixueFactory.create('lemon');
drink2.show(); // Lemon Tea 4 yuan
const drink3 = MixueFactory.create('milk');
drink3.show(); // Pearl Milk Tea 8 yuan
What the Factory Pattern Solves
| Without Factory | With Factory |
|---|---|
| The caller needs to know the name and construction method of every product class | The caller only needs to know the factory and a type parameter |
| As product classes increase, the calling code becomes chaotic | Adding a new product only requires changing the factory internally; the calling code stays the same |
| Product classes and calling code are tightly coupled | The factory shields internal details, achieving decoupling |
Core idea: Program to an interface, not an implementation. All products implement the show() method, and the caller doesn't need to care which specific class the factory returns.
NestFactory: NestJS's Factory
Once you understand Mixue, NestJS's entry point becomes logical:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
// NestFactory is that "factory"
// create(AppModule) is like MixueFactory.create('ice')
// You pass in a module, and the factory creates a complete application instance for you
const app = await NestFactory.create(AppModule);
// Start the HTTP service, listening on port 3000
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
What NestFactory.create(AppModule) does follows the same idea as MixueFactory.create('ice'): you hand your requirements to the factory, and the factory is responsible for assembling and returning the instance. NestJS can build far more than just HTTP services — it also supports WebSockets, microservices, gRPC, etc. NestFactory is that unified production entry point.
Decorator Pattern: Adding Capabilities to Classes
NestJS code is full of syntax starting with @ — these are decorators.
The essence of a decorator is: dynamically add extra functionality to a class without modifying its original code.
@Controller('todos') // Tag this class as a "controller", route prefix is /todos
export class TodosController {
@Get() // Tag this method as "handles GET requests"
findAll() { ... }
@Post() // Tag this method as "handles POST requests"
create() { ... }
}
| Traditional Express Style | NestJS Decorator Style |
|---|---|
app.get('/todos', (req, res) => { ... }) |
@Get() placed on a method, routes are auto-registered |
| Routing and logic code are mixed together | Route declarations and business code are separated, improving readability |
| No type constraints | TypeScript type checking provides full protection |
NestJS uses decorators to the extreme: @Module() defines modules, @Controller() defines controllers, @Injectable() marks injectable services, @Get(), @Post(), @Delete() define route methods, @Param(), @Body() extract request parameters. Each decorator has its own role, turning an ordinary class into a fully functional backend component.
Note: TypeScript does not enable decorator support by default. A NestJS project's
tsconfig.jsonmust have"experimentalDecorators": trueand"emitDecoratorMetadata": trueconfigured, which thenest newscaffolding does for you automatically.
Modular Architecture: Module → Controller → Service
NestJS's core architecture can be summarized in one sentence:
AppModule (Root Module)
├── imports: [other sub-modules...]
├── controllers: [list of controllers]
└── providers: [list of services]
Three-Layer Responsibility Division
| Layer | File Convention | Responsibility | Decorator |
|---|---|---|---|
| Module | xx.module.ts |
Assemble the module, declare dependency relationships | @Module() |
| Controller | xx.controller.ts |
Receive requests, validate parameters, return responses | @Controller() |
| Service | xx.service.ts |
Business logic, data processing | @Injectable() |
This is the classic MVC pattern mapped into NestJS:
- M (Model/Service): Data service and business logic layer, marked by
@Injectable(), can be automatically injected - C (Controller): Controller layer, the entry point for handling HTTP requests, validates parameters and then delegates business to the Service
- V (View): NestJS is a pure backend framework; the view layer is handled by the frontend
What a Root Module Looks Like
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/todos.module';
@Module({
imports: [TodosModule], // Dependent sub-modules
controllers: [AppController], // This module's controllers
providers: [AppService], // This module's services
})
export class AppModule {}
The @Module() decorator receives a configuration object with three key fields:
imports: Declares which other modules this module depends on (e.g.,TodosModule)controllers: Registers this module's controllersproviders: Registers this module's services (Service)
Dependency Injection: Service Automatically into Controller
This is the most "magical" part of NestJS. In a Controller:
@Controller()
export class AppController {
// No manual new AppService(), just declare it and you get it
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
You didn't write new AppService(), but this.appService works. This is because:
AppServiceis marked as injectable with@Injectable()AppServiceis registered inAppModule'sproviders- NestJS's IoC container automatically instantiates
AppServiceand injects it into the constructor when creatingAppController
| Manual Instantiation | Dependency Injection |
|---|---|
const service = new AppService() must be written everywhere |
The container automatically creates and injects |
| Changing the Service implementation class requires changes at every call site | Only change the Module registration; call sites remain unchanged |
| Cannot easily mock for unit testing | Can be replaced with a mock implementation during testing |
Project Directory Structure Overview
A standard NestJS project structure is as follows:
hello/
├── src/
│ ├── main.ts # Entry file: creates the app, starts the service
│ ├── app.module.ts # Root module: assembles all modules
│ ├── app.controller.ts # Root controller: handles the / route
│ ├── app.service.ts # Root service: returns Hello World
│ └── todos/ # Business module (independent directory)
│ ├── todos.module.ts # Module definition
│ ├── todos.controller.ts # Controller: RESTful routes
│ └── todos.service.ts # Service: CRUD business logic
├── test/
│ └── app.e2e-spec.ts # E2E tests
├── package.json
├── tsconfig.json # TypeScript config (enables decorators)
└── nest-cli.json # NestJS CLI config
Key conventions:
- Entry:
main.tsis responsible for bootstrapping, callingNestFactory.create(AppModule)to create the application - Modules: Each business feature is an independent directory containing the
module.ts,controller.ts,service.tstrio - Assembly: The root module
app.module.tsimports all sub-modules viaimports, forming a dependency tree
Starting a NestJS Project from Scratch
# Install NestJS CLI globally
npm i -g @nestjs/cli
# Create a new project
nest new hello
# Enter the project directory
cd hello
# Run in development mode (auto-restart on file changes)
npm run start:dev
Open a browser and visit http://localhost:3000. Seeing Hello World! means the project is up and running.
A few key commands in package.json:
| Command | Role |
|---|---|
npm run start |
Normal start |
npm run start:dev |
Development mode, hot reload |
npm run build |
Compile to dist/ directory |
npm run start:prod |
Production mode, runs compiled dist/main.js |
npm test |
Run unit tests |
npm run test:e2e |
Run end-to-end tests |
Summary
NestJS's architectural design is not complicated; at its core, it's a combination of three design patterns:
- Factory Pattern:
NestFactory.create()uniformly creates application instances, shielding internal assembly details - Decorator Pattern: Decorators like
@Module,@Controller,@Injectableadd capabilities to classes without modifying the original code - Dependency Injection: Services don't need manual instantiation; the IoC container automatically injects them where needed
Once you understand this architecture, looking at any NestJS project, you'll find they all follow the same pattern: entry creates the app → modules organize the structure → controllers handle requests → services handle business logic. Mastering this pattern is like getting the master key to NestJS.
In the next article, we'll use a complete Todos CRUD module to turn this architecture from a paper concept into runnable code.