What is NestJS?
If you know Express.js, you know it's basically a blank canvas ā you organize everything yourself. NestJS is Express.js with opinions and structure. It tells you where to put your code, how to organize modules, and provides built-in patterns like Dependency Injection.
Think of it this way:
- Express.js = A pile of LEGO bricks. You can build anything, but there's no instruction manual.
- NestJS = A LEGO Technic set. You still have creative freedom, but there's a proven structure to follow.
For enterprise applications with 50+ endpoints and 4-6 developers, structure isn't optional ā it's essential.
Project Setup
npm i -g @nestjs/cli
nest new fleet-api
cd fleet-api
NestJS generates a clean project structure. Let's understand each piece:
fleet-api/
āāā src/
ā āāā app.module.ts # Root module ā ties everything together
ā āāā app.controller.ts # Root controller (we'll delete this)
ā āāā app.service.ts # Root service (we'll delete this)
ā āāā main.ts # Entry point ā starts the server
āāā test/ # E2E tests
āāā nest-cli.json # NestJS CLI configuration
āāā tsconfig.json # TypeScript configuration
Understanding the Module System
NestJS organizes code into modules. Each module is a self-contained feature. Here's the analogy:
A NestJS app is like a company:
āāā Company (AppModule) ā The whole organization
ā āāā HR Department (AuthModule) ā Handles employees
ā āāā Fleet Department (FleetModule) ā Manages vehicles
ā āāā Driver Department (DriverModule) ā Manages drivers
ā āāā IT Department (SharedModule) ā Shared utilities
Each department (module) has:
- Controller = The receptionist. Receives requests and delegates work.
- Service = The workers. Contains the actual business logic.
- Repository = The filing cabinet. Handles database operations.
Building the Fleet Module
Let's build our fleet management API step by step.
Step 1: Generate the Module
nest generate module fleet
nest generate controller fleet
nest generate service fleet
Step 2: Define the Entity
// src/fleet/entities/vehicle.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export enum VehicleStatus {
ACTIVE = 'active',
IDLE = 'idle',
MAINTENANCE = 'maintenance',
OFFLINE = 'offline',
}
@Entity('vehicles')
export class Vehicle {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20, unique: true })
plateNumber: string;
@Column({ length: 100 })
model: string;
@Column({ type: 'int' })
year: number;
@Column({ type: 'enum', enum: VehicleStatus, default: VehicleStatus.OFFLINE })
status: VehicleStatus;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 100 })
fuelLevel: number;
@Column({ nullable: true })
currentDriverId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
Step 3: Create the DTO (Data Transfer Object)
What is a DTO? It's a simple object that defines what data your API accepts. Think of it as a form ā if someone fills in the wrong fields, the form rejects it before you even process it.
// src/fleet/dto/create-vehicle.dto.ts
import { IsString, IsInt, IsEnum, IsOptional, Length, Min, Max } from 'class-validator';
import { VehicleStatus } from '../entities/vehicle.entity';
export class CreateVehicleDto {
@IsString()
@Length(3, 20)
plateNumber: string;
@IsString()
@Length(2, 100)
model: string;
@IsInt()
@Min(2000)
@Max(2030)
year: number;
@IsEnum(VehicleStatus)
@IsOptional()
status?: VehicleStatus;
}
// src/fleet/dto/update-vehicle.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateVehicleDto } from './create-vehicle.dto';
// PartialType makes all fields optional ā perfect for PATCH updates
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
Why DTOs matter for seniors: Without DTOs, any data can reach your service layer. Imagine someone sends { "plateNumber": "", "year": -5 }. With DTOs and class-validator, that request is rejected immediately with a clear error message ā before it ever touches your database.
Step 4: Build the Service
// src/fleet/fleet.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
@Injectable()
export class FleetService {
constructor(
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
) {}
async findAll(): Promise<Vehicle[]> {
return this.vehicleRepo.find({
order: { plateNumber: 'ASC' },
});
}
async findOne(id: string): Promise<Vehicle> {
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
if (!vehicle) {
throw new NotFoundException(`Vehicle with ID "${id}" not found`);
}
return vehicle;
}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
// Check for duplicate plate number
const existing = await this.vehicleRepo.findOne({
where: { plateNumber: dto.plateNumber },
});
if (existing) {
throw new ConflictException(`Vehicle with plate "${dto.plateNumber}" already exists`);
}
const vehicle = this.vehicleRepo.create(dto);
return this.vehicleRepo.save(vehicle);
}
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
const vehicle = await this.findOne(id); // Reuse findOne ā throws if not found
Object.assign(vehicle, dto);
return this.vehicleRepo.save(vehicle);
}
async remove(id: string): Promise<void> {
const vehicle = await this.findOne(id);
await this.vehicleRepo.remove(vehicle);
}
}
Step 5: Build the Controller
// src/fleet/fleet.controller.ts
import {
Controller, Get, Post, Patch, Delete,
Param, Body, ParseUUIDPipe, HttpCode, HttpStatus,
} from '@nestjs/common';
import { FleetService } from './fleet.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
@Controller('api/vehicles')
export class FleetController {
constructor(private readonly fleetService: FleetService) {}
@Get()
findAll() {
return this.fleetService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetService.findOne(id);
}
@Post()
create(@Body() dto: CreateVehicleDto) {
return this.fleetService.create(dto);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateVehicleDto,
) {
return this.fleetService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetService.remove(id);
}
}
Understanding Dependency Injection
This is the concept that confuses most beginners. Let me explain it simply.
Without Dependency Injection:
// ā Bad ā the service creates its own dependencies
class FleetService {
private repo = new VehicleRepository(); // Hardcoded!
private logger = new Logger(); // Hardcoded!
}
With Dependency Injection:
// ā
Good ā dependencies are injected from outside
@Injectable()
class FleetService {
constructor(
private readonly repo: VehicleRepository, // Injected!
private readonly logger: Logger, // Injected!
) {}
}
Why does this matter?
- Testing ā You can inject a fake repository for testing
- Flexibility ā Switch from MySQL to PostgreSQL without changing the service
- Separation ā Each class focuses on one job
Think of it like a restaurant. The chef (Service) doesn't go to the farm to get ingredients. The ingredients (Dependencies) are delivered to the kitchen. This makes the chef more efficient and allows you to switch ingredient suppliers without retraining the chef.
Error Handling: The Professional Way
NestJS has a built-in exception filter system. Here's how to create custom exceptions:
// src/common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
Senior Tip: Never expose internal error details to the client. Log the full error server-side, but send a generic message to the client. "Database connection failed at MySQL:3306" is a security risk. "Service temporarily unavailable" is safe.
Common Mistakes
Mistake 1: Business Logic in Controllers
// ā Bad ā controller doing too much
@Post()
async create(@Body() dto: CreateVehicleDto) {
const existing = await this.repo.findOne({ where: { plate: dto.plate } });
if (existing) throw new ConflictException();
const vehicle = this.repo.create(dto);
await this.emailService.notify('New vehicle added');
return this.repo.save(vehicle);
}
// ā
Good ā controller delegates to service
@Post()
async create(@Body() dto: CreateVehicleDto) {
return this.fleetService.create(dto);
}
Mistake 2: Not Using Pipes for Validation
// ā Bad ā manual validation
@Get(':id')
findOne(@Param('id') id: string) {
if (!isUUID(id)) throw new BadRequestException('Invalid UUID');
return this.service.findOne(id);
}
// ā
Good ā use built-in pipes
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findOne(id);
}
What's Next
In Part 4, we'll build the Laravel Admin Panel ā a powerful administration interface for managing drivers, invoices, and schedules using Laravel and Filament. We'll explore why sometimes PHP is the better choice over Node.js.
This is Part 3 of the Fleet Management System series. We're building clean, structured backend APIs the way senior developers do it ā with proper architecture, not spaghetti code.
