Part 3.2 — Building REST Controllers, Services & DTO Validation
A backend becomes useful only when it exposes predictable, validated REST endpoints.
NestJS gives you structure; Prisma gives you typed database access; DTOs keep the data correct.
In this post, we’ll build the foundational modules for Users and Posts, complete with:
REST controllers
Service layer with Prisma
DTO validation using class-validator
Proper error handling (to be expanded in Part 3.4)
Production-grade structure
By the end, your backend will expose working CRUD endpoints aligned with the React clients you built in Part 2.
1. Why Controllers + Services Matter
A good REST backend avoids mixing responsibilities:
Controller: handles route definitions & request/response boundaries
Service: handles business logic & Prisma queries
DTOs: validate input and define contracts
Prisma model: defines the database layer
Keeping these separate makes your app maintainable as it grows.
2. Generate the Users Module
Inside backend/:
pnpm nest g module users
pnpm nest g controller users
pnpm nest g service usersNow you have:
src/users/
users.controller.ts
users.service.ts
users.module.tsThis is your first resource.
3. Create DTOs (the Backbone of Validation)
Inside src/users/dto/:
CreateUserDto
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
export class CreateUserDto {
@IsNotEmpty()
name!: string;
@IsEmail()
email!: string;
}UpdateUserDto
import { IsEmail, IsOptional, MinLength } from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@MinLength(1)
name?: string;
@IsOptional()
@IsEmail()
email?: string;
}These rules mirror what your frontend already had — now enforced on the server.
4. Implement Users Service (Prisma-Powered Logic)
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
create(dto: CreateUserDto) {
return this.prisma.user.create({ data: dto });
}
findAll() {
return this.prisma.user.findMany({
include: { posts: true },
});
}
findOne(id: number) {
return this.prisma.user.findUnique({
where: { id },
include: { posts: true },
});
}
update(id: number, dto: UpdateUserDto) {
return this.prisma.user.update({
where: { id },
data: dto,
});
}
remove(id: number) {
return this.prisma.user.delete({
where: { id },
});
}
}Clean, typed, and directly tied to your database.
5. Implement Users Controller (REST Endpoints)
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private service: UsersService) {}
@Post()
create(@Body() dto: CreateUserDto) {
return this.service.create(dto);
}
@Get()
findAll() {
return this.service.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateUserDto,
) {
return this.service.update(id, dto);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.service.remove(id);
}
}Key notes:
ParseIntPipe ensures
idis a numberDTOs validate the body
The service does the actual work
This is 100% REST, no GraphQL
6. Add Validation Globally (Important!)
Inside main.ts:
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
}
bootstrap();Now:
unknown fields are removed
invalid DTOs produce clean 400 errors
primitive values auto-cast (e.g.,
"1"→1)
This protects your backend from malformed input.
7. Build the Posts Module (Faster This Time)
pnpm nest g module posts
pnpm nest g controller posts
pnpm nest g service postsDTOs
// create-post.dto.ts
import { IsNotEmpty } from 'class-validator';
export class CreatePostDto {
@IsNotEmpty()
title!: string;
@IsNotEmpty()
body!: string;
authorId!: number;
}// update-post.dto.ts
import { IsOptional } from 'class-validator';
export class UpdatePostDto {
@IsOptional()
title?: string;
@IsOptional()
body?: string;
}Service
@Injectable()
export class PostsService {
constructor(private prisma: PrismaService) {}
create(dto: CreatePostDto) {
return this.prisma.post.create({ data: dto });
}
findAll() {
return this.prisma.post.findMany({ include: { author: true } });
}
findOne(id: number) {
return this.prisma.post.findUnique({
where: { id },
include: { author: true },
});
}
update(id: number, dto: UpdatePostDto) {
return this.prisma.post.update({ where: { id }, data: dto });
}
remove(id: number) {
return this.prisma.post.delete({ where: { id } });
}
}Controller
@Controller('posts')
export class PostsController {
constructor(private service: PostsService) {}
@Post()
create(@Body() dto: CreatePostDto) {
return this.service.create(dto);
}
@Get()
findAll() {
return this.service.findAll();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePostDto,
) {
return this.service.update(id, dto);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.service.remove(id);
}
}Your backend now fully supports REST CRUD for users and posts.
8. Test Endpoints Manually (Quick Verification)
You can use:
Thunder Client
Postman
cURL
VSCode REST Client
Example:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice", "email":"alice@example.com"}'You should receive a fully typed user object.
9. Frontend Integration Is Now Possible
Your React frontend from Part 2 can now call:
GET
/usersPOST
/usersPATCH
/users/:idDELETE
/users/:idGET
/postsPOST
/postsetc.
We’ll formalize this integration in Part 3.5.
10. Coming Next: Deeper NestJS Internals
In Part 3.3, you’ll explore:
Prisma relations & joins
Pagination using
takeandskipFiltering & ordering
N+1 avoidance
Query performance awareness
Relation modeling techniques
This is where your backend goes beyond “CRUD” and starts looking ready for production traffic.
Related
Part 4.6 — Building a Maintainable Testing Architecture
Writing tests is easy. Maintaining hundreds of them is hard. This post shows how to structure your NestJS backend tests so they scale without pain.
Part 4.4 — Testing Auth Guards, JWT Strategy & Protected Routes
Authentication logic is critical—and fragile if untested. In this post, you’ll learn how to test JWT auth guards and protected REST routes with confidence.
Part 4.3 — Integration Testing with Supertest: REST Endpoints
Integration tests verify the entire request pipeline—controllers, DTOs, pipes, filters, interceptors, and database behavior. This post shows how to test your REST API like a professional.
Comments