This article is available in Indonesian

šŸ‡®šŸ‡© Baca dalam Bahasa Indonesia

May 10, 2026

šŸ‡¬šŸ‡§ English

Fleet Management Part 3: Backend API with NestJS

Build a scalable backend API with NestJS and TypeScript. Modules, controllers, services, DTOs, dependency injection, and clean architecture patterns.

7 min read

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?

  1. Testing — You can inject a fake repository for testing
  2. Flexibility — Switch from MySQL to PostgreSQL without changing the service
  3. 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.

Continue Reading

Previous article

← Previous Article

Fleet Management Part 2: Building the Real-Time Dashboard with Next.js

Next Article →

Fleet Management Part 4: Admin Panel & Business Logic with Laravel

Next article