Building Your First App
Reading about controllers, services, and dependency injection only gets you so far — the concepts click the moment you wire them together into something that actually responds to HTTP requests. In this guide you’ll build a small but complete CRUD API for managing “cats,” touching every layer of a real NestJS application: a controller that maps routes, a service that owns the logic, a DTO that shapes and validates input, and a feature module that binds it all into the DI container. By the end you’ll have a running endpoint you can hit with curl and a mental model of how a request flows through Nest.
Scaffold the building blocks
Start from a fresh project (nest new cats-api) or generate the pieces inside an existing one. The CLI can stamp out a whole feature in a single command using the resource schematic, but to understand each part we’ll generate them individually and write the bodies ourselves.
nest generate module cats
nest generate controller cats --no-spec
nest generate service cats --no-spec
Each command creates a file under src/cats/ and, crucially, wires it into the right place: the controller and service are registered in cats.module.ts, and the module is imported into the root AppModule. That automatic wiring is why you should prefer the CLI over hand-creating files.
Tip: Run
nest generate resource catsto scaffold the module, controller, service, DTOs, and entity all at once, then choose “REST API” and let Nest generate working CRUD stubs you can edit.
Define a DTO
A Data Transfer Object describes the shape of data crossing your API boundary. Pairing it with class-validator decorators turns the DTO into a runtime contract: requests that don’t match are rejected before they reach your logic.
// src/cats/dto/create-cat.dto.ts
import { IsInt, IsString, Min } from 'class-validator';
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
@Min(0)
age: number;
@IsString()
breed: string;
}
For validation to fire, enable a global ValidationPipe in main.ts. The whitelist option strips any properties not declared on the DTO, protecting you from over-posting.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Write the service
The service holds the business logic and state. Here we keep an in-memory array so the example runs without a database — in a real app this is where you’d inject a repository. Marking the class @Injectable() lets Nest manage its lifecycle and hand it to anyone who asks for it.
// src/cats/cats.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
export interface Cat {
id: number;
name: string;
age: number;
breed: string;
}
@Injectable()
export class CatsService {
private cats: Cat[] = [];
private nextId = 1;
findAll(): Cat[] {
return this.cats;
}
findOne(id: number): Cat {
const cat = this.cats.find((c) => c.id === id);
if (!cat) {
throw new NotFoundException(`Cat ${id} not found`);
}
return cat;
}
create(dto: CreateCatDto): Cat {
const cat: Cat = { id: this.nextId++, ...dto };
this.cats.push(cat);
return cat;
}
remove(id: number): void {
const index = this.cats.findIndex((c) => c.id === id);
if (index === -1) {
throw new NotFoundException(`Cat ${id} not found`);
}
this.cats.splice(index, 1);
}
}
Throwing NotFoundException is all you need to return a proper 404 — Nest’s built-in exception filter converts it into the right HTTP response automatically.
Write the controller
The controller is the HTTP layer. Route decorators (@Get, @Post, @Delete) map methods to verbs and paths, while parameter decorators (@Body, @Param) extract data from the request. Notice the controller never instantiates CatsService — it declares the dependency in the constructor and the DI container injects the singleton.
// src/cats/cats.controller.ts
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
ParseIntPipe,
HttpCode,
} from '@nestjs/common';
import { CatsService, Cat } from './cats.service';
import { CreateCatDto } from './dto/create-cat.dto';
@Controller('cats')
export class CatsController {
constructor(private readonly catsService: CatsService) {}
@Get()
findAll(): Cat[] {
return this.catsService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Cat {
return this.catsService.findOne(id);
}
@Post()
create(@Body() dto: CreateCatDto): Cat {
return this.catsService.create(dto);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseIntPipe) id: number): void {
this.catsService.remove(id);
}
}
ParseIntPipe transforms the :id route segment from a string into a number (and 400s on garbage input), and @HttpCode(204) overrides the default 201 for the delete route. Returning a plain object or array is enough — Nest serializes it to JSON for you.
Register the module
The CLI already added the controller and service to cats.module.ts, but it’s worth seeing what binds them together. The module is the unit Nest uses to assemble the DI graph.
// src/cats/cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}
Make sure CatsModule is imported by the root AppModule so Nest knows about it. The nest generate module command does this automatically.
Run and test it
Start the dev server with hot reload, then exercise each route from another terminal.
npm run start:dev
# Create a cat
curl -s -X POST http://localhost:3000/cats \
-H 'Content-Type: application/json' \
-d '{"name":"Milo","age":3,"breed":"Tabby"}'
# List all cats
curl -s http://localhost:3000/cats
# Fetch one
curl -s http://localhost:3000/cats/1
# Delete it (returns 204, no body)
curl -s -i -X DELETE http://localhost:3000/cats/1
Output:
{"id":1,"name":"Milo","age":3,"breed":"Tabby"}
[{"id":1,"name":"Milo","age":3,"breed":"Tabby"}]
{"id":1,"name":"Milo","age":3,"breed":"Tabby"}
HTTP/1.1 204 No Content
Sending an invalid body proves the DTO and pipe are working:
curl -s -X POST http://localhost:3000/cats \
-H 'Content-Type: application/json' \
-d '{"name":"Milo","age":"three","breed":"Tabby"}'
Output:
{"message":["age must be an integer number"],"error":"Bad Request","statusCode":400}
That single round trip exercises the entire stack: the validation pipe checked the body, the controller mapped the route and injected the service, the service threw a typed exception, and Nest’s filter turned everything into a clean JSON response.
Best Practices
- Generate features with the CLI (
nest generate ...orresource) so files are named consistently and wired into modules automatically. - Keep controllers thin — they should parse the request and delegate; all logic belongs in the injectable service.
- Define a DTO for every write endpoint and validate it with a global
ValidationPipeusingwhitelist: true. - Use built-in pipes like
ParseIntPipeto convert and validate route params instead of casting manually. - Throw Nest’s HTTP exceptions (
NotFoundException,BadRequestException) rather than crafting responses by hand. - Return plain objects and arrays from handlers and let Nest handle JSON serialization and status codes.
- Group a feature’s controller, service, DTOs, and module in one folder so the domain stays self-contained.