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 8.6 — When (and When Not) to Use GraphQL in Real Systems
GraphQL is powerful—but expensive. This post helps you decide, based on real constraints, whether GraphQL is worth adopting or whether REST will serve you better.
Part 8.5 — Backend Architecture: Controllers vs “Resolvers” in NestJS
If you squint a little, a NestJS controller method already is a resolver. This post shows how to embrace that idea without introducing GraphQL complexity.
Part 8.4 — Client-Driven Data with REST: Avoiding Over-Fetching
Over-fetching is a client problem as much as a server problem. This post shows how React apps can drive data needs intentionally—without turning REST APIs into guesswork.
Comments