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.
- A
TasksModulegenerated with the Nest CLI and registered inAppModule. - A
Taskmodel with aTaskStatusenum (OPEN, IN_PROGRESS, DONE). CreateTaskDto,UpdateTaskDtoandFilterTasksDtoclasses validated with class-validator.- A
TasksServicethat stores tasks in memory and throwsNotFoundExceptionfor missing ids. - A
TasksControllerexposingPOST,GET,GET /:id,PATCH /:idandDELETE /:idroutes. - A global
ValidationPipethat 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.tsEverything 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-apicd task-manager-apinpm install class-validator class-transformer @nestjs/mapped-typesnest g module tasksnest g controller tasks --no-specnest g service tasks --no-specimport { 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.
export enum TaskStatus { OPEN = 'OPEN', IN_PROGRESS = 'IN_PROGRESS', DONE = 'DONE',}
export interface Task { id: number; title: string; description: string; status: TaskStatus; createdAt: Date;}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;}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) {}import { IsEnum, IsOptional, IsString } from 'class-validator';import { TaskStatus } from '../task.model.js';
// Validates the query string of GET /tasks?status=OPEN&search=reportexport 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.
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.
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.
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:
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
Click Run to see what this code prints.
Extend This Project
- Add a
dueDatefield validated with@IsDateString()and aGET /tasks/overdueroute — remember to declare it above@Get(':id'). - Add pagination to
FilterTasksDtowithpageandlimitfields 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
TasksServicethat check the 404 path, as shown in lesson 13. - Add Swagger documentation with
@nestjs/swaggerso 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.