Building a REST API with NestJS 2025

Building a REST API with NestJS 2025 In this tutorial, I will show you how to develop a REST API using NestJS. Requirements review Now, suppose you are given the following database design: You are required to create a REST API with the following endpoints: Products: Method Endpoint Description GET /api/products Get paginated list of products GET /api/products/{id} Get a specific product by ID POST /api/products Create a new product PUT /api/products/{id} Update an existing product DELETE /api/products/{id} Delete a product Categories: Method Endpoint Description GET /api/categories Get paginated list of categories GET /api/categories/{id} Get a specific category by ID POST /api/categories Create a new category PUT /api/categories/{id} Update an existing category DELETE /api/categories/{id} Delete a category Example of responses: GET /api/products { "content": [ { "id": 1, "name": "Smartphone X", "description": "A high-end smartphone with an excellent camera.", "price": 999.99, "categories": [ { "id": 2, "name": "Electronics" }, { "id": 5, "name": "Mobile Phones" } ] } ], "pageNo": 0, "pageSize": 10, "totalElements": 1, "totalPages": 1, "last": true } GET /api/products/{id} { "id": 1, "name": "Smartphone X", "description": "A high-end smartphone with an excellent camera.", "price": 999.99, "categories": [ { "id": 2, "name": "Electronics" }, { "id": 5, "name": "Mobile Phones" } ] } POST /api/products // Body { "name": "Smartwatch Z", "description": "A waterproof smartwatch with fitness tracking.", "price": 199.99, "categories": [2, 7] } // Response { "id": 2, "name": "Smartwatch Z", "description": "A waterproof smartwatch with fitness tracking.", "price": 199.99, "categories": [ { "id": 2, "name": "Electronics" }, { "id": 7, "name": "Wearables" } ] } PUT /api/products/{id} // Body { "name": "Smartwatch Z Pro", "description": "Upgraded smartwatch with longer battery life.", "price": 249.99, "categories": [2, 7] } // Response { "id": 2, "name": "Smartwatch Z Pro", "description": "Upgraded smartwatch with longer battery life.", "price": 249.99, "categories": [ { "id": 2, "name": "Electronics" }, { "id": 7, "name": "Wearables" } ] } DELETE /api/products/{id} { "message": "Product deleted successfully" } The database must be implemented using PostgreSQL. Database If you don't have a PostgreSQL database, install Docker on your computer and use the file docker-compose.local-dev.yaml to create a PostgreSQL server and a database. Add a file .env in the root of the project with the following content: # App PORT=3000 CLIENT_URL="http://localhost:5173" # DB Postgress POSTGRES_DB_NAME=products-api POSTGRES_DB_HOST=localhost POSTGRES_DB_PORT=5432 POSTGRES_DB_USERNAME=admin POSTGRES_DB_PASSWORD=admin Then run this command in the root of the project: docker compose -f docker-compose.local-dev.yaml up -d Start coding Project setup Install NestJS: npm i -g @nestjs/cli Create the project with this command: nest new products-api Select the package manager you want to use, I will use npm: Which package manager would you ❤️ to use? npm This command will create a folder products-api with a minimal NestJS project. Now we can install the dependencies: npm ci And run the project: npm run start The app will be up and running on port 3000. You can test the endpoints using Postman or the REST Client extension in VSCode I will use the REST Client extension: In the root of the project add a folder rest-client. Inside it add a file products.http and place this content: @base_url=http://localhost:3000 # Get all products GET {{base_url}} You will see something like this: Project directory structure Create the following folders inside src: core database products categories Project configuration Database Install the following packages: npm install @nestjs/typeorm typeorm pg @nestjs/config pg: Driver for communicating our NestJS app with the PostgreSQL database. typeorm: Object Relational Mapper (ORM) for TypeScript. @nestjs/typeorm: Package provided by NestJS to integrate TypeORM in our app. @nestjs/config: Package provided by NestJS to use environment variables. Create the following files inside the folder /database/ and paste the content: /entities/base.ts: import { CreateDateColumn, DeleteDateColumn, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; export abstract class BaseModel { @PrimaryGeneratedColumn() id: number; @CreateDa

Feb 16, 2025 - 06:22
 0
Building a REST API with NestJS 2025

Building a REST API with NestJS 2025

In this tutorial, I will show you how to develop a REST API using NestJS.

Requirements review

Now, suppose you are given the following database design:

Database diagram

You are required to create a REST API with the following endpoints:

Products:

Method Endpoint Description
GET /api/products Get paginated list of products
GET /api/products/{id} Get a specific product by ID
POST /api/products Create a new product
PUT /api/products/{id} Update an existing product
DELETE /api/products/{id} Delete a product

Categories:

Method Endpoint Description
GET /api/categories Get paginated list of categories
GET /api/categories/{id} Get a specific category by ID
POST /api/categories Create a new category
PUT /api/categories/{id} Update an existing category
DELETE /api/categories/{id} Delete a category

Example of responses:
GET /api/products

{
  "content": [
    {
      "id": 1,
      "name": "Smartphone X",
      "description": "A high-end smartphone with an excellent camera.",
      "price": 999.99,
      "categories": [
        {
          "id": 2,
          "name": "Electronics"
        },
        {
          "id": 5,
          "name": "Mobile Phones"
        }
      ]
    }
  ],
  "pageNo": 0,
  "pageSize": 10,
  "totalElements": 1,
  "totalPages": 1,
  "last": true
}

GET /api/products/{id}

{
  "id": 1,
  "name": "Smartphone X",
  "description": "A high-end smartphone with an excellent camera.",
  "price": 999.99,
  "categories": [
    {
      "id": 2,
      "name": "Electronics"
    },
    {
      "id": 5,
      "name": "Mobile Phones"
    }
  ]
}

POST /api/products

// Body
{
  "name": "Smartwatch Z",
  "description": "A waterproof smartwatch with fitness tracking.",
  "price": 199.99,
  "categories": [2, 7]
}

// Response
{
  "id": 2,
  "name": "Smartwatch Z",
  "description": "A waterproof smartwatch with fitness tracking.",
  "price": 199.99,
  "categories": [
    {
      "id": 2,
      "name": "Electronics"
    },
    {
      "id": 7,
      "name": "Wearables"
    }
  ]
}

PUT /api/products/{id}

// Body
{
  "name": "Smartwatch Z Pro",
  "description": "Upgraded smartwatch with longer battery life.",
  "price": 249.99,
  "categories": [2, 7]
}

// Response
{
  "id": 2,
  "name": "Smartwatch Z Pro",
  "description": "Upgraded smartwatch with longer battery life.",
  "price": 249.99,
  "categories": [
    {
      "id": 2,
      "name": "Electronics"
    },
    {
      "id": 7,
      "name": "Wearables"
    }
  ]
}

DELETE /api/products/{id}

{
  "message": "Product deleted successfully"
}

The database must be implemented using PostgreSQL.

Database

If you don't have a PostgreSQL database, install Docker on your computer and use the file docker-compose.local-dev.yaml
to create a PostgreSQL server and a database.

Add a file .env in the root of the project with the following content:

# App
PORT=3000
CLIENT_URL="http://localhost:5173"

# DB Postgress
POSTGRES_DB_NAME=products-api
POSTGRES_DB_HOST=localhost
POSTGRES_DB_PORT=5432
POSTGRES_DB_USERNAME=admin
POSTGRES_DB_PASSWORD=admin

Then run this command in the root of the project:

docker compose -f docker-compose.local-dev.yaml up -d

Start coding

Project setup

Install NestJS:

npm i -g @nestjs/cli

Create the project with this command:

nest new products-api

Select the package manager you want to use, I will use npm:

Which package manager would you ❤️ to use? npm

This command will create a folder products-api with a minimal NestJS project.

Now we can install the dependencies:

npm ci

And run the project:

npm run start

The app will be up and running on port 3000.

You can test the endpoints using Postman or the REST Client extension in VSCode

I will use the REST Client extension:

Image description

In the root of the project add a folder rest-client. Inside it add a file products.http and place this content:

@base_url=http://localhost:3000

# Get all products

GET {{base_url}}

You will see something like this:

Image description

Project directory structure

Create the following folders inside src:

  • core
  • database
  • products
  • categories

Project configuration

Database

Install the following packages:

npm install @nestjs/typeorm typeorm pg @nestjs/config
  • pg: Driver for communicating our NestJS app with the PostgreSQL database.
  • typeorm: Object Relational Mapper (ORM) for TypeScript.
  • @nestjs/typeorm: Package provided by NestJS to integrate TypeORM in our app.
  • @nestjs/config: Package provided by NestJS to use environment variables.

Create the following files inside the folder /database/ and paste the content:

/entities/base.ts:

import {
  CreateDateColumn,
  DeleteDateColumn,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

export abstract class BaseModel {
  @PrimaryGeneratedColumn()
  id: number;

  @CreateDateColumn({
    name: 'created_at',
    type: 'timestamptz',
    default: () => 'CURRENT_TIMESTAMP',
  })
  createdAt: Date;

  @UpdateDateColumn({
    name: 'updated_at',
    type: 'timestamptz',
    default: () => 'CURRENT_TIMESTAMP',
    onUpdate: 'CURRENT_TIMESTAMP',
  })
  updatedAt: Date;

  @DeleteDateColumn({
    name: 'deleted_at',
    type: 'timestamptz',
  })
  deletedAt: Date;
}

/entities/product.ts:

import { BaseModel } from '@src/database/entities/base';
import { Entity, Column, ManyToMany, JoinTable } from 'typeorm';
import { Category } from './category';

@Entity({ name: 'products' })
export class Product extends BaseModel {
  @Column()
  name: string;

  @Column()
  description: string;

  @Column('decimal', { precision: 10, scale: 2 })
  price: number;

  @ManyToMany(() => Category, (category) => category.products, {
    cascade: true,
  })
  @JoinTable({
    name: 'product_categories',
    joinColumn: { name: 'product_id', referencedColumnName: 'id' },
    inverseJoinColumn: { name: 'category_id', referencedColumnName: 'id' },
  })
  categories: Category[];
}

/entities/category.ts:

import { BaseModel } from '@src/database/entities/base';
import { Entity, Column, ManyToMany } from 'typeorm';
import { Product } from './product';

@Entity({ name: 'categories' })
export class Category extends BaseModel {
  @Column()
  name: string;

  @ManyToMany(() => Product, (product) => product.categories)
  products: Product[];
}

/providers/postgresql.provider.ts:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { Product } from '@src/database/entities/product';
import { Category } from '../entities/category';

@Injectable()
export class PostgresqlDdProvider implements TypeOrmOptionsFactory {
  constructor(private readonly configService: ConfigService) {}

  createTypeOrmOptions(): Promise<TypeOrmModuleOptions> | TypeOrmModuleOptions {
    return {
      type: 'postgres',
      host: this.configService.get<'string'>('POSTGRES_DB_HOST'),
      port: parseInt(
        this.configService.get<'string'>('POSTGRES_DB_PORT') ?? '5432',
      ),
      username: this.configService.get<'string'>('POSTGRES_DB_USERNAME'),
      password: this.configService.get<'string'>('POSTGRES_DB_PASSWORD'),
      database: this.configService.get<'string'>('POSTGRES_DB_NAME'),
      entities: [Product, Category],
      synchronize: true,
      logging: false,
    };
  }
}

/database.module.ts:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { PostgresqlDdProvider } from './providers/postgresql.provider';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      name: 'postgres', // Explicitly set the connection name for PostgreSQL
      useClass: PostgresqlDdProvider,
    }),
  ],
  exports: [TypeOrmModule],
})
export class DatabaseModule {}

Core

Delete files:

  • app.service.ts
  • app.controller.ts
  • app.controller.spec.ts

Install the following packages:

npm install helmet nestjs-pino @nestjs/throttler
npm install pino-pretty --save-dev
  • helmet: Middleware that enhances security by setting various HTTP headers.
  • nestjs-pino: Logging integration for NestJS that uses the pino logger. Pino is a fast and efficient logging library for Node.js.
  • @nestjs/throttler: NestJS module that provides rate-limiting functionality for your application.
  • pino-pretty: Formats Pino's structured JSON logs into a human-readable, colorized output for easier debugging in development.

Replace the content in main.ts with the following:

import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';

import { Logger as PinoLogger } from 'nestjs-pino';
import helmet from 'helmet';

import { AppModule } from './app.module';

import * as bodyParser from 'body-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    bufferLogs: true,
    bodyParser: true,
  });

  // Increase payload size limit
  app.use(bodyParser.json({ limit: '10mb' }));
  app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));

  app.useLogger(app.get(PinoLogger));

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  app.use(helmet());

  app.enableCors({
    origin: process.env.CLIENT_URL ?? '*',
    credentials: true,
  });

  await app.listen(process.env.PORT ?? 3000);

  const logger = new Logger('Bootstrap');

  logger.log(`App is running on ${await app.getUrl()}`);
}

bootstrap();

Replace the content in app.module.ts with the following:

import { Module } from '@nestjs/common';

import { CoreModule } from './core/core.module';
import { ProductsModule } from './products/products.module';
import { CategoriesModule } from './categories/categories.module';

@Module({
  imports: [CoreModule, ProductsModule, CategoriesModule],
})
export class AppModule {}

Create the following file inside the folder /core/ and paste the content:

/core/core.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';

import { LoggerModule } from 'nestjs-pino';

import { DatabaseModule } from '@src/database/database.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),

    ThrottlerModule.forRoot([
      {
        ttl: 60000, // Time-to-live in milliseconds
        limit: 60, // Maximum requests per window globally
      },
    ]),

    LoggerModule.forRoot({
      pinoHttp: {
        serializers: {
          req: () => undefined,
          res: () => undefined,
        },
        autoLogging: false,
        level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
        transport:
          process.env.NODE_ENV === 'production'
            ? undefined
            : {
                target: 'pino-pretty',
                options: {
                  messageKey: 'message',
                  colorize: true,
                },
              },
        messageKey: 'message',
      },
    }),

    DatabaseModule,
  ],
})
export class CoreModule {}

Products

Install the following packages:

npm install class-validator class-transformer
  • class-validator: Package that provides decorators and functions to validate the properties of classes, ensuring that the data meets specified rules. I will use it in DTOs.
  • class-transformer: Transforms plain JavaScript objects into class instances and vice versa, enabling serialization and deserialization in TypeScript.

Create the following files inside the folder /products/ and paste the content:

/dtos/create-product.dto.ts:

import { ArrayNotEmpty, IsArray, IsNumber, IsString } from 'class-validator';

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

  @IsString()
  description: string;

  @IsNumber()
  price: number;

  @IsArray()
  @ArrayNotEmpty()
  @IsNumber({}, { each: true })
  categories: number[];
}

/dtos/update-product.dto.ts:

import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';

export class UpdateProductDto {
  @IsOptional()
  @IsString()
  name?: string;

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

  @IsOptional()
  @IsNumber()
  price?: number;

  @IsOptional()
  @IsArray()
  @IsNumber({}, { each: true })
  categories?: number[];
}

/dtos/product-response.dto.ts:

export class ProductResponseDto {
  id: number;
  name: string;
  description: string;
  price: number;
  categories: CategoryResponseDto[];
}

export class CategoryResponseDto {
  id: number;
  name: string;
}

/products.mapper.ts:

import { Injectable } from '@nestjs/common';
import { Product } from '@src/database/entities/product';

import { ProductResponseDto } from './dtos/product-response.dto';

@Injectable()
export class ProductMapper {
  mapEntityToDto(product: Product): ProductResponseDto {
    return {
      id: product.id,
      name: product.name,
      description: product.description,
      price: product.price,
      categories:
        product.categories?.map((category) => ({
          id: category.id,
          name: category.name,
        })) || [],
    };
  }
}

/products.service.ts:

import {
  BadRequestException,
  HttpException,
  Injectable,
  InternalServerErrorException,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';

import { In, Repository } from 'typeorm';

import { Product } from '@src/database/entities/product';
import { CreateProductDto } from './dtos/create-product.dto';
import { UpdateProductDto } from './dtos/update-product.dto';
import { Category } from '@src/database/entities/category';
import { ProductMapper } from './products.mapper';

@Injectable()
export class ProductsService {
  private readonly logger = new Logger('ProductsService');

  constructor(
    @InjectRepository(Product, 'postgres')
    private readonly productRepository: Repository<Product>,
    @InjectRepository(Category, 'postgres')
    private readonly categoryRepository: Repository<Category>,
    private readonly productMapper: ProductMapper,
  ) {}

  async getAll(pageNo: number = 0, pageSize: number = 10) {
    try {
      // Ensure pageNo is a non-negative integer and pageSize is within reasonable limits
      if (pageNo < 0 || pageSize <= 0) {
        throw new BadRequestException('Invalid page number or page size');
      }

      // Calculate skip value based on page number and page size
      const skip = pageNo * pageSize;

      // Get products with pagination
      const [products, totalElements] =
        await this.productRepository.findAndCount({
          skip,
          take: pageSize,
          relations: ['categories'], // To load related categories for each product
        });

      // Calculate totalPages and whether it's the last page
      const totalPages = Math.ceil(totalElements / pageSize);
      const last = pageNo + 1 >= totalPages;

      // Map the result into the desired format
      return {
        content: products.map((product) =>
          this.productMapper.mapEntityToDto(product),
        ),
        pageNo,
        pageSize,
        totalElements,
        totalPages,
        last,
      };
    } catch (error) {
      this.handleError(error, 'An error occurred while getting products');
    }
  }

  async getById(id: number) {
    try {
      const product = await this.productRepository.findOne({
        where: { id },
        relations: ['categories'],
      });

      if (!product) {
        throw new NotFoundException('Product not found');
      }

      return this.productMapper.mapEntityToDto(product);
    } catch (error) {
      this.handleError(error, 'An error occurred while fetching product');
    }
  }

  async create(createProductDto: CreateProductDto) {
    try {
      // Convert category IDs to actual Category entities
      const categories = await this.categoryRepository.findBy({
        id: In(createProductDto.categories),
      });

      if (!categories.length) {
        throw new BadRequestException('Invalid category IDs');
      }

      // Create a new product with the categories attached
      const newProduct = this.productRepository.create({
        ...createProductDto,
        categories,
      });

      await this.productRepository.save(newProduct);
      return this.productMapper.mapEntityToDto(newProduct);
    } catch (error) {
      this.handleError(error, 'An error occurred while creating product');
    }
  }

  async update(id: number, updateProductDto: UpdateProductDto) {
    try {
      const { categories, ...updateProductDtoWithoutCategories } =
        updateProductDto;
      // Find the product by ID and preload with the updated values
      const product = await this.productRepository.preload({
        id,
        ...updateProductDtoWithoutCategories,
      });

      if (!product) {
        throw new NotFoundException('Product not found');
      }

      // If the update involves categories, convert category IDs to actual Category entities
      if (updateProductDto.categories) {
        const categories = await this.categoryRepository.findBy({
          id: In(updateProductDto.categories),
        });

        if (!categories.length) {
          throw new BadRequestException('Invalid category IDs');
        }

        product.categories = categories;
      }

      await this.productRepository.save(product);
      return this.productMapper.mapEntityToDto(product);
    } catch (error) {
      this.handleError(error, 'An error occurred while updating product');
    }
  }

  async delete(id: number) {
    try {
      const product = await this.productRepository.findOne({ where: { id } });

      if (!product) {
        throw new NotFoundException('Product not found');
      }

      await this.productRepository.remove(product);
      return { message: 'Product deleted successfully' };
    } catch (error) {
      this.handleError(error, 'An error occurred while deleting product');
    }
  }

  private handleError(error: unknown, defaultErrorMessage?: string) {
    // Log the error
    this.logger.error(error);

    // Handle known HTTP exceptions
    if (error instanceof HttpException) {
      throw error; // Preserve the original exception, don't modify it
    }

    // Handle unexpected errors
    if (error instanceof Error) {
      // Handle generic errors
      throw new InternalServerErrorException({
        message: defaultErrorMessage ?? 'An unexpected error occurred',
      });
    }

    // Default to a generic BadRequestException if error is unknown
    throw new BadRequestException({
      message: defaultErrorMessage ?? 'An error occurred in ProductsService',
    });
  }
}

/products.controller.ts:

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Post,
  Put,
  Query,
} from '@nestjs/common';

import { ProductsService } from './products.service';
import { CreateProductDto } from './dtos/create-product.dto';
import { UpdateProductDto } from './dtos/update-product.dto';

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

  @Get('/')
  getAll(
    @Query('pageNo', new ParseIntPipe({ errorHttpStatusCode: 400 }))
    pageNo: number = 0,
    @Query('pageSize', new ParseIntPipe({ errorHttpStatusCode: 400 }))
    pageSize: number = 10,
  ) {
    return this.productsService.getAll(pageNo, pageSize);
  }

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

  @Post('/')
  create(@Body() createProductDto: CreateProductDto) {
    return this.productsService.create(createProductDto);
  }

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

  @Delete('/:id')
  delete(@Param('id', ParseIntPipe) id: number) {
    return this.productsService.delete(id);
  }
}

/products.module.ts:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Category } from '@src/database/entities/category';
import { Product } from '@src/database/entities/product';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { ProductMapper } from './products.mapper';

@Module({
  imports: [TypeOrmModule.forFeature([Product, Category], 'postgres')],
  controllers: [ProductsController],
  providers: [ProductsService, ProductMapper],
  exports: [ProductsService],
})
export class ProductsModule {}

Categories

Create the following files inside the folder /categories/ and paste the content:

/dtos/create-category.dto.ts:

import { IsString } from 'class-validator';

export class CreateCategoryDto {
  @IsString()
  name: string;
}

/dtos/update-category.dto.ts:

import { IsString } from 'class-validator';

export class UpdateCategoryDto {
  @IsString()
  name: string;
}

/dtos/category-response.dto.ts:

export class CategoryResponseDto {
  id: number;
  name: string;
}

/categories.mapper.ts:

import { Injectable } from '@nestjs/common';
import { Category } from '@src/database/entities/category';

import { CategoryResponseDto } from './dtos/category-response.dto';

@Injectable()
export class CategoryMapper {
  mapEntityToDto(category: Category): CategoryResponseDto {
    return {
      id: category.id,
      name: category.name,
    };
  }
}

/categories.service.ts:

import {
  BadRequestException,
  HttpException,
  Injectable,
  InternalServerErrorException,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';

import { Repository } from 'typeorm';

import { Category } from '@src/database/entities/category';

import { CreateCategoryDto } from './dtos/create-category.dto';
import { UpdateCategoryDto } from './dtos/update-category.dto';
import { CategoryMapper } from './categories.mapper';

@Injectable()
export class CategoriesService {
  private readonly logger = new Logger('CategoriesService');

  constructor(
    @InjectRepository(Category, 'postgres')
    private readonly categoryRepository: Repository<Category>,
    private readonly categoryMapper: CategoryMapper,
  ) {}

  async getAll(pageNo: number = 0, pageSize: number = 10) {
    try {
      // Ensure pageNo is a non-negative integer and pageSize is within reasonable limits
      if (pageNo < 0 || pageSize <= 0) {
        throw new BadRequestException('Invalid page number or page size');
      }

      // Calculate skip value based on page number and page size
      const skip = pageNo * pageSize;

      // Get categories with pagination
      const [categories, totalElements] =
        await this.categoryRepository.findAndCount({
          skip,
          take: pageSize,
        });

      // Calculate totalPages and whether it's the last page
      const totalPages = Math.ceil(totalElements / pageSize);
      const last = pageNo + 1 >= totalPages;

      // Map the result into the desired format
      return {
        content: categories.map((category) =>
          this.categoryMapper.mapEntityToDto(category),
        ),
        pageNo,
        pageSize,
        totalElements,
        totalPages,
        last,
      };
    } catch (error) {
      this.handleError(error, 'An error occurred while getting categories');
    }
  }

  async getById(id: number) {
    try {
      const category = await this.categoryRepository.findOne({
        where: { id },
      });

      if (!category) {
        throw new NotFoundException('Category not found');
      }

      return this.categoryMapper.mapEntityToDto(category);
    } catch (error) {
      this.handleError(error, 'An error occurred while fetching category');
    }
  }

  async create(createCategoryDto: CreateCategoryDto) {
    try {
      // Create a new category
      const newCategory = this.categoryRepository.create(createCategoryDto);

      await this.categoryRepository.save(newCategory);
      return this.categoryMapper.mapEntityToDto(newCategory);
    } catch (error) {
      this.handleError(error, 'An error occurred while creating category');
    }
  }

  async update(id: number, updateCategoryDto: UpdateCategoryDto) {
    try {
      const category = await this.categoryRepository.findOne({
        where: { id },
      });

      if (!category) {
        throw new NotFoundException('Category not found');
      }

      category.name = updateCategoryDto.name;

      await this.categoryRepository.save(category);
      return this.categoryMapper.mapEntityToDto(category);
    } catch (error) {
      this.handleError(error, 'An error occurred while updating category');
    }
  }

  async delete(id: number) {
    try {
      const category = await this.categoryRepository.findOne({ where: { id } });

      if (!category) {
        throw new NotFoundException('Category not found');
      }

      await this.categoryRepository.remove(category);
      return { message: 'Category deleted successfully' };
    } catch (error) {
      this.handleError(error, 'An error occurred while deleting category');
    }
  }

  private handleError(error: unknown, defaultErrorMessage?: string) {
    // Log the error
    this.logger.error(error);

    // Handle known HTTP exceptions
    if (error instanceof HttpException) {
      throw error; // Preserve the original exception, don't modify it
    }

    // Handle unexpected errors
    if (error instanceof Error) {
      // Handle generic errors
      throw new InternalServerErrorException({
        message: defaultErrorMessage ?? 'An unexpected error occurred',
      });
    }

    // Default to a generic BadRequestException if error is unknown
    throw new BadRequestException({
      message: defaultErrorMessage ?? 'An error occurred in CategoriesService',
    });
  }
}

/categories.controller.ts:

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Post,
  Put,
  Query,
} from '@nestjs/common';

import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dtos/create-category.dto';
import { UpdateCategoryDto } from './dtos/update-category.dto';

@Controller('api/categories')
export class CategoriesController {
  constructor(private readonly categoriesService: CategoriesService) {}

  @Get('/')
  getAll(
    @Query('pageNo', new ParseIntPipe({ errorHttpStatusCode: 400 }))
    pageNo: number = 0,
    @Query('pageSize', new ParseIntPipe({ errorHttpStatusCode: 400 }))
    pageSize: number = 10,
  ) {
    return this.categoriesService.getAll(pageNo, pageSize);
  }

  @Get('/:id')
  getById(@Param('id', ParseIntPipe) id: number) {
    return this.categoriesService.getById(id);
  }

  @Post('/')
  create(@Body() createCategoryDto: CreateCategoryDto) {
    return this.categoriesService.create(createCategoryDto);
  }

  @Put('/:id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateCategoryDto: UpdateCategoryDto,
  ) {
    return this.categoriesService.update(id, updateCategoryDto);
  }

  @Delete('/:id')
  delete(@Param('id', ParseIntPipe) id: number) {
    return this.categoriesService.delete(id);
  }
}

/categories.module.ts:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Category } from '@src/database/entities/category';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
import { CategoryMapper } from './categories.mapper';

@Module({
  imports: [TypeOrmModule.forFeature([Category], 'postgres')],
  controllers: [CategoriesController],
  providers: [CategoriesService, CategoryMapper],
  exports: [CategoriesService],
})
export class CategoriesModule {}

Test the endpoints

Create the following files inside the folder /rest-client/ and paste the content:

/products.http:

@base_url=http://localhost:3000/api/products

### Get All Products
GET {{base_url}}?pageNo=0&pageSize=10

### Get Product by ID
GET {{base_url}}/1

### Create a New Product
POST {{base_url}}
Content-Type: application/json

{
  "name": "Smartphone X",
  "description": "A high-end smartphone with an excellent camera.",
  "price": 999.99,
  "categories": [1, 2]
}

### Update Product
PUT {{base_url}}/1
Content-Type: application/json

{
  "name": "Smartphone Y",
  "description": "An updated version of Smartphone X.",
  "price": 899.99,
  "categories": [2]
}

### Delete Product
DELETE {{base_url}}/1

/categories.http:

@base_url = http://localhost:3000/api/categories

### Get All Categories
GET {{base_url}}?pageNo=0&pageSize=10

### Get Category by ID
GET {{base_url}}/1

### Create a New Category
POST {{base_url}}
Content-Type: application/json

{
  "name": "Electronics"
}

### Update Category
PUT {{base_url}}/1
Content-Type: application/json

{
  "name": "Updated Electronics"
}

### Delete Category
DELETE {{base_url}}/1

Now run the project with the following command:

npm run start

Conclusion

That's it. Now, we have a basic REST API working in NestJS. You can download the source code on my GitHub.

Any ideas for a tutorial or suggestions to improve this project? Feel free to share them in the comments!

Thank you for reading.