跪拜 Guibai
← Back to the summary

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 type string. Even if the URL contains the number 1, you get "1". Conversion with Number(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:


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:

  1. Define interfaces and models: Todo interface + in-memory data, establishing the data structure
  2. Write the Service: @Injectable() + CRUD methods + NotFoundException exception handling
  3. Write the Controller: @Controller('todos') + route decorators + parameter extraction decorators
  4. Assemble the Module: @Module() registers the Controller and Service
  5. Import into the root module: Add the sub-module to AppModule's imports
  6. 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.