Building a REST API
This is the payoff phase. For five phases you've been collecting parts: a controller that handles HTTP (Phase 2), a service that holds logic and gets injected (Phase 3), a module that wires them together (Phase 4), and DTOs with a ValidationPipe that guard the input (Phase 5). Now we snap them into one complete, working resource.
Here's the mental model for a REST resource in Nest, and it's the same shape for every resource you'll ever build:
A thin controller maps routes to method calls → a service holds the data and the logic → DTOs type the input → a module wires it all → and you throw HTTP exceptions for the error cases.
That's the whole thing. The controller decides nothing; it reads the request and delegates. The service is where the work lives. When something goes wrong — a task that doesn't exist, say — you don't hand-craft a 404 response; you throw a built-in exception and Nest renders it for you.
📝 You don't have to assemble this by hand every time.
nest g resource tasksscaffolds exactly this layout — controller, service, DTOs, module, and CRUD method stubs — in one command. We're building it manually here so you can see every piece and why it's there; once you understand it, let the generator do the typing.
The service: where the data and logic live
Start with the service, because it's the heart of the resource. It owns an in-memory store and the five CRUD operations. For the error cases, it throws Nest's built-in HTTP exceptions:
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
export interface Task {
id: number;
title: string;
done: boolean;
}
@Injectable()
export class TasksService {
private tasks: Task[] = [];
private nextId = 1;
findAll() {
return this.tasks;
}
findOne(id: number) {
const task = this.tasks.find((t) => t.id === id);
if (!task) throw new NotFoundException(`Task ${id} not found`); // → 404 automatically
return task;
}
create(dto: CreateTaskDto) {
const task = { id: this.nextId++, ...dto, done: dto.done ?? false };
this.tasks.push(task);
return task;
}
update(id: number, dto: UpdateTaskDto) {
const task = this.findOne(id); // reuses the 404 throw above
Object.assign(task, dto);
return task;
}
remove(id: number) {
const index = this.tasks.findIndex((t) => t.id === id);
if (index === -1) throw new NotFoundException(`Task ${id} not found`);
this.tasks.splice(index, 1);
}
}
What just happened: The service is pure logic with zero HTTP knowledge — no @Get, no request objects, exactly as Phase 3 promised. findOne is the piece to study: when the task isn't there, it throws a NotFoundException instead of returning null or fabricating a response object. That throw is doing real work — Nest catches it and turns it into a 404 Not Found with a clean JSON body, automatically. Notice update reuses findOne, so the "not found" rule lives in exactly one place. The nextId counter hands out simple ascending ids, and done: dto.done ?? false defaults the optional flag. This store is a stand-in for a real database — swap it for TypeORM or Prisma later (more on that below).
The controller: thin routing to the service
Now the controller. Its only job is to map each HTTP route to a service call, lean on the pipes and DTOs from Phase 5, and pick the right status code:
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasks: TasksService) {}
@Get()
findAll() {
return this.tasks.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.tasks.findOne(id);
}
@Post()
create(@Body() dto: CreateTaskDto) {
return this.tasks.create(dto); // 201 Created by default on POST
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateTaskDto) {
return this.tasks.update(id, dto);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseIntPipe) id: number) {
this.tasks.remove(id);
}
}
What just happened: Every handler is one line, because every decision was made elsewhere. @Param('id', ParseIntPipe) turns the URL string "42" into the number 42 (and 400s on garbage); @Body() dto: CreateTaskDto arrives already validated by the global ValidationPipe — so the controller trusts its inputs without a single if. Two status-code details matter: a @Post() returns 201 Created automatically (Nest knows POST means "made something new"), and @HttpCode(204) overrides the default on @Delete to return 204 No Content — the correct "done, nothing to send back" status. The constructor injection (private readonly tasks: TasksService) is the Phase 3 wiring; the controller never news the service.
The module: wiring it together
Neither class does anything until a module registers them. This is the Phase 4 piece:
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
What just happened: controllers registers the route handlers; providers registers TasksService so the DI container can build it and inject it into the controller. Leave TasksService out of providers and you'd hit that "Nest can't resolve dependencies of the TasksController" error from Phase 3. Import this TasksModule into your AppModule and the resource is live.
Driving it with curl
With the app running (npm run start:dev from Phase 1), here's the full resource in action. First, create a task:
HTTP/1.1 201 Created
{"id":1,"title":"Buy milk","done":false}
What just happened: The POST returned 201, not 200 — that's Nest's default for @Post, signalling a resource was created. The body came back with the server-assigned id and the defaulted done: false. The ValidationPipe checked {"title": "Buy milk"} against CreateTaskDto and let it through.
Now ask for a task that doesn't exist:
HTTP/1.1 404 Not Found
{"statusCode":404,"message":"Task 999 not found","error":"Not Found"}
What just happened: This is the throw new NotFoundException(...) from the service, rendered. You wrote one line — throw — and Nest produced the correct status code and a structured JSON error body with your message. You never touched a response object.
And here's what a bad request body looks like, courtesy of the Phase 5 ValidationPipe:
HTTP/1.1 400 Bad Request
{"statusCode":400,"message":["title should not be empty"],"error":"Bad Request"}
What just happened: The empty title violated @IsNotEmpty() on the DTO, so the pipe rejected the request with a 400 before create() ever ran — and the message names exactly which rule failed. The controller stayed blissfully ignorant; validation happened in the pipeline. Finally, curl -i -X DELETE http://localhost:3000/tasks/1 returns a bare 204 No Content with no body, exactly as @HttpCode(204) specified.
The two ideas to carry forward
💡 Built-in HTTP exceptions auto-map to the right status code and a JSON body — you throw, Nest renders.
NotFoundException→ 404,BadRequestException→ 400,ForbiddenException→ 403,UnauthorizedException→ 401,ConflictException→ 409, and more. Reach for the named exception that fits the situation and stop thinking about response objects; the framework handles the HTTP translation.
💡 The in-memory array is a database stand-in. It's perfect for learning and prototyping, but it forgets everything on restart and won't survive more than one server process. The beauty of the service layer is that swapping it out is a contained change: the controller, DTOs, and module don't move — you replace the array and the CRUD bodies inside
TasksServicewith real persistence. That's where an ORM comes in: see how an ORM works, then wire in TypeORM or Prisma.
You've now built a complete REST resource the way Nest intends: thin controller, logic-holding service, validated DTOs, a module that wires it, and exceptions that turn into proper HTTP responses. Every resource you build follows this exact template. In Phase 7 we'll wrap cross-cutting concerns — auth, logging, request transformation — around this pipeline without touching the resource itself.
Recap
- A REST resource is a thin controller (routes → calls) over a service (data + logic), with DTOs typing the input and a module wiring it — the same shape for every resource.
- The service owns the store and the CRUD methods, and throws built-in HTTP exceptions (like
NotFoundException) for error cases instead of crafting responses by hand. - The controller delegates in one-line handlers, using
@Param('id', ParseIntPipe)and validated@Body() dto, returning 201 on POST and 204 on DELETE (via@HttpCode(204)). - Built-in exceptions auto-map to status + JSON:
throwthe right one and Nest renders the response —NotFoundException→ 404,BadRequestException→ 400, and so on. nest g resource tasksscaffolds this entire layout in one command once you understand the pieces.- The in-memory array is a database stand-in; swapping it for TypeORM/Prisma (how an ORM works) is contained to the service, leaving the controller, DTOs, and module untouched.
Quick check
[
{
"q": "In the TasksService, what happens when findOne is called with an id that isn't in the store?",
"choices": ["It returns null and the controller must build a 404", "It throws NotFoundException, which Nest auto-maps to a 404 response", "It returns an empty array", "It throws a generic Error that becomes a 500"],
"answer": 1,
"explain": "The service throws NotFoundException; Nest catches built-in HTTP exceptions and renders the correct status code (404) plus a JSON error body automatically."
},
{
"q": "Why is the @HttpCode(204) decorator added to the remove() handler?",
"choices": ["To make DELETE return 200 with the deleted task", "To override the default and return 204 No Content, the right status for a successful delete with no body", "To validate the id parameter", "To register the route in the module"],
"answer": 1,
"explain": "DELETE that succeeds with nothing to return should respond 204 No Content. @HttpCode(204) overrides Nest's default 200 for that handler."
},
{
"q": "What stays the same when you later replace the in-memory array with a real database via an ORM?",
"choices": ["Nothing — every layer must be rewritten", "The controller, DTOs, and module; only the service's store and CRUD bodies change", "Only the module changes", "The DTOs must be converted to interfaces"],
"answer": 1,
"explain": "The service layer contains the change: the controller, DTOs, and module don't move. You swap the array and the CRUD method bodies inside TasksService for real persistence."
}
]
← Phase 5: DTOs, Validation & Pipes · Guide overview · Phase 7: Guards, Interceptors & Middleware →
Check your understanding
1. In the TasksService, what happens when findOne is called with an id that isn't in the store?
2. Why is the @HttpCode(204) decorator added to the remove() handler?
3. What stays the same when you later replace the in-memory array with a real database via an ORM?