Skip to content

Part 3.2 — Building REST Controllers, Services & DTO Validation

Site Console Site Console
5 min read Updated Jan 20, 2026 Backend Development 0 comments

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 users

Now you have:

src/users/
  users.controller.ts
  users.service.ts
  users.module.ts

This 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 id is a number

  • DTOs 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 posts

DTOs

// 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 /users

  • POST /users

  • PATCH /users/:id

  • DELETE /users/:id

  • GET /posts

  • POST /posts

  • etc.

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 take and skip

  • Filtering & 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

Leave a comment

Sign in to leave a comment.

Comments