NestJS Controller, Service, Module: The Three Buckets Every Route Hits
This is the third article in the Nest First Steps series.
In the first two articles, we completed project setup and database connection. In this article, we will understand the core design of NestJS—the three-layer architecture.
To understand it from a frontend perspective:
- Controller is like routing (equivalent to
router.jsin frontend), defining "which URL corresponds to which handler function"- Service is like utility functions (equivalent to
utils/in frontend), encapsulating specific business logic- Module is like file organization (equivalent to
index.jsaggregate exports in frontend), grouping related code togetherOnce you understand these three layers, you can read the code structure of a NestJS project and know "where to write the code."
1. Three-Layer Architecture Diagram
Let's first use a diagram to clearly see the data flow of the three-layer architecture:
graph LR
A["Frontend Request<br>GET /users"] --> B["Controller Layer<br>app.controller.ts<br>@Get('users')"]
B --> C["Service Layer<br>app.service.ts<br>getUserList()"]
C --> D["Prisma Layer<br>prisma.service.ts<br>user.findMany()"]
D --> E["Database<br>Neon PostgreSQL<br>User Table"]
E --> F["Return Data<br>User List JSON"]
F --> G["Frontend<br>Render Page"]
style A fill:#e1f5fe,stroke:#01579b
style B fill:#fff3e0,stroke:#e65100
style C fill:#e8f5e9,stroke:#1b5e20
style D fill:#f3e5f5,stroke:#4a148c
style E fill:#fff9c4,stroke:#f57f17
style F fill:#e1f5fe,stroke:#01579b
style G fill:#e1f5fe,stroke:#01579b
What does this diagram tell us?
| Step | Who Does It | What They Do |
|---|---|---|
| 1 | Frontend | Initiates HTTP request GET /users |
| 2 | Controller | Receives request, calls Service |
| 3 | Service | Handles business logic, calls Prisma to query database |
| 4 | Prisma | Executes SQL, fetches data from database |
| 5 | Service | Returns data to Controller |
| 6 | Controller | Wraps data as JSON and returns to frontend |
| 7 | Frontend | Receives JSON, renders page |
2. Controller Layer: Routing and Request Handling
Frontend analogy: Controller is like
router.jsin frontend, defining "which URL corresponds to which handler function"
2.1 What is a Controller?
In NestJS, the responsibilities of a Controller are:
- Receive HTTP requests (GET, POST, PATCH, DELETE, etc.)
- Parse request parameters (URL parameters, request body, query parameters, etc.)
- Call Service to handle business
- Return response to client
2.2 Walkthrough of Our Existing Controller Code
// ────────────────────────────────────────────────────────────
// 1. Import Dependencies
// ────────────────────────────────────────────────────────────
// Controller: marks this class as a controller
// Get, Post, Patch, Delete: HTTP method decorators
// Body, Param: parameter decorators for getting data from requests
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
} from '@nestjs/common';
// Import Service (business logic layer)
import { AppService, UserService } from './app.service.js';
// ────────────────────────────────────────────────────────────
// 2. Controller Class
// ────────────────────────────────────────────────────────────
// @Controller() decorator: tells NestJS this class is a controller
// If a prefix is written like @Controller('api'), all routes become /api/xxx
@Controller()
export class AppController {
// Constructor: dependency injection of Service
// Equivalent to "the front desk is equipped with a walkie-talkie to directly contact the back office"
constructor(
private readonly appService: AppService,
private readonly userService: UserService,
) {}
// ──────────────────────────────────────────────────────────
// Route: GET /
// ──────────────────────────────────────────────────────────
// @Get() decorator: handles GET requests
// No path argument means root path: /
// Equivalent to frontend route: { path: '/', component: ... }
@Get()
getHello(): string {
// Calls Service method, returns result
return this.appService.getHello();
}
// ──────────────────────────────────────────────────────────
// Route: GET /users
// ──────────────────────────────────────────────────────────
// @Get('users'): handles GET /users requests
// Equivalent to frontend route: { path: '/users', component: ... }
@Get('users')
async getUsers() {
// Calls UserService's getUserList method
// Service layer queries database for all users
return this.userService.getUserList();
}
// ──────────────────────────────────────────────────────────
// Route: GET /users/:id
// ──────────────────────────────────────────────────────────
// @Get('users/:id'): handles requests like GET /users/1
// :id is a dynamic parameter, can match /users/1, /users/2, etc.
@Get('users/:id')
async getUser(@Param('id') id: string) {
// @Param('id'): extracts id parameter from URL
// For example, request GET /users/1 → id = '1'
// Then passes to Service, converted to number type
return this.userService.getUserDetail(Number(id));
}
// ──────────────────────────────────────────────────────────
// Route: POST /users
// ──────────────────────────────────────────────────────────
// @Post('users'): handles POST /users requests
// Used for creating new users
@Post('users')
async createUser(@Body() body: { email: string; name: string }) {
// @Body(): extracts entire body object from request body
// When frontend POSTs /users, it sends JSON in request body
// Directly passes to Service for processing
return this.userService.createUser(body);
}
// ──────────────────────────────────────────────────────────
// Route: PATCH /users/:id
// ──────────────────────────────────────────────────────────
// @Patch('users/:id'): handles requests like PATCH /users/1
// Used for partial update of user information
@Patch('users/:id')
async updateUser(
@Param('id') id: string,
@Body() body: { email?: string; name?: string },
) {
// Takes id from URL, fields to update from body
// Passes to Service to execute update
return this.userService.updateUser(Number(id), body);
}
// ──────────────────────────────────────────────────────────
// Route: DELETE /users/:id
// ──────────────────────────────────────────────────────────
@Delete('users/:id')
async deleteUser(@Param('id') id: string) {
return this.userService.deleteUser(Number(id));
}
}
2.3 Summary of Common Controller Decorators
| Decorator | Purpose | Example |
|---|---|---|
@Controller() |
Marks class as controller | @Controller('api') → all routes prefixed with /api |
@Get() |
Handles GET requests | @Get('list') → GET /list |
@Post() |
Handles POST requests | @Post() → POST / |
@Patch() |
Handles PATCH requests | @Patch(':id') → PATCH /:id |
@Delete() |
Handles DELETE requests | @Delete(':id') → DELETE /:id |
@Param() |
Gets URL path parameters | @Param('id') id → extracts 1 from /users/1 |
@Query() |
Gets URL query parameters | @Query('page') page → extracts 2 from ?page=2 |
@Body() |
Gets request body | @Body() body → gets entire JSON body |
2.4 Frontend Comparison Table
| Frontend Concept | NestJS Equivalent |
|---|---|
router.js defining routes |
@Get(), @Post() decorators |
useParams() getting URL params |
@Param() decorator |
useSearchParams() getting query params |
@Query() decorator |
fetch() body |
@Body() decorator |
3. Service Layer: Business Logic
Frontend analogy: Service is like utils/ or api/ folders in frontend, encapsulating specific business logic
3.1 What is a Service?
The responsibilities of a Service are:
- Handle specific business logic
- Operate the database (via Prisma)
- Call external APIs
- Process data transformation
Simply put: Service is the "back office"—after Controller receives an order (request), it tells Service "go do the work," and Service returns the result (data) after finishing.
3.2 Why Have a Service Layer?
| Without Service | With Service |
|---|---|
| All code written in Controller | Controller only handles routing and parameter parsing |
| Same business logic duplicated across multiple Controllers | One Service reused by multiple Controllers |
| Changing business logic requires changing Controller, changing routes also requires changing Controller | Changing business logic only changes Service, Controller unchanged |
Core principle: Controller only handles "receiving" and "returning," Service only handles "doing."
3.3 Walkthrough of Our Existing Service Code
// ────────────────────────────────────────────────────────────
// 1. Import Dependencies
// ────────────────────────────────────────────────────────────
// Injectable: marks class as injectable via dependency injection
import { Injectable } from '@nestjs/common';
// PrismaService: database operation service
import { PrismaService } from './prisma.service.js';
// UserModel: type generated by Prisma, used for type checking
import type { UserModel } from './generated/prisma/models.js';
// ────────────────────────────────────────────────────────────
// 2. AppService: Application Basic Service
// ────────────────────────────────────────────────────────────
@Injectable()
export class AppService {
// This is a synchronous method, directly returns a string
getHello(): string {
return 'Hello World!';
}
}
// ────────────────────────────────────────────────────────────
// 3. UserService: User Business Service
// ────────────────────────────────────────────────────────────
@Injectable()
export class UserService {
// Constructor: injects PrismaService
// Equivalent to "the kitchen is equipped with a fridge (database) to directly get ingredients"
constructor(private readonly prisma: PrismaService) {}
// The methods below are called by Controller
// Methods are responsible for "going to the database" to query, edit, or delete users
// ──────────────────────────────────────────────────────────
// Business Method: Query All Users
// ──────────────────────────────────────────────────────────
async getUserList(): Promise<UserModel[]> {
return this.prisma.user.findMany();
}
// ──────────────────────────────────────────────────────────
// Business Method: Query Single User by ID
// ──────────────────────────────────────────────────────────
async getUserDetail(id: number): Promise<UserModel | null> {
return this.prisma.user.findUnique({
where: { id },
});
}
// ──────────────────────────────────────────────────────────
// Business Method: Create User
// ──────────────────────────────────────────────────────────
async createUser(data: { email: string; name: string }): Promise<UserModel> {
return this.prisma.user.create({
data,
});
}
// ──────────────────────────────────────────────────────────
// Business Method: Update User
// ──────────────────────────────────────────────────────────
async updateUser(
id: number,
data: { email?: string; name?: string },
): Promise<UserModel> {
return this.prisma.user.update({
where: { id },
data,
});
}
// ──────────────────────────────────────────────────────────
// Business Method: Delete User
// ──────────────────────────────────────────────────────────
async deleteUser(id: number): Promise<UserModel> {
return this.prisma.user.delete({
where: { id },
});
}
}
3.4 Prisma Common Methods Quick Reference
When operating the database in Service, the most commonly used are these 5 methods provided by Prisma:
| Prisma Method | Purpose | Corresponding SQL |
|---|---|---|
findMany() |
Query multiple records | SELECT * FROM "User" |
findUnique() |
Query single record by unique field | SELECT * FROM "User" WHERE id = ? |
create() |
Create a record | INSERT INTO "User" ... |
update() |
Update a record | UPDATE "User" SET ... WHERE id = ? |
delete() |
Delete a record | DELETE FROM "User" WHERE id = ? |
Other commonly used methods:
| Prisma Method | Purpose |
|---|---|
findFirst() |
Query first record matching conditions |
count() |
Count number of records |
upsert() |
Update if exists, create if not |
deleteMany() |
Delete multiple records |
updateMany() |
Update multiple records |
3.5 Frontend Comparison Table
| Frontend Concept | NestJS Equivalent |
|---|---|
utils/ utility functions |
Methods in Service |
api/ interface encapsulation |
Calling Prisma in Service |
| Actions in state management | Business methods in Service |
4. Module Layer: Organizing Code
Frontend analogy: Module is like
index.jsaggregate exports in frontend, organizing related code together
4.1 What is a Module?
The responsibilities of a Module are:
- Organize Controller and Service together
- Register all classes that can be dependency injected
- Import functionality from other modules
Simply put: Module is the "company organizational chart"—it tells NestJS: which front desks (Controllers) and which back offices (Services) are in this module.
4.2 Walkthrough of Our Existing Module Code
// ────────────────────────────────────────────────────────────
// 1. Import Dependencies
// ────────────────────────────────────────────────────────────
// Module: marks class as a module
import { Module } from '@nestjs/common';
// Controller: front desk responsible for handling HTTP requests
import { AppController } from './app.controller.js';
// Service: back office responsible for business logic
import { AppService, UserService } from './app.service.js';
// Database service
import { PrismaService } from './prisma.service.js';
// ────────────────────────────────────────────────────────────
// 2. Root Module AppModule
// ────────────────────────────────────────────────────────────
@Module({
// imports: import other modules (none yet)
// Equivalent to "referencing external company departments"
imports: [],
// controllers: register controllers (front desks)
// Tells NestJS which front desk receptionists this module has
controllers: [AppController],
// providers: register services (back offices)
// Tells NestJS which back offices this module has
// All classes registered in providers can be "dependency injected" into other classes
providers: [
AppService, // Basic service
UserService, // User business service
PrismaService, // Database service
],
})
export class AppModule {}
4.3 Three Core Properties of Module
| Property | Role | Analogy |
|---|---|---|
imports |
Import functionality from other modules | "Borrowing resources from other departments" |
controllers |
Register controllers (routes) | "Front desk receptionist list" |
providers |
Register services (injectable) | "Back office employee list" |
Hands-On Practice: Query User by Email
5.1 Add Method in Service
Add in UserService of src/app.service.ts:
// ──────────────────────────────────────────────────────────
// Business Method: Query Single User by Email
// ──────────────────────────────────────────────────────────
// Corresponding route: GET /users/email/:email
// Example: GET /users/email/[email protected]
async getUserByEmail(email: string): Promise<UserModel | null> {
// Prisma executes SQL: SELECT * FROM "User" WHERE email = ?;
return this.prisma.user.findUnique({
where: { email },
});
}
5.2 Add Route in Controller
Add in AppController of src/app.controller.ts:
// ──────────────────────────────────────────────────────────
// Route: GET /users/email/:email
// ──────────────────────────────────────────────────────────
@Get('users/email/:email')
async getUserByEmail(@Param('email') email: string) {
return this.userService.getUserByEmail(email);
}
5.3 Test
Visit in browser: http://localhost:3000/users/email/[email protected]
6. Summary
Three-Layer Responsibilities in One Sentence
| Layer | One-Sentence Summary | Frontend Analogy |
|---|---|---|
| Controller | Defines URLs and receives requests | Route configuration |
| Service | Writes specific business logic | Utility functions / API encapsulation |
| Module | Assembles Controller and Service together | Module export aggregation |
Code Organization Principle
Controller receives request → hands to Service for processing → returns result to client
Remember:
- Controller should not write business logic (only handle routing and parameters)
- Service should not write routes (only handle business logic)
- Module should register everything needed
Welcome to follow, let's move from React/Vue to full-stack together.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Very detailed explanation, great. You can check out my open-source project, it's a NestJS practical project. https://github.com/liangy0323/ly-fullstack