The sun was barely up over Playa Venao, Panama, when my phone buzzed.
Luis—one of the junior devs I’d mentored back in Medellín—was frantic: “James, our Rest API is returning 500s. The NestJS controller looks fine, but something’s off. Can you jump on a call?” I was still dripping seawater after an early surf session, board tucked under my arm, but I remembered being in his shoes: staring at mysterious JSON errors while the Caribbean heat melted any remaining patience. So I hustled to a beach-side café, ordered the strongest café con leche they had, and opened VS Code. By the second sip, we’d traced the issue to a sloppy controller that mashed validation, business logic, and database calls into a single, tangled function. Today’s post distills that salty debugging session into a blueprint you can reuse—before your next wave sets roll in.
Foundations: Why NestJS Loves REST
NestJS wraps Express (or Fastify) in TypeScript and decorators, letting us write controllers that feel more like TypeScript classes than spaghetti routes. A disciplined Rest API in NestJS gives you:
- Predictable endpoints for every front-end in the company (or continent).
- First-class TypeScript types that double as living documentation.
- Middleware hooks for authentication, logging, and rate-limiting—all without reinventing the wheel.
Key idea: keep controllers thin—or risk the same 3 a.m. alerts Luis faced in Medellín.
The Case Study: How a 200-Line Controller Became 20
While consulting for a rideshare startup in Mexico City, I inherited a controller that managed users, trips, and payments—all in one file. Latency yawed past 600 ms, and every deploy felt like spinning a roulette wheel. We broke the monolith controller into three resources, introduced DTO validation, and delegated hairy business rules to services. Average response time dropped to 110 ms. Investors stopped asking awkward questions, and I finally had time to explore Coyoacán’s mercados without Slack anxiety.
Step-by-Step Implementation in NestJS
1 · Bootstrapping the Project
bashCopyEditnpm i -g @nestjs/cli
nest new library-api
cd library-api
nest g resource books --no-spec --crud --path src
The resource
schematic scaffolds a module, controller, service, and DTOs—perfect scaffolding for a clean Rest API.
2 · Defining a DTO
typescriptCopyEdit// src/books/dto/create-book.dto.ts
import { IsString, IsInt, Min, Max } from 'class-validator';
export class CreateBookDto {
@IsString() readonly title: string;
@IsString() readonly author: string;
@IsInt() @Min(1500) @Max(new Date().getFullYear())
readonly year: number;
}
Why? Validation belongs near the boundary, so invalid data never reaches your service layer.
3 · Writing the Controller
typescriptCopyEdit// src/books/books.controller.ts
import {
Controller, Get, Post, Param, Body, Put, Delete, HttpCode, HttpStatus,
} from '@nestjs/common';
import { BooksService } from './books.service';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
@Controller('books')
export class BooksController {
constructor(private readonly booksService: BooksService) {}
@Get()
findAll() {
return this.booksService.findAll(); // returns Promise<Book[]>
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.booksService.findOne(id); // 404 handled in service
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateBookDto) {
return this.booksService.create(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateBookDto) {
return this.booksService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string) {
return this.booksService.remove(id);
}
}
Notice the controller only orchestrates, delegating heavy lifting to BooksService
. That keeps our Rest API lean—and unit-test friendly.
⚡ Mini-exercise: Add pagination to
findAll()
by injectingQuery
parameters fortake
andskip
.
4 · Service Layer
typescriptCopyEdit@Injectable()
export class BooksService {
constructor(@InjectModel(Book.name) private readonly model: Model<BookDocument>) {}
async findAll() { return this.model.find().exec(); }
async findOne(id: string) {
const book = await this.model.findById(id).exec();
if (!book) throw new NotFoundException(`Book ${id} not found`);
return book;
}
async create(dto: CreateBookDto) { return this.model.create(dto); }
async update(id: string, dto: UpdateBookDto) {
const updated = await this.model.findByIdAndUpdate(id, dto, { new: true });
if (!updated) throw new NotFoundException(`Book ${id} not found`);
return updated;
}
async remove(id: string) {
const result = await this.model.findByIdAndDelete(id);
if (!result) throw new NotFoundException(`Book ${id} not found`);
}
}
Troubles Spained in Bogotá: Common Pitfalls
Symptom | Root Cause | Remedy |
---|---|---|
502 Bad Gateway spikes | Blocking sync work in controller | Mark CPU-heavy logic async ; offload to queues |
DTO validation ignored | Pipes not enabled globally | In main.ts , app.useGlobalPipes(new ValidationPipe()) |
Database leaks on error | Missing try/catch in service | Wrap calls, throw HttpException |
N+1 query pain | Multiple populate() inside loops | Use aggregation or batch loaders |
“Ask me how I learned this the hard way in Bangkok…”—I once forgot to enable global pipes, and invalid JSON crashed a production Rest API while I was trapped on a tuk-tuk in rush-hour rain.
Performance and Security: Keep It Tight
pgsqlCopyEditBrowser ──> Nginx ──> NestJS Cluster ──> MongoDB Replica
▲ │
└─────────── Prometheus <──────────┘
- Clustering: Use
@nestjs/terminus
plus Node’scluster
to run workers per CPU core. - Rate-limiting:
@nestjs/throttler
shields your endpoints. - Helmet integration:
app.use(helmet())
for secure headers. - Caching: Decorate heavy GETs with
@CacheTTL()
and@CacheKey()
—backed by Redis. - Observability: OpenTelemetry exporter feeds traces to Grafana Tempo, making 2 a.m. debugging far less terrifying.
⚡ Mini-exercise: Instrument the
BooksService.findAll()
method with a custom OpenTelemetry span and visualize it in Jaeger.
CI/CD: From GitHub to AWS Without Tears
yamlCopyEdit# .github/workflows/ci.yml
name: node-ci
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm test -- --runInBand
- run: npm run build
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/your-org/library-api:latest
In production I deploy this container to AWS Fargate behind an Application Load Balancer with HTTPS certificates via AWS ACM. Terraform modules wire up the task definition, CloudWatch log group, and secret-manager variables.
For the Curious — Beyond REST
Sidebar: GraphQL Federation in NestJS
When your Rest API grows into microservices, consider Apollo Federation with
@nestjs/graphql
. Each service exposes a partial schema; a gateway stitches them into a single API. Tracing and caching still apply—but schema-first thinking changes how you model resources.
Vocabulary Table
Term | Meaning |
---|---|
Rest API | Resource-oriented interface over HTTP verbs |
DTO | Data Transfer Object: validated payload schema |
Pipe | NestJS class that transforms/validates input |
Provider | Injectable class (service, repository, etc.) |
Interceptor | Wrapper for cross-cutting concerns (logging, caching) |
Module | Logical container for providers/controllers |
Guard | Authorization layer for routes |
Middleware | Function that runs before controllers |
Wrapping Up on a Rio Rooftop
Right now I’m finishing this draft from a rooftop in Ipanema. The Atlantic hums below, and somewhere on my laptop a Jest suite is green. That’s the real payoff of a disciplined Rest API: freedom to chase sunsets while your endpoints hum along. You learned how thin NestJS controllers point to services, how pipes keep bad data out, and why observability saves your sleep. Next post we’ll version these controllers and explore feature flags—because change is the one constant when you’re coding from country to country.
Questions? War stories? Drop a comment below, and I’ll answer while the coffee’s still hot.