Lesson 47 of 49 intermediate

NestJS CRUD with Database

Building a Complete REST API

Open interactive version (quiz + challenge)

Real-world analogy

Building CRUD is like running a library. Create = buying new books and adding them to shelves. Read = finding and borrowing books. Update = correcting typos or updating editions. Delete = removing damaged books. The librarian (service) manages the catalog (database), while the front desk (controller) talks to visitors.

What is it?

A complete CRUD API combines controllers (HTTP handling), services (business logic), DTOs (input validation), and database operations (Prisma/TypeORM) into a RESTful interface. Each endpoint maps to a database operation: POST→create, GET→read, PATCH→update, DELETE→delete.

Real-world relevance

Every SaaS product is built on CRUD. Shopify's product API, Notion's page API, GitHub's repository API — they all follow the same CRUD pattern with pagination, filtering, and validation. This is the bread and butter of backend development.

Key points

Code example

// products.dto.ts — Input validation
import {
  IsString, IsNumber, IsOptional,
  MinLength, Min, MaxLength, IsEnum,
} from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';

enum ProductStatus { ACTIVE = 'active', DRAFT = 'draft' }

export class CreateProductDto {
  @IsString()
  @MinLength(3)
  name: string;

  @IsNumber()
  @Min(0)
  price: number;

  @IsOptional()
  @IsString()
  @MaxLength(500)
  description?: string;

  @IsOptional()
  @IsEnum(ProductStatus)
  status?: ProductStatus;
}

export class UpdateProductDto extends PartialType(CreateProductDto) {}

// products.service.ts — Business logic + DB
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class ProductsService {
  constructor(private prisma: PrismaService) {}

  // CREATE
  async create(dto: CreateProductDto) {
    return this.prisma.product.create({ data: dto });
  }

  // READ (all, paginated)
  async findAll(params: {
    page: number; limit: number; search?: string;
  }) {
    const { page, limit, search } = params;
    const skip = (page - 1) * limit;
    const where = search
      ? { name: { contains: search, mode: 'insensitive' as const } }
      : {};

    const [data, total] = await Promise.all([
      this.prisma.product.findMany({
        where, skip, take: limit,
        orderBy: { createdAt: 'desc' },
      }),
      this.prisma.product.count({ where }),
    ]);

    return {
      data,
      meta: {
        total,
        page,
        limit,
        pages: Math.ceil(total / limit),
      },
    };
  }

  // READ (one)
  async findOne(id: number) {
    const product = await this.prisma.product.findUnique({
      where: { id },
    });
    if (!product) {
      throw new NotFoundException(`Product #${id} not found`);
    }
    return product;
  }

  // UPDATE
  async update(id: number, dto: UpdateProductDto) {
    await this.findOne(id); // Throws 404 if not found
    return this.prisma.product.update({
      where: { id }, data: dto,
    });
  }

  // DELETE
  async remove(id: number) {
    await this.findOne(id); // Throws 404 if not found
    return this.prisma.product.delete({ where: { id } });
  }
}

// products.controller.ts — HTTP layer
import {
  Controller, Get, Post, Patch, Delete,
  Param, Query, Body, HttpCode,
  ParseIntPipe, DefaultValuePipe,
} from '@nestjs/common';

@Controller('products')
export class ProductsController {
  constructor(private productsService: ProductsService) {}

  @Post()
  create(@Body() dto: CreateProductDto) {
    return this.productsService.create(dto);
  }

  @Get()
  findAll(
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
    @Query('search') search?: string,
  ) {
    return this.productsService.findAll({ page, limit, search });
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.productsService.findOne(id);
  }

  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() dto: UpdateProductDto,
  ) {
    return this.productsService.update(id, dto);
  }

  @Delete(':id')
  @HttpCode(204)
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.productsService.remove(id);
  }
}

Line-by-line walkthrough

  1. 1. products.dto.ts — Input validation
  2. 2. Import validation decorators
  3. 3.
  4. 4.
  5. 5.
  6. 6. Import PartialType utility
  7. 7.
  8. 8. Define allowed product statuses
  9. 9.
  10. 10. DTO for creating a product
  11. 11. Name must be a string
  12. 12. At least 3 characters
  13. 13. Property declaration
  14. 14.
  15. 15. Price must be a number
  16. 16. Cannot be negative
  17. 17. Property declaration
  18. 18.
  19. 19. Description is optional
  20. 20. Must be a string if provided
  21. 21. Maximum 500 characters
  22. 22. Optional property declaration
  23. 23.
  24. 24. Status is optional
  25. 25. Must be a valid enum value
  26. 26. Optional property declaration
  27. 27. Close DTO class
  28. 28.
  29. 29. UpdateDto — all fields become optional
  30. 30.
  31. 31. products.service.ts — Business logic + DB
  32. 32. Import Injectable and error class
  33. 33. Import Prisma for database access
  34. 34.
  35. 35. Mark as injectable for DI
  36. 36. Export the service class
  37. 37. Inject PrismaService via constructor
  38. 38.
  39. 39. CREATE operation
  40. 40. Insert DTO data into database
  41. 41. Close method
  42. 42.
  43. 43. READ all with pagination
  44. 44. Accept page, limit, and optional search
  45. 45.
  46. 46. Destructure parameters
  47. 47. Calculate records to skip
  48. 48. Build where clause for search
  49. 49. Case-insensitive name search
  50. 50. Empty where if no search
  51. 51.
  52. 52. Run both queries in parallel
  53. 53. Fetch paginated products
  54. 54. Apply where filter, skip, and limit
  55. 55. Sort by newest first
  56. 56. Close findMany
  57. 57. Count total matching products
  58. 58. Close Promise.all
  59. 59.
  60. 60. Return structured response
  61. 61. The product records
  62. 62. Pagination metadata
  63. 63. Total matching records
  64. 64. Current page
  65. 65. Items per page
  66. 66. Total number of pages
  67. 67. Close meta
  68. 68. Close return
  69. 69. Close method
  70. 70.
  71. 71. READ one by ID
  72. 72. Query database for single product
  73. 73. Filter by ID
  74. 74. Close query
  75. 75. If not found...
  76. 76. Throw 404 error
  77. 77. Close if
  78. 78. Return the product
  79. 79. Close method
  80. 80.
  81. 81. UPDATE operation
  82. 82. Check if product exists (throws 404)
  83. 83. Update in database
  84. 84. Filter by ID and set new data
  85. 85. Close update
  86. 86. Close method
  87. 87.
  88. 88. DELETE operation
  89. 89. Check if product exists (throws 404)
  90. 90. Delete from database
  91. 91. Close method
  92. 92. Close service class
  93. 93.
  94. 94. products.controller.ts — HTTP layer
  95. 95. Import NestJS decorators
  96. 96.
  97. 97.
  98. 98.
  99. 99.
  100. 100. Controller with /products base route
  101. 101. Export the controller class
  102. 102. Inject the service
  103. 103.
  104. 104. POST /products — create
  105. 105. Validate body against CreateProductDto
  106. 106. Delegate to service
  107. 107. Close method
  108. 108.
  109. 109. GET /products — list all
  110. 110. Method signature
  111. 111. Page query param with default 1
  112. 112. Limit query param with default 10
  113. 113. Optional search query param
  114. 114. Close parameter list
  115. 115. Delegate to service with params
  116. 116. Close method
  117. 117.
  118. 118. GET /products/:id — find one
  119. 119. Extract and validate ID as integer
  120. 120. Delegate to service
  121. 121. Close method
  122. 122.
  123. 123. PATCH /products/:id — update
  124. 124. Method signature
  125. 125. Extract and validate ID
  126. 126. Validate body against UpdateProductDto
  127. 127. Close parameter list
  128. 128. Delegate to service
  129. 129. Close method
  130. 130.
  131. 131. DELETE /products/:id — remove
  132. 132. Return 204 No Content
  133. 133. Extract and validate ID
  134. 134. Delegate to service
  135. 135. Close method
  136. 136. Close controller class

Spot the bug

@Controller('products')
export class ProductsController {
  constructor(private productsService: ProductsService) {}

  @Patch(':id')
  async update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
    return this.productsService.update(id, dto);
  }

  @Delete(':id')
  async remove(@Param('id') id: string) {
    this.productsService.remove(id);
  }
}
Need a hint?
Two issues: one with the param type and one with the delete method...
Show answer
1) The 'id' param is a string but the service expects a number. Fix: @Param('id', ParseIntPipe) id: number. 2) The delete method is missing 'return' and 'await'. Without await, errors won't be caught. Without return, NestJS can't handle the response properly. Fix: return this.productsService.remove(id);

Explain like I'm 5

CRUD is like a toy box. CREATE = putting a new toy in the box. READ = looking inside to see what toys you have. UPDATE = fixing a broken toy or painting it a new color. DELETE = throwing away a toy you don't want anymore. Every app in the world is just a fancy toy box with these four actions!

Fun fact

The REST architecture was defined by Roy Fielding in his year 2000 PhD dissertation. He was also a co-author of the HTTP specification. REST is so foundational that almost every API in the world follows it — you're learning the language that all web services speak!

Hands-on challenge

Build a complete tasks API with: POST /tasks (create), GET /tasks (list with pagination + filter by status), GET /tasks/:id, PATCH /tasks/:id, DELETE /tasks/:id. Include a TaskStatus enum (todo/in_progress/done), search by title, and sort by createdAt. Use DTOs for all input validation!

More resources

Open interactive version (quiz + challenge) ← Back to course: Full-Stack Playbook