A Complete NestJS CRUD Module, Layer by Layer
theme: github
In the previous article, we covered NestJS's factory pattern, decorator pattern, and modular architecture. This article dives straight into writing code—implementing a complete Todos CRUD module from scratch, covering the full create, read, update, and delete workflow, and understanding how the three layers of Module, Controller, and Service collaborate.
Goal: A Complete Todos API
The interfaces we need to implement are as follows:
| HTTP Method | Route | Function | Request Body |
|---|---|---|---|
| GET | /todos |
Get all todos | - |
| GET | /todos/:id |
Get a single todo | - |
| POST | /todos |
Create a todo | { "title": "xxx" } |
| PATCH | /todos/:id |
Update a todo | { "completed": true } |
| DELETE | /todos/:id |
Delete a todo | - |
This is a standard RESTful style. Let's implement it layer by layer.
Step 1: Define the Data Model
Define the data structure in src/todos/todos.service.ts:
export interface Todo {
id: number;
title: string;
completed: boolean;
}
// Initial data
let todos: Todo[] = [
{ id: 1, title: 'Learn nestjs', completed: false },
{ id: 2, title: 'Learn CRUD', completed: true },
];
let nextId = 3; // Auto-increment ID
Here, an in-memory array simulates a database, focusing on understanding the framework mechanisms. In real projects, database tools like TypeORM or Prisma would be integrated, but the collaboration between the three layers is exactly the same.
Step 2: Service Layer — Business Logic
The Service is the core of data operations; all CRUD logic resides here:
import { Injectable, NotFoundException } from '@nestjs/common';
import { type Todo } from './todos.service'; // Type import
@Injectable() // Marks it as injectable via dependency injection
export class TodosService {
// Query all
findAll(): Todo[] {
return todos;
}
// Query one
findOne(id: number): Todo {
const todo = todos.find(t => t.id === id);
// Throw a standard exception if not found
if (!todo) throw new NotFoundException(`todo not found: ${id}`);
return todo;
}
// Create
create(title: string): Todo {
const todo: Todo = { id: nextId++, title, completed: false };
todos.push(todo);
return todo;
}
// Delete
remove(id: number): void {
const index = todos.findIndex(t => t.id === id);
if (index === -1) throw new NotFoundException(`Todo ${id} does not exist`);
todos.splice(index, 1);
}
// Update (partial update)
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id); // Reuse query logic
Object.assign(todo, patch); // Merge updated fields
return todo;
}
}
Key points:
@Injectable() decorator: Marks this class as manageable by NestJS's dependency injection container. With this decorator, the Controller can automatically get its instance via the constructor.
NotFoundException: A built-in NestJS exception class. Throwing it causes the framework to automatically return a standard HTTP error response:
{
"statusCode": 404,
"message": "todo not found: 999",
"error": "Not Found"
}
Partial<Todo>: A TypeScript utility type that makes all properties of Todo optional. This allows the update method to accept only the fields that need modification, rather than the entire object.
Object.assign: Merges the properties of the patch object into todo, overwriting only the passed fields. For example, passing { completed: true } only changes completed, leaving title unchanged.
| Exception Handling Method | Return Result |
|---|---|
throw new NotFoundException(msg) |
HTTP 404 + standard JSON error body |
throw new Error(msg) |
HTTP 500 + non-standard error response |
Manual res.status(404).json(...) |
Requires manual handling, not consistent with NestJS style |
NestJS provides a complete system of built-in exception classes: BadRequestException(400), UnauthorizedException(401), ForbiddenException(403), NotFoundException(404), InternalServerErrorException(500), etc. Simply throw them, and the framework handles converting them into standard HTTP responses.
Step 3: Controller Layer — Routing and Parameter Extraction
The Controller is the entry point for requests, responsible for receiving HTTP requests, extracting parameters, calling the Service, and returning responses:
import {
Body, // Extract data from the request body
Controller,
Get,
Param, // Extract from URL path parameters
Post,
Delete,
Patch
} from '@nestjs/common';
import { TodosService } from './todos.service';
import { type Todo } from './todos.service';
@Controller('todos') // Route prefix: /todos
export class TodosController {
// Dependency injection: declared in the constructor, NestJS auto-instantiates
constructor(private readonly todosService: TodosService) {}
// GET /todos
@Get()
findAll(): Todo[] {
return this.todosService.findAll();
}
// GET /todos/1
@Get(':id')
findOne(@Param('id') id: string): Todo {
return this.todosService.findOne(Number(id));
}
// POST /todos body: { "title": "xxx" }
@Post()
create(@Body('title') title: string): Todo {
return this.todosService.create(title);
}
// DELETE /todos/1
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: 'Deleted successfully' };
}
// PATCH /todos/1 body: { "completed": true }
@Patch(':id')
update(
@Param('id') id: string,
@Body() patch: Partial<Todo>
): Todo {
return this.todosService.update(Number(id), patch);
}
}
Parameter Extraction Decorator Reference
| Decorator | Extraction Source | Example | Value Obtained |
|---|---|---|---|
@Param('id') |
URL path parameter | GET /todos/1 → @Param('id') |
"1" (string type) |
@Body('title') |
Specific field of request body | { "title": "Eat" } → @Body('title') |
"Eat" |
@Body() |
Entire request body | { "completed": true } → @Body() |
{ completed: true } |
@Query() |
URL query parameter | GET /todos?page=1 → @Query('page') |
"1" |
Note: The value extracted by
@Param()is always of typestring. Even if the URL contains the number1, you get"1". Conversion withNumber(id)is required.
Route Matching Rules
@Controller('todos') sets the prefix, which combines with method decorators to form the full route:
| Method Decorator | Route Prefix | Full Route |
|---|---|---|
@Get() |
/todos |
GET /todos |
@Get(':id') |
/todos |
GET /todos/:id |
@Post() |
/todos |
POST /todos |
@Patch(':id') |
/todos |
PATCH /todos/:id |
@Delete(':id') |
/todos |
DELETE /todos/:id |
NestJS matches routes in top-to-bottom order. @Get() comes before @Get(':id'), so GET /todos matches findAll() instead of passing "todos" as an id to findOne().
Step 4: Module Layer — Assembling the Module
The Module is responsible for assembling the Controller and Service into a unit that can be imported by the root module:
import { Module } from '@nestjs/common';
import { TodosController } from './todos.controller';
import { TodosService } from './todos.service';
@Module({
controllers: [TodosController], // Register controllers
providers: [TodosService] // Register services (injectable)
})
export class TodosModule {}
Then import it into the root module:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/todos.module';
@Module({
imports: [TodosModule], // Import the Todos sub-module
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Full Data Flow Chain
Taking GET /todos/1 as an example, the complete request processing flow:
Client requests GET /todos/1
│
▼
The Express instance created by NestFactory receives the request
│
▼
Route matching → @Controller('todos') + @Get(':id')
│
▼
@Param('id') extracts id = "1"
│
▼
TodosController.findOne("1")
│
▼
Calls this.todosService.findOne(1) ← Dependency Injection
│
▼
TodosService.findOne(1)
│
▼
todos.find(t => t.id === 1) ← Data lookup
│
├── Found → return todo object
│ │
│ ▼
│ Controller returns JSON → Client receives 200
│
└── Not found → throw NotFoundException
│
▼
NestJS exception filter intercepts
│
▼
Returns 404 + error JSON → Client receives 404
Summary of Three-Layer Responsibilities
| Layer | File | Concerned With | Not Concerned With |
|---|---|---|---|
| Controller | todos.controller.ts |
Route matching, parameter extraction, calling Service | Where the data comes from |
| Service | todos.service.ts |
Data operations, business logic, exception throwing | HTTP request details |
| Module | todos.module.ts |
Registering and assembling Controller + Service | Specific business logic |
The benefit of this layering: The Controller only "answers the phone," the Service only "does the work," and the Module only "arranges the seating." Each layer has a single responsibility; modifying one layer does not affect the others.
Testing: Unit Test Structure
The NestJS scaffold comes with the Jest testing framework. app.controller.spec.ts is a standard unit test example:
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
// Create a test module, replacing the real NestFactory
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
Key points:
Test.createTestingModule()creates a lightweight test container without needing to start a full HTTP service- Real Services can be replaced with mock implementations in
providersfor isolated testing - Running
npm testexecutes all*.spec.tstest files
Verification: Testing Interfaces with curl
Once the project is running (npm run start:dev), quickly verify with curl:
# Query all
curl http://localhost:3000/todos
# [{"id":1,"title":"Learn nestjs","completed":false},{"id":2,"title":"Learn CRUD","completed":true}]
# Query one
curl http://localhost:3000/todos/1
# {"id":1,"title":"Learn nestjs","completed":false}
# Create
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Write blog"}'
# {"id":3,"title":"Write blog","completed":false}
# Update (mark as complete)
curl -X PATCH http://localhost:3000/todos/3 \
-H "Content-Type: application/json" \
-d '{"completed":true}'
# {"id":3,"title":"Write blog","completed":true}
# Delete
curl -X DELETE http://localhost:3000/todos/3
# {"message":"Deleted successfully"}
# Query a non-existent ID → triggers NotFoundException
curl http://localhost:3000/todos/999
# {"statusCode":404,"message":"todo not found: 999","error":"Not Found"}
Summary
Although simple, this Todos module fully demonstrates the core development pattern of NestJS:
- Define interfaces and models:
Todointerface + in-memory data, establishing the data structure - Write the Service:
@Injectable()+ CRUD methods +NotFoundExceptionexception handling - Write the Controller:
@Controller('todos')+ route decorators + parameter extraction decorators - Assemble the Module:
@Module()registers the Controller and Service - Import into the root module: Add the sub-module to
AppModule'simports - Write tests:
Test.createTestingModule()for isolated testing
This Module → Controller → Service trio pattern is the standard paradigm for developing all business modules in NestJS. Whether it's user authentication, an order system, or a payment gateway, only the business logic changes; the architectural skeleton remains the same. Once you master this routine, you can build any backend service with a consistent approach.