跪拜 Guibai
← Back to the summary

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.js but a bunch of @Module, @Controller, and @Injectable decorators, 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.json must have "experimentalDecorators": true and "emitDecoratorMetadata": true configured, which the nest new scaffolding 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:

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:

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:

  1. AppService is marked as injectable with @Injectable()
  2. AppService is registered in AppModule's providers
  3. NestJS's IoC container automatically instantiates AppService and injects it into the constructor when creating AppController
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:


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:

  1. Factory Pattern: NestFactory.create() uniformly creates application instances, shielding internal assembly details
  2. Decorator Pattern: Decorators like @Module, @Controller, @Injectable add capabilities to classes without modifying the original code
  3. 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.