LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
NestJSBeginner~2 hours

Task Manager REST API

Build a complete CRUD REST API for tasks with a feature module, a service, validated DTOs and query filtering.

ModulesControllersServicesDTO Validation

Overview

A task manager API is the classic first NestJS project because it touches every one of Nest's core building blocks without any distractions: a feature module groups the code, a controller maps HTTP routes, a service holds the business logic, and DTOs validate everything a client sends. Data is kept in memory for now, so you can focus entirely on how Nest wires these pieces together through dependency injection.

By the end you will have a working API that can create, list, filter, update and delete tasks, rejects invalid input with clear 400 errors, and returns proper 404s for tasks that do not exist — the same shape as the backend behind any real to-do or project-tracking app.

What You'll Build
  • A TasksModule generated with the Nest CLI and registered in AppModule.
  • A Task model with a TaskStatus enum (OPEN, IN_PROGRESS, DONE).
  • CreateTaskDto, UpdateTaskDto and FilterTasksDto classes validated with class-validator.
  • A TasksService that stores tasks in memory and throws NotFoundException for missing ids.
  • A TasksController exposing POST, GET, GET /:id, PATCH /:id and DELETE /:id routes.
  • A global ValidationPipe that strips unknown fields and converts query/route values to the right types.

Prerequisites

  • Node.js 20 or newer and the Nest CLI (npm install -g @nestjs/cli) — lesson 2.
  • Modules, controllers and providers — lessons 3 to 5.
  • DTOs and the ValidationPipe — lesson 6.
  • Built-in HTTP exceptions such as NotFoundException — lesson 8.
  • An API client such as Postman, Insomnia or curl for testing endpoints.

Project Structure

task-manager-api/
└── src/
├── tasks/
│ ├── dto/
│ │ ├── create-task.dto.ts
│ │ ├── filter-tasks.dto.ts
│ │ └── update-task.dto.ts
│ ├── task.model.ts
│ ├── tasks.controller.ts
│ ├── tasks.module.ts
│ └── tasks.service.ts
├── app.module.ts
└── main.ts

Everything about tasks lives in the tasks/ feature folder. The controller only reads requests and calls the service; the service is the single place that owns the task list and enforces rules such as "a missing id means 404". AppModule simply imports TasksModule, and main.ts applies app-wide settings.

Step 1: Create the Project & Tasks Module

Generate a new project, install the validation libraries, and let the CLI create the module, controller and service — it also registers TasksModule in AppModule for you.

nest new task-manager-api
cd task-manager-api
npm install class-validator class-transformer @nestjs/mapped-types
nest g module tasks
nest g controller tasks --no-spec
nest g service tasks --no-spec
src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller.js';
import { TasksService } from './tasks.service.js';
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}

Step 2: Define the Task Model & DTOs

The Task interface describes what the service stores. The DTOs are classes (not interfaces) because the ValidationPipe needs real runtime classes to read the class-validator decorators from. UpdateTaskDto reuses every rule from CreateTaskDto but makes each field optional via PartialType.

src/tasks/task.model.ts
export enum TaskStatus {
OPEN = 'OPEN',
IN_PROGRESS = 'IN_PROGRESS',
DONE = 'DONE',
}
export interface Task {
id: number;
title: string;
description: string;
status: TaskStatus;
createdAt: Date;
}
src/tasks/dto/create-task.dto.ts
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { TaskStatus } from '../task.model.js';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
title: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
}
src/tasks/dto/update-task.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateTaskDto } from './create-task.dto.js';
// Every field from CreateTaskDto, with the same rules, but all optional.
export class UpdateTaskDto extends PartialType(CreateTaskDto) {}
src/tasks/dto/filter-tasks.dto.ts
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { TaskStatus } from '../task.model.js';
// Validates the query string of GET /tasks?status=OPEN&search=report
export class FilterTasksDto {
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsString()
search?: string;
}

Step 3: Build the TasksService

The service owns a private array and a counter for ids. findOne() is the single place that throws NotFoundException, and update()/remove() call it first, so every "task not found" response in the API comes from one line of code.

src/tasks/tasks.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto.js';
import { FilterTasksDto } from './dto/filter-tasks.dto.js';
import { UpdateTaskDto } from './dto/update-task.dto.js';
import { Task, TaskStatus } from './task.model.js';
@Injectable()
export class TasksService {
private tasks: Task[] = [];
private nextId = 1;
create(dto: CreateTaskDto): Task {
const task: Task = {
id: this.nextId++,
title: dto.title,
description: dto.description ?? '',
status: dto.status ?? TaskStatus.OPEN,
createdAt: new Date(),
};
this.tasks.push(task);
return task;
}
findAll(filter: FilterTasksDto): Task[] {
let result = this.tasks;
if (filter.status) {
result = result.filter((t) => t.status === filter.status);
}
if (filter.search) {
const term = filter.search.toLowerCase();
result = result.filter(
(t) => t.title.toLowerCase().includes(term) || t.description.toLowerCase().includes(term),
);
}
return result;
}
findOne(id: number): Task {
const task = this.tasks.find((t) => t.id === id);
if (!task) {
throw new NotFoundException(`Task ${id} not found`);
}
return task;
}
update(id: number, dto: UpdateTaskDto): Task {
const task = this.findOne(id);
Object.assign(task, dto);
return task;
}
remove(id: number): void {
this.findOne(id); // throws 404 if missing
this.tasks = this.tasks.filter((t) => t.id !== id);
}
}

Step 4: Build the TasksController

The controller is deliberately thin: each handler reads one piece of the request (@Body, @Query or @Param) and hands it to the service. ParseIntPipe turns the :id string into a number, and @HttpCode(204) makes DELETE return "No Content" instead of the default 200.

src/tasks/tasks.controller.ts
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Query,
} from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto.js';
import { FilterTasksDto } from './dto/filter-tasks.dto.js';
import { UpdateTaskDto } from './dto/update-task.dto.js';
import { TasksService } from './tasks.service.js';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Post()
create(@Body() dto: CreateTaskDto) {
return this.tasksService.create(dto);
}
@Get()
findAll(@Query() filter: FilterTasksDto) {
return this.tasksService.findAll(filter);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.tasksService.findOne(id);
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateTaskDto) {
return this.tasksService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number) {
this.tasksService.remove(id);
}
}

Step 5: Enable Global Validation

Without a ValidationPipe, the decorators on your DTOs do nothing. Registering it globally in main.ts protects every route at once: whitelist strips fields the DTO does not declare, forbidNonWhitelisted turns those fields into a 400 error instead, and transform converts plain JSON into real DTO instances.

src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();

Complete Code

Steps 1–5 above contain every file in full. The only file left is the root module, which the CLI already updated. If you deleted the starter AppController/AppService, it looks like this:

src/app.module.ts
import { Module } from '@nestjs/common';
import { TasksModule } from './tasks/tasks.module.js';
@Module({
imports: [TasksModule],
})
export class AppModule {}

Start the server in watch mode with npm run start:dev and the API is available at http://localhost:3000/tasks.

Sample Run

Testing with curl

Click Run to see what this code prints.

Extend This Project

  • Add a dueDate field validated with @IsDateString() and a GET /tasks/overdue route — remember to declare it above @Get(':id').
  • Add pagination to FilterTasksDto with page and limit fields using @Type(() => Number) and @Min(1).
  • Replace the in-memory array with a real database using TypeORM, exactly as in lesson 12 — the controller will not need to change.
  • Write unit tests for TasksService that check the 404 path, as shown in lesson 13.
  • Add Swagger documentation with @nestjs/swagger so other developers can try the API in a browser.

Summary

You built a complete, validated CRUD API using a feature module, a thin controller and a service that owns all the business rules. Because the controller only ever talks to TasksService, you can later swap the in-memory array for a database without touching a single route — which is exactly the next project.