This guide will walk you through the process of adding new API endpoints to the NestJS starter kit, following best practices and established patterns.
- Project Structure
- Creating a New Feature Module
- Step-by-Step Guide
- Logging
- Validation
- Error Handling
- Testing
- Example Implementation
The project follows a modular structure:
src/
├── common/ # Shared utilities, decorators, guards, etc.
├── config/ # Configuration management
├── database/ # Database configuration and migrations
├── health/ # Health check endpoints
└── [feature]/ # Feature modules
├── dto/ # Data Transfer Objects
├── entities/ # TypeORM entities
├── [feature].controller.ts
├── [feature].service.ts
├── [feature].module.ts
└── tests/ # Feature-specific tests
All examples in this guide use npm run, but you can substitute with your
preferred package manager:
| Command Type | npm | bun | pnpm | yarn |
|---|---|---|---|---|
| Run Script | npm run <script> |
bun run <script> |
pnpm run <script> |
yarn <script> |
| Install Package | npm install <pkg> |
bun add <pkg> |
pnpm add <pkg> |
yarn add <pkg> |
| Dev Install | npm install -D <pkg> |
bun add -d <pkg> |
pnpm add -D <pkg> |
yarn add -D <pkg> |
| Generate | npm run nest g <type> <name> |
bun run nest g <type> <name> |
pnpm run nest g <type> <name> |
yarn nest g <type> <name> |
# Create directories
mkdir -p src/users/dto
mkdir -p src/users/entities
mkdir -p src/users/tests
# OR use NestJS CLI to generate the module structure
npm run nest g module users # npm
# bun run nest g module users # bun
# pnpm run nest g module users # pnpm
# yarn nest g module users # yarn
npm run nest g controller users # npm
# bun run nest g controller users # bun
# pnpm run nest g controller users # pnpm
# yarn nest g controller users # yarn
npm run nest g service users # npm
# bun run nest g service users # bun
# pnpm run nest g service users # pnpm
# yarn nest g service users # yarn// src/users/entities/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
firstName: string;
@Column()
lastName: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}// src/users/dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(2)
firstName: string;
@IsString()
@IsNotEmpty()
@MinLength(2)
lastName: string;
}// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}// src/users/users.service.ts
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
this.logger.log(`Creating new user with email: ${createUserDto.email}`);
try {
const user = this.userRepository.create(createUserDto);
const savedUser = await this.userRepository.save(user);
this.logger.log(`User created successfully with ID: ${savedUser.id}`);
return savedUser;
} catch (error) {
this.logger.error(`Failed to create user: ${error.message}`, error.stack);
throw error;
}
}
async findAll(): Promise<User[]> {
this.logger.log('Fetching all users');
return this.userRepository.find();
}
async findOne(id: string): Promise<User> {
this.logger.log(`Fetching user with ID: ${id}`);
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
this.logger.warn(`User not found with ID: ${id}`);
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
this.logger.log(`Updating user with ID: ${id}`);
const user = await this.findOne(id);
const updatedUser = await this.userRepository.save({
...user,
...updateUserDto,
});
this.logger.log(`User updated successfully with ID: ${id}`);
return updatedUser;
}
async remove(id: string): Promise<void> {
this.logger.log(`Removing user with ID: ${id}`);
const user = await this.findOne(id);
await this.userRepository.remove(user);
this.logger.log(`User removed successfully with ID: ${id}`);
}
}// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
HttpCode,
HttpStatus,
Logger,
ParseUUIDPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller({ path: 'users', version: '1' })
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(private readonly usersService: UsersService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() createUserDto: CreateUserDto) {
this.logger.log(
`POST /users - Creating user with email: ${createUserDto.email}`,
);
return this.usersService.create(createUserDto);
}
@Get()
async findAll() {
this.logger.log('GET /users - Fetching all users');
return this.usersService.findAll();
}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string) {
this.logger.log(`GET /users/${id} - Fetching user`);
return this.usersService.findOne(id);
}
@Patch(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateUserDto: UpdateUserDto,
) {
this.logger.log(`PATCH /users/${id} - Updating user`);
return this.usersService.update(id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string) {
this.logger.log(`DELETE /users/${id} - Removing user`);
await this.usersService.remove(id);
}
}// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // Export if other modules need to use this service
})
export class UsersModule {}// src/app.module.ts
import { Module } from '@nestjs/common';
// ... other imports
import { UsersModule } from './users/users.module';
@Module({
imports: [
// ... other modules
UsersModule,
],
// ...
})
export class AppModule {}The starter kit supports multiple logging levels and formats:
error- Only error messageswarn- Error and warning messageslog- Error, warning, and general log messagesdebug- All above plus debug informationverbose- All log levels including verbose output
Set logging level via environment variables:
LOG_LEVEL=debug
LOG_FORMAT=json # or 'pretty' for developmentimport { Logger } from '@nestjs/common';
@Injectable()
export class YourService {
private readonly logger = new Logger(YourService.name);
someMethod() {
this.logger.log('General information');
this.logger.debug('Debug information');
this.logger.warn('Warning message');
this.logger.error('Error occurred', error.stack);
this.logger.verbose('Detailed verbose information');
}
}The LoggingInterceptor automatically logs:
- Incoming requests with method, URL, IP, and user agent
- Request body, query parameters, and route parameters
- Response status codes and response times
- Error details for failed requests
The starter kit uses class-validator for automatic validation:
import {
IsString,
IsEmail,
IsNumber,
IsOptional,
IsNotEmpty,
MinLength,
MaxLength,
IsUUID,
IsDate,
IsEnum,
ValidateNested,
Type,
} from 'class-validator';
export class ExampleDto {
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(50)
name: string;
@IsEmail()
email: string;
@IsNumber()
@IsOptional()
age?: number;
@IsUUID()
userId: string;
@IsDate()
@Type(() => Date)
birthDate: Date;
@IsEnum(['admin', 'user'])
role: string;
}// src/common/pipes/parse-date.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
transform(value: string): Date {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new BadRequestException('Invalid date format');
}
return date;
}
}The GlobalExceptionFilter automatically handles errors and returns consistent
error responses:
{
"statusCode": 400,
"message": "Validation failed",
"error": "Bad Request",
"timestamp": "2024-01-15T10:30:00.000Z",
"path": "/api/v1/users",
"method": "POST",
"errors": [
{
"field": "email",
"message": "email must be an email",
"value": "invalid-email"
}
]
}import { HttpException, HttpStatus } from '@nestjs/common';
export class UserAlreadyExistsException extends HttpException {
constructor(email: string) {
super(
{
statusCode: HttpStatus.CONFLICT,
message: `User with email ${email} already exists`,
error: 'UserAlreadyExists',
},
HttpStatus.CONFLICT,
);
}
}Run tests with your package manager:
npm test # npm - run unit tests
# bun test # bun - run unit tests
# pnpm test # pnpm - run unit tests
# yarn test # yarn - run unit tests
npm run test:watch # npm - run in watch mode
# bun test --watch # bun - run in watch mode
# pnpm run test:watch # pnpm - run in watch mode
# yarn test:watch # yarn - run in watch mode// src/users/tests/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsersService } from '../users.service';
import { User } from '../entities/user.entity';
describe('UsersService', () => {
let service: UsersService;
let repository: Repository<User>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
repository = module.get<Repository<User>>(getRepositoryToken(User));
});
describe('create', () => {
it('should create a user', async () => {
const createUserDto = {
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
};
const user = { id: '123', ...createUserDto } as User;
jest.spyOn(repository, 'create').mockReturnValue(user);
jest.spyOn(repository, 'save').mockResolvedValue(user);
const result = await service.create(createUserDto);
expect(repository.create).toHaveBeenCalledWith(createUserDto);
expect(repository.save).toHaveBeenCalledWith(user);
expect(result).toEqual(user);
});
});
});Run E2E tests with your package manager:
npm run test:e2e # npm - run E2E tests
# bun run test:e2e # bun - run E2E tests
# pnpm run test:e2e # pnpm - run E2E tests
# yarn test:e2e # yarn - run E2E tests
npm run test:e2e:cov # npm - run E2E tests with coverage
# bun run test:e2e:cov # bun - run E2E tests with coverage
# pnpm run test:e2e:cov # pnpm - run E2E tests with coverage
# yarn test:e2e:cov # yarn - run E2E tests with coverage// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Users (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/api/v1/users (POST)', () => {
return request(app.getHttpServer())
.post('/api/v1/users')
.send({
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
})
.expect(201);
});
afterAll(async () => {
await app.close();
});
});Here's a complete example of a simple Posts feature:
// src/posts/entities/post.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column('text')
content: string;
@Column({ default: false })
published: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}// src/posts/dto/create-post.dto.ts
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
export class CreatePostDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
content: string;
@IsBoolean()
@IsOptional()
published?: boolean = false;
}Follow the same pattern as the Users example above, adapting the field names and business logic for posts.
- Use descriptive logging - Log important business operations with context
- Follow naming conventions - Use plural for controllers
(
UsersController) - Validate all inputs - Use DTOs with validation decorators
- Handle errors gracefully - Use appropriate HTTP status codes
- Write tests - Both unit and integration tests
- Use TypeScript strictly - Enable strict mode and use proper typing
- Document your APIs - Consider adding Swagger decorators
- Use database transactions - For complex operations that involve multiple entities
All endpoints automatically inherit rate limiting from the global
ThrottlerGuard. You can customize limits per endpoint:
import { Throttle } from '@nestjs/throttler';
@Throttle({ default: { limit: 10, ttl: 60000 } }) // 10 requests per minute
@Post()
async create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}# Generate complete module structure
npm run nest g resource users --no-spec # npm
# bun run nest g resource users --no-spec # bun
# pnpm run nest g resource users --no-spec # pnpm
# yarn nest g resource users --no-spec # yarn
# This creates: module, controller, service, entity, and DTOs# Start development server
npm run start:dev # npm
# bun run start:dev # bun
# pnpm run start:dev # pnpm
# yarn start:dev # yarn
# Run linting
npm run lint # npm
# bun run lint # bun
# pnpm run lint # pnpm
# yarn lint # yarn
# Run tests
npm run test:all # npm - run all tests
# bun run test:all # bun - run all tests
# pnpm run test:all # pnpm - run all tests
# yarn test:all # yarn - run all tests# Generate migration after creating entities
npm run typeorm:migration:generate -- --name CreateUser # npm
# bun run typeorm:migration:generate -- --name CreateUser # bun
# pnpm run typeorm:migration:generate -- --name CreateUser # pnpm
# yarn typeorm:migration:generate --name CreateUser # yarn
# Run migrations
npm run typeorm:migration:run # npm
# bun run typeorm:migration:run # bun
# pnpm run typeorm:migration:run # pnpm
# yarn typeorm:migration:run # yarn- Create your entity and DTOs
- Implement your service with proper logging
- Create your controller with validation
- Write tests for your endpoints
- Register your module in
AppModule - Test your endpoints using the health endpoint as a reference
For more examples, check the existing health module in the codebase.