One hand business logo

Documentation

NestJS avec Prisma

Lire la documentation

Guide technique

Building Backend Modules with the Project Approach

Table of Contents


1. Introduction

This guide is for developers joining the project who need to understand how to write a backend module correctly.

The goal is not only to show code. The goal is to explain:

  • how modules are designed;
  • why responsibilities are separated;
  • how to reuse shared building blocks;
  • how to create a new feature without breaking the existing architecture;
  • how to keep controllers, services, DTOs, validations, mappers, and modules consistent.

The final hands-on tutorial builds seven modules:

  • authors;
  • books;
  • videos;
  • book-views;
  • video-views;
  • book-comments;
  • video-comments.

The main rules are:

  • the database is MySQL;
  • the local environment can be run with Docker and Docker Compose;
  • each table has a complete CRUD;
  • every module is responsible for its own domain;
  • a module that needs another entity must call the corresponding service instead of directly accessing another Prisma model;
  • services must not return raw database entities when a DTO is expected;
  • mappers transform entities into DTOs before the response is returned.

2. What Was Analyzed

Backend archive

Main shared files observed:

  • @1hand/base.type.ts;
  • @1hand/base.validator.ts;
  • @1hand/utils.ts;
  • @1hand/pipes/JoiValidatorPipe.ts;
  • @1hand/decorators/current-user.decorator.ts;
  • shared upload modules;
  • shared PDF modules.

Module academic-levels

Observed files:

  • academic-levels.types.ts;
  • academic-levels.validation.ts;
  • academic-levels.controller.ts;
  • academic-levels.service.ts;
  • academic-levels.module.ts.

Module academic-years

Observed files:

  • academic-years.types.ts;
  • academic-years.validation.ts;
  • academic-years.controller.ts;
  • academic-years.service.ts;
  • academic-years.module.ts.

What this reveals immediately

Even from a small sample, the approach is clear:

  1. HTTP inputs are described with DTOs.
  2. Inputs are validated with Joi.
  3. Routes are defined in controllers.
  4. Business logic and Prisma access live in services.
  5. The module file wires dependencies.
  6. Shared helpers prevent repeated logic across modules.

3. Backend Approach Overview

The backend follows a modular NestJS approach.

A good module answers a simple chain of questions.

Step 1: What data does the module accept?

The answer is in *.types.ts.

Examples:

  • CreateAcademicLevelDto;
  • UpdateAcademicYearDto;
  • FilterAcademicYearDto.

These classes describe the expected data shape.

Step 2: What rules apply to the inputs?

The answer is in *.validation.ts.

The project uses Joi to validate payloads and query parameters.

Step 3: Which HTTP route triggers which action?

The answer is in *.controller.ts.

The controller receives the request, applies validation pipes, then delegates to the service.

Step 4: Where does the real business behavior live?

The answer is in *.service.ts.

This is where the module:

  • queries Prisma;
  • checks related entities;
  • throws business errors;
  • applies soft delete;
  • manages transactions;
  • prepares the final response.

Step 5: How does NestJS know about this module?

The answer is in *.module.ts.

This file wires:

  • controllers;
  • providers;
  • imported modules;
  • exported services.

Core idea

A module is not “a service with a few routes around it”.

A module is an autonomous feature with a predictable structure and clear file responsibilities.


4. Shared Foundations

Before creating a new module, a developer must understand the shared foundations already available in the backend.

They exist to keep the project consistent.

4.1 Why these foundations exist

When several developers work on the same backend, the main risk is not only bugs. The main risk is divergence.

Divergence means:

  • one module validates with Joi, another does not;
  • one module soft deletes, another hard deletes;
  • one module returns paginated results consistently, another does not;
  • one module accesses another entity directly through Prisma, while another correctly calls the external service;
  • one module uses translated errors, another hardcodes messages.

Shared foundations exist to avoid these inconsistencies.

They standardize:

  • data shapes;
  • validation rules;
  • naming conventions;
  • utility helpers;
  • shared technical behaviors;
  • generated codes;
  • date handling;
  • password hashing;
  • slugs;
  • reusable types.

The right reflex is:

before coding a new feature, first check whether a shared building block already exists.


4.2 DTOs and shared base types

The project describes reusable structures in shared DTOs instead of duplicating the same object shape in many modules.

Example:

export class PhoneNumberDto {
  dialCode: string;
  iso2: string;
  nationalNumber: string;
  internationalNumber: string;
}

This approach makes the code easier to document, validate, and reuse.

If a data structure is used by several modules, it should not be rewritten locally each time.

Good candidates for shared DTOs include:

  • phone numbers;
  • addresses;
  • pagination metadata;
  • uploaded file metadata;
  • audit objects;
  • current-user structures.

Example:

export class AddressDto {
  street: string;
  city: string;
  postalCode: string;
  country: string;
}

A beginner often duplicates first and refactors later. In a team project, this quickly becomes expensive.

Ask yourself:

  • is this structure local to my module?
  • or is it shared and reusable?

4.3 Input validation with Joi

The project uses Joi validation schemas in *.validation.ts.

A module should usually define at least:

  • a create schema;
  • an update schema;
  • a filter/listing schema.

Example:

export const CreateAuthorSchema = Joi.object({
  firstName: Joi.string().trim().required(),
  lastName: Joi.string().trim().required(),
  email: Joi.string().email().required(),
});

export const UpdateAuthorSchema = Joi.object({
  firstName: Joi.string().trim().optional(),
  lastName: Joi.string().trim().optional(),
  email: Joi.string().email().optional(),
});

export const FilterAuthorSchema = Joi.object({
  search: Joi.string().optional(),
  page: Joi.number().min(1).optional(),
  limit: Joi.number().min(1).max(100).optional(),
});

The controller applies validation through JoiValidationPipe:

@UsePipes(new JoiValidationPipe(CreateAuthorSchema))
create(@Body() dto: CreateAuthorDto) {
  return this.service.create(dto);
}

The controller does not mix validation and business logic.

The DTO, Joi schema, route, and service method must tell the same story.


4.4 Utility helpers

Shared helpers prevent duplicated low-level logic.

Examples of useful shared helpers:

  • formatDateOnly(date);
  • truncateDate(date);
  • parseDateAsLocalMidnight(input);
  • hashPassword;
  • generateMatricule;
  • getSlug;
  • extractUserProfile.

Date helpers

Dates are a frequent source of bugs:

  • time zones;
  • local midnight;
  • inconsistent formatting;
  • wrong comparisons.

Instead of repeating manual date manipulation in every service, use shared helpers.

Bad practice:

const date = new Date(input);
date.setHours(0, 0, 0, 0);

Better practice:

const normalizedDate = parseDateAsLocalMidnight(input);

hashPassword

Password hashing must be centralized.

This keeps:

  • the number of rounds in one place;
  • behavior consistent across modules;
  • future changes easier.

generateMatricule

This helper generates a business code.

Example:

const code = generateMatricule("BOOK");

When the project already has a business-code convention, new modules must follow it.

getSlug

If a module needs readable slugs, use the shared slug helper instead of inventing a local one.


4.5 Decorators and NestJS conventions

The project can expose decorators such as CurrentUser.

Example:

export const CurrentUser = createParamDecorator(
  (field: string | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;

    if (!field) {
      return user;
    }

    return user?.[field];
  },
);

This avoids repeating low-level request access in every controller.

Example usage:

create(@Body() dto: CreatePostDto, @CurrentUser('id') userId: string) {
  return this.service.create(dto, userId);
}

When a technical or ergonomic pattern appears repeatedly, consider:

  • a decorator;
  • a pipe;
  • a guard;
  • a helper;
  • a shared service.

4.6 Shared technical services

A shared technical capability deserves its own module.

Examples:

  • upload service;
  • PDF generation service;
  • file storage service;
  • notification service;
  • email service.

A technical module encapsulates complex implementation details behind a simple service API.

Business modules should consume the service instead of duplicating the implementation.


4.7 How a new developer should rely on these foundations

Before adding a feature, check:

  1. Does a shared type already exist?
  2. Does a reusable Joi schema already exist?
  3. Does a utility helper already cover part of the need?
  4. Does a technical service already exist?
  5. Is the new module purely business logic or does it depend on a shared technical module?

The ideal result:

  • business modules contain only domain behavior;
  • shared helpers contain cross-cutting logic;
  • technical services encapsulate reusable behaviors;
  • module-to-module collaboration happens through services.

5. Detailed Documentation of the Analyzed Modules

5.1 Module academic-levels

Observed structure

academic-levels.types.ts
academic-levels.validation.ts
academic-levels.controller.ts
academic-levels.service.ts
academic-levels.module.ts

What this module manages

The module manages an academicLevel entity with:

  • creation;
  • update;
  • deletion;
  • single-item lookup;
  • filtered listing.

Types

The module defines DTOs such as:

  • CreateAcademicLevelDto;
  • UpdateAcademicLevelDto;
  • FilterAcademicLevelDto.

This shows the classic DTO trio:

  • one DTO for creation;
  • one DTO for update;
  • one DTO for listing filters.

Validation

The module defines Joi schemas such as:

  • CreateAcademicLevelSchema;
  • UpdateAcademicLevelSchema;
  • FilterAcademicLevelSchema.

This is exactly the pattern to reproduce in a simple feature.

Controller and service conventions

The guide standardizes CRUD method names as follows:

Controller methods:

create(dto);
selectMany(filter);
selectById(id);
update(id, body);
remove(id);

Service methods:

create(dto);
selectMany(filter);
selectById(id);
update(id, body);
remove(id);

If older modules use different names, new modules should follow the standardized project convention used in this guide.


5.2 Module academic-years

Observed structure

academic-years.types.ts
academic-years.validation.ts
academic-years.controller.ts
academic-years.service.ts
academic-years.module.ts

What this module manages

The module is richer than a basic CRUD.

It handles:

  • creation;
  • update;
  • soft deletion;
  • paginated listing;
  • lookup;
  • current academic year retrieval;
  • activation logic.

What this reveals

A module can expose:

  1. standard CRUD endpoints;
  2. business-specific endpoints.

A service is not only a repository. It also carries business rules.

Important behaviors

The service can:

  • check related entities;
  • translate business errors;
  • apply soft delete;
  • return paginated listings;
  • use Prisma transactions when several writes must be consistent.

5.3 What these modules teach about architecture

Together, the modules show that:

  • simple modules start with a clear CRUD;
  • mature modules add business actions;
  • consistency checks live in services;
  • validation stays separate;
  • pagination must be explicit;
  • external relations must be checked;
  • route, DTO, Joi schema, and service method must stay aligned.

6. Module Construction Logic

6.1 The complete request-processing chain

When an HTTP request arrives, a well-built module usually follows this chain:

  1. The controller receives the request.
  2. Joi validates the input through a pipe.
  3. The controller extracts useful parameters.
  4. The service executes business checks.
  5. The service calls Prisma.
  6. The service transforms entities when needed.
  7. The controller returns the response.

Example:

Client calls POST /books
CreateBookSchema validates the body
BooksController.create() forwards the DTO
BooksService.create() checks the author
Prisma creates the book
toBookDto(entity) transforms the entity
The API returns a clean DTO

6.2 Exact responsibility of each file

*.types.ts

Defines DTOs and contracts.

It must not contain business logic.

*.validation.ts

Contains Joi schemas.

It must not contain database access.

*.mapper.ts

Transforms database entities into DTOs.

It must not query Prisma.

*.controller.ts

Maps HTTP routes to service methods.

It must stay thin.

*.service.ts

Contains business behavior.

It can:

  • verify entities;
  • decide whether an action is allowed;
  • run transactions;
  • call Prisma;
  • call other services;
  • map entities to DTOs.

*.module.ts

Wires dependencies.

If one service depends on another, the module must import the module that exports the needed service.


6.3 Module independence rule

Each module is responsible for its own entity.

For example:

  • AuthorsService manipulates authors;
  • BooksService manipulates books;
  • BookCommentsService manipulates book comments.

What not to do

If books needs to verify an author, it should not directly query prisma.author if the authors module owns that logic.

Bad:

await this.prisma.author.findFirst(...);

Better:

await this.authorsService.selectById(authorId);

Why this matters

It guarantees:

  • one source of truth for entity rules;
  • less duplication;
  • easier testing;
  • better module boundaries;
  • easier maintenance.

6.4 Frequent mistakes to avoid

Mistake 1: mixing validation and business logic

Structural validation belongs in Joi schemas and pipes.

Business checks belong in services.

Mistake 2: writing an omnipotent service

A service should not directly manage all foreign entities.

It should call other services.

Mistake 3: incomplete CRUD

If a table exists and the feature requires it, define a complete CRUD.

Mistake 4: direct Prisma access to another module’s entity

Use the other module’s service instead.

Mistake 5: forgetting soft delete

If the project uses deleted / archived, follow that convention.


src/
  authors/
    authors.module.ts
    authors.controller.ts
    authors.service.ts
    authors.types.ts
    authors.validation.ts
    authors.mapper.ts

  books/
    books.module.ts
    books.controller.ts
    books.service.ts
    books.types.ts
    books.validation.ts
    books.mapper.ts

  videos/
    videos.module.ts
    videos.controller.ts
    videos.service.ts
    videos.types.ts
    videos.validation.ts
    videos.mapper.ts

The same pattern applies to:

book-views/
video-views/
book-comments/
video-comments/

8. Hands-on Tutorial: Building authors, books, videos, book-views, video-views, book-comments, and video-comments

8.1 Tutorial goal

This tutorial is a Nest.js hands-on project.

The goal is to build a complete API around authors who publish books and videos.

The objective is not only to code CRUD endpoints. The objective is to reproduce the project’s design logic:

  • one module per resource;
  • DTOs in *.types.ts;
  • Joi validation in *.validation.ts;
  • entity-to-DTO transformation in *.mapper.ts;
  • thin controllers;
  • business logic in services;
  • module independence;
  • consistent CRUD method names.

8.2 Database Model Statement

We want to design the database for an API that allows authors to publish content in the form of books and videos.

Each author can publish multiple books and multiple videos.

Each book belongs to one author, and each video also belongs to one author.

Authors can view content published by other authors. Therefore, a book can have multiple views, and a video can also have multiple views. Each view is linked to the author who viewed the content.

Authors can also comment on content published by other authors. An author can comment on a book or a video, and each book or video can receive multiple comments.

The system should manage:

  • authors;
  • books published by authors;
  • videos published by authors;
  • book views;
  • video views;
  • book comments;
  • video comments.

8.3 Resources and CRUD endpoints

The endpoints below follow the project convention:

  • POST /resources to create;
  • GET /resources to list with query params;
  • GET /resources/:id to select one record by ID;
  • PUT /resources/:id to update;
  • DELETE /resources/:id to remove.

The corresponding method names are:

create(dto);
selectMany(filter);
selectById(id);
update(id, body);
remove(id);

1. Author CRUD

Authors are users who can publish books and videos, view other authors’ content, and comment on it.

OperationDescriptionEndpoint Example
CreateCreate a new authorPOST /authors
ReadGet all authorsGET /authors
ReadGet one author by IDGET /authors/:id
UpdateUpdate author informationPUT /authors/:id
DeleteDelete an authorDELETE /authors/:id

2. Book CRUD

Books are published by authors. One author can have many books.

OperationDescriptionEndpoint Example
CreateCreate a new bookPOST /books
ReadGet all booksGET /books
ReadGet one book by IDGET /books/:id
ReadGet all books by one authorGET /books?authorId=...
UpdateUpdate a bookPUT /books/:id
DeleteDelete a bookDELETE /books/:id

Example body:

{
  "title": "My First Book",
  "description": "A short description of the book",
  "authorId": "author-id"
}

3. Video CRUD

Videos are also published by authors.

OperationDescriptionEndpoint Example
CreateCreate a new videoPOST /videos
ReadGet all videosGET /videos
ReadGet one video by IDGET /videos/:id
ReadGet all videos by one authorGET /videos?authorId=...
UpdateUpdate a videoPUT /videos/:id
DeleteDelete a videoDELETE /videos/:id

Example body:

{
  "title": "My First Video",
  "description": "A short description of the video",
  "url": "https://example.com/video.mp4",
  "authorId": "author-id"
}

4. Book View CRUD

Book views represent authors viewing books published by other authors.

OperationDescriptionEndpoint Example
CreateRegister a view on a bookPOST /book-views
ReadGet all book viewsGET /book-views
ReadGet one book view by IDGET /book-views/:id
UpdateUpdate a book viewPUT /book-views/:id
DeleteDelete a book view recordDELETE /book-views/:id

Example body:

{
  "bookId": "book-id",
  "authorId": "viewer-author-id"
}

5. Video View CRUD

Video views represent authors viewing videos published by other authors.

OperationDescriptionEndpoint Example
CreateRegister a view on a videoPOST /video-views
ReadGet all video viewsGET /video-views
ReadGet one video view by IDGET /video-views/:id
UpdateUpdate a video viewPUT /video-views/:id
DeleteDelete a video view recordDELETE /video-views/:id

6. Book Comment CRUD

Authors can comment on books written by other authors.

OperationDescriptionEndpoint Example
CreateAdd a comment to a bookPOST /book-comments
ReadGet all book commentsGET /book-comments
ReadGet one book commentGET /book-comments/:id
UpdateUpdate a book commentPUT /book-comments/:id
DeleteDelete a book commentDELETE /book-comments/:id

Example body:

{
  "bookId": "book-id",
  "authorId": "comment-author-id",
  "content": "This is a very interesting book."
}

7. Video Comment CRUD

Authors can also comment on videos published by other authors.

OperationDescriptionEndpoint Example
CreateAdd a comment to a videoPOST /video-comments
ReadGet all video commentsGET /video-comments
ReadGet one video commentGET /video-comments/:id
UpdateUpdate a video commentPUT /video-comments/:id
DeleteDelete a video commentDELETE /video-comments/:id

Example body:

{
  "videoId": "video-id",
  "authorId": "comment-author-id",
  "content": "Great video!"
}

8.4 Database diagram

Book api db diagram

8.5 Backend module diagram

This diagram represents the backend structure to reproduce for every module.

  • The blue block represents the Controller.
  • The green block represents the Service.
  • The orange element inside the service block represents the .mapper.ts file.
  • The entity is represented separately because it is manipulated by the service.
  • Controller and service methods use the same CRUD nomenclature.

Book api db diagram

8.7 Target backend project structure

src/
  authors/
    authors.module.ts
    authors.controller.ts
    authors.service.ts
    authors.types.ts
    authors.validation.ts
    authors.mapper.ts

  books/
    books.module.ts
    books.controller.ts
    books.service.ts
    books.types.ts
    books.validation.ts
    books.mapper.ts

  videos/
    videos.module.ts
    videos.controller.ts
    videos.service.ts
    videos.types.ts
    videos.validation.ts
    videos.mapper.ts

  book-views/
    book-views.module.ts
    book-views.controller.ts
    book-views.service.ts
    book-views.types.ts
    book-views.validation.ts
    book-views.mapper.ts

  video-views/
    video-views.module.ts
    video-views.controller.ts
    video-views.service.ts
    video-views.types.ts
    video-views.validation.ts
    video-views.mapper.ts

  book-comments/
    book-comments.module.ts
    book-comments.controller.ts
    book-comments.service.ts
    book-comments.types.ts
    book-comments.validation.ts
    book-comments.mapper.ts

  video-comments/
    video-comments.module.ts
    video-comments.controller.ts
    video-comments.service.ts
    video-comments.types.ts
    video-comments.validation.ts
    video-comments.mapper.ts

  prisma.service.ts

8.8 MySQL Prisma schema

Create or complete prisma/schema.prisma.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Author {
  id        String   @id @default(uuid())
  code      String   @unique
  name      String
  email     String   @unique
  deleted   Boolean  @default(false)
  archived  Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  books         Book[]
  videos        Video[]
  bookViews     BookView[]
  videoViews    VideoView[]
  bookComments  BookComment[]
  videoComments VideoComment[]

  @@map("authors")
}

model Book {
  id          String   @id @default(uuid())
  code        String   @unique
  title       String
  description String?
  authorId    String
  deleted     Boolean  @default(false)
  archived    Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  author   Author        @relation(fields: [authorId], references: [id])
  views    BookView[]
  comments BookComment[]

  @@index([authorId])
  @@map("books")
}

model Video {
  id          String   @id @default(uuid())
  code        String   @unique
  title       String
  description String?
  url         String
  authorId    String
  deleted     Boolean  @default(false)
  archived    Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  author   Author         @relation(fields: [authorId], references: [id])
  views    VideoView[]
  comments VideoComment[]

  @@index([authorId])
  @@map("videos")
}

model BookView {
  id        String   @id @default(uuid())
  code      String   @unique
  bookId    String
  authorId  String
  deleted   Boolean  @default(false)
  archived  Boolean  @default(false)
  viewedAt  DateTime @default(now())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  book   Book   @relation(fields: [bookId], references: [id])
  author Author @relation(fields: [authorId], references: [id])

  @@index([bookId])
  @@index([authorId])
  @@map("book_views")
}

model VideoView {
  id        String   @id @default(uuid())
  code      String   @unique
  videoId   String
  authorId  String
  deleted   Boolean  @default(false)
  archived  Boolean  @default(false)
  viewedAt  DateTime @default(now())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  video  Video  @relation(fields: [videoId], references: [id])
  author Author @relation(fields: [authorId], references: [id])

  @@index([videoId])
  @@index([authorId])
  @@map("video_views")
}

model BookComment {
  id        String   @id @default(uuid())
  code      String   @unique
  bookId    String
  authorId  String
  content   String
  deleted   Boolean  @default(false)
  archived  Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  book   Book   @relation(fields: [bookId], references: [id])
  author Author @relation(fields: [authorId], references: [id])

  @@index([bookId])
  @@index([authorId])
  @@map("book_comments")
}

model VideoComment {
  id        String   @id @default(uuid())
  code      String   @unique
  videoId   String
  authorId  String
  content   String
  deleted   Boolean  @default(false)
  archived  Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  video  Video  @relation(fields: [videoId], references: [id])
  author Author @relation(fields: [authorId], references: [id])

  @@index([videoId])
  @@index([authorId])
  @@map("video_comments")
}

Then run:

npx prisma generate
npx prisma migrate dev --name init_authors_books_videos

8.9 DTOs, validations, and mappers to create

Each module must have:

  • a <module>.types.ts file;
  • a <module>.validation.ts file;
  • a <module>.mapper.ts file.
ModuleOutput DTOCreate DTOUpdate DTOFilter DTOMapper
authorsAuthorDtoCreateAuthorDtoUpdateAuthorDtoFilterAuthorDtotoAuthorDto(entity)
booksBookDtoCreateBookDtoUpdateBookDtoFilterBookDtotoBookDto(entity)
videosVideoDtoCreateVideoDtoUpdateVideoDtoFilterVideoDtotoVideoDto(entity)
book-viewsBookViewDtoCreateBookViewDtoUpdateBookViewDtoFilterBookViewDtotoBookViewDto(entity)
video-viewsVideoViewDtoCreateVideoViewDtoUpdateVideoViewDtoFilterVideoViewDtotoVideoViewDto(entity)
book-commentsBookCommentDtoCreateBookCommentDtoUpdateBookCommentDtoFilterBookCommentDtotoBookCommentDto(entity)
video-commentsVideoCommentDtoCreateVideoCommentDtoUpdateVideoCommentDtoFilterVideoCommentDtotoVideoCommentDto(entity)

DTO classes must be documented with Swagger:

import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";

Validations must use Joi:

import * as Joi from "joi";

Mappers must not call Prisma. They only transform an entity into a DTO.


8.10 Controller pattern to write

All CRUD controllers must use the same method nomenclature.

Controller methods are always:

create(dto);
selectMany(filter);
selectById(id);
update(id, body);
remove(id);

Do not create methods such as:

createBook(dto);
selectManyBooks(filter);
selectBookById(id);
updateBook(id, body);
removeBook(id);

The resource name is already carried by the controller class, for example BooksController. Method names must remain generic.

Correct pattern with BooksController:

@Controller("books")
export class BooksController {
  constructor(private readonly service: BooksService) {}

  @Post()
  @UsePipes(new JoiValidationPipe(CreateBookSchema))
  create(@Body() dto: CreateBookDto) {
    return this.service.create(dto);
  }

  @Get()
  @UsePipes(new JoiValidationPipe(FilterBookSchema))
  selectMany(@Query() filter: FilterBookDto) {
    return this.service.selectMany(filter);
  }

  @Get(":id")
  selectById(@Param("id") id: string) {
    return this.service.selectById(id);
  }

  @Put(":id")
  @UsePipes(new JoiValidationPipe(UpdateBookSchema))
  update(@Param("id") id: string, @Body() body: UpdateBookDto) {
    return this.service.update(id, body);
  }

  @Delete(":id")
  remove(@Param("id") id: string) {
    return this.service.remove(id);
  }
}

Remember:

  • create exists in the controller;
  • selectMany exists in the controller;
  • selectById exists in the controller;
  • update exists in the controller;
  • remove exists in the controller;
  • the controller delegates to the service;
  • the controller does not contain business logic;
  • the controller does not call Prisma.

8.11 Service pattern to write

All CRUD services must use the same method nomenclature.

Service methods are always:

create(dto);
selectMany(filter);
selectById(id);
update(id, body);
remove(id);

Do not create methods such as:

createBook(dto);
selectManyBooks(filter);
selectBookById(id);
updateBook(id, body);
removeBook(id);

The resource name is already carried by the service class, for example BooksService.

Correct pattern with BooksService:

@Injectable()
export class BooksService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly authorsService: AuthorsService,
  ) {}

  async create(dto: CreateBookDto) {
    const author = await this.authorsService.selectById(dto.authorId);

    const entity = await this.prisma.book.create({
      data: {
        code: generateMatricule("BOOK"),
        title: dto.title,
        description: dto.description,
        authorId: author.id,
      },
      include: {
        author: true,
      },
    });

    return toBookDto(entity);
  }

  async selectMany(filter: FilterBookDto) {
    const page = Math.max(1, Number(filter.page) || 1);
    const limit = Math.max(1, Number(filter.limit) || 10);
    const search = filter.search?.trim();

    let authorId: string | undefined = undefined;

    if (filter.authorId) {
      const author = await this.authorsService.selectById(filter.authorId);
      authorId = author.id;
    }

    const where = {
      deleted: false,
      archived: false,
      authorId,
      OR: search
        ? [
            { title: { contains: search } },
            { description: { contains: search } },
          ]
        : undefined,
    };

    const [total, entities] = await this.prisma.$transaction([
      this.prisma.book.count({ where }),
      this.prisma.book.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: "desc" },
        include: {
          author: true,
        },
      }),
    ]);

    return {
      page,
      limit,
      total,
      data: entities.map(toBookDto),
    };
  }

  async selectById(id: string) {
    const entity = await this.prisma.book.findFirst({
      where: {
        id,
        deleted: false,
        archived: false,
      },
      include: {
        author: true,
      },
    });

    if (!entity) {
      throw new NotFoundException("Book not found");
    }

    return toBookDto(entity);
  }

  async update(id: string, body: UpdateBookDto) {
    await this.selectById(id);

    const entity = await this.prisma.book.update({
      where: { id },
      data: body,
      include: {
        author: true,
      },
    });

    return toBookDto(entity);
  }

  async remove(id: string) {
    await this.selectById(id);

    const entity = await this.prisma.book.update({
      where: { id },
      data: {
        deleted: true,
      },
      include: {
        author: true,
      },
    });

    return toBookDto(entity);
  }
}

Remember:

  • create exists in the service;
  • selectMany exists in the service;
  • selectById exists in the service;
  • update exists in the service;
  • remove exists in the service;
  • the service manipulates its entity;
  • the service checks relations by calling other services;
  • the service does not return raw entities;
  • the service uses its mapper before returning a response;
  • remove(id) applies soft delete when the project convention requires it.

8.12 Complete example: the books module

This section shows the detailed code to write for one module. Other modules reuse the same pattern with their own fields.

8.12.1 books.types.ts

import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";

export class BookDto {
  @ApiProperty()
  id: string;

  @ApiProperty()
  title: string;

  @ApiPropertyOptional()
  description?: string;

  @ApiProperty()
  authorId: string;

  @ApiProperty()
  createdAt: Date;

  @ApiProperty()
  updatedAt: Date;
}

export class CreateBookDto {
  @ApiProperty()
  @IsString()
  title: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  description?: string;

  @ApiProperty()
  @IsString()
  authorId: string;
}

export class UpdateBookDto {
  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  title?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  description?: string;
}

export class FilterBookDto {
  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  search?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  authorId?: string;

  @ApiPropertyOptional()
  @IsOptional()
  page?: number;

  @ApiPropertyOptional()
  @IsOptional()
  limit?: number;
}

8.12.2 books.validation.ts

import * as Joi from "joi";

export const CreateBookSchema = Joi.object({
  title: Joi.string().trim().required(),
  description: Joi.string().allow("", null).optional(),
  authorId: Joi.string().required(),
});

export const UpdateBookSchema = Joi.object({
  title: Joi.string().trim().optional(),
  description: Joi.string().allow("", null).optional(),
});

export const FilterBookSchema = Joi.object({
  search: Joi.string().optional(),
  authorId: Joi.string().optional(),
  page: Joi.number().min(1).optional(),
  limit: Joi.number().min(1).max(100).optional(),
});

8.12.3 books.mapper.ts

import { BookDto } from "./books.types";

export function toBookDto(entity: any): BookDto {
  return {
    id: entity.id,
    title: entity.title,
    description: entity.description,
    authorId: entity.authorId,
    createdAt: entity.createdAt,
    updatedAt: entity.updatedAt,
  };
}

8.12.4 books.controller.ts

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
  Query,
  UsePipes,
} from "@nestjs/common";
import { JoiValidationPipe } from "src/@1hand/pipes/JoiValidatorPipe";
import { BooksService } from "./books.service";
import { CreateBookDto, UpdateBookDto, FilterBookDto } from "./books.types";
import {
  CreateBookSchema,
  UpdateBookSchema,
  FilterBookSchema,
} from "./books.validation";

@Controller("books")
export class BooksController {
  constructor(private readonly service: BooksService) {}

  @Post()
  @UsePipes(new JoiValidationPipe(CreateBookSchema))
  create(@Body() dto: CreateBookDto) {
    return this.service.create(dto);
  }

  @Get()
  @UsePipes(new JoiValidationPipe(FilterBookSchema))
  selectMany(@Query() filter: FilterBookDto) {
    return this.service.selectMany(filter);
  }

  @Get(":id")
  selectById(@Param("id") id: string) {
    return this.service.selectById(id);
  }

  @Put(":id")
  @UsePipes(new JoiValidationPipe(UpdateBookSchema))
  update(@Param("id") id: string, @Body() body: UpdateBookDto) {
    return this.service.update(id, body);
  }

  @Delete(":id")
  remove(@Param("id") id: string) {
    return this.service.remove(id);
  }
}

8.12.5 books.service.ts

import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma.service";
import { AuthorsService } from "src/authors/authors.service";
import { generateMatricule } from "src/@1hand/utils";
import { CreateBookDto, UpdateBookDto, FilterBookDto } from "./books.types";
import { toBookDto } from "./books.mapper";

@Injectable()
export class BooksService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly authorsService: AuthorsService,
  ) {}

  async create(dto: CreateBookDto) {
    const author = await this.authorsService.selectById(dto.authorId);

    const entity = await this.prisma.book.create({
      data: {
        code: generateMatricule("BOOK"),
        title: dto.title,
        description: dto.description,
        authorId: author.id,
      },
      include: {
        author: true,
      },
    });

    return toBookDto(entity);
  }

  async selectMany(filter: FilterBookDto) {
    const page = Math.max(1, Number(filter.page) || 1);
    const limit = Math.max(1, Number(filter.limit) || 10);
    const search = filter.search?.trim();

    let authorId: string | undefined = undefined;

    if (filter.authorId) {
      const author = await this.authorsService.selectById(filter.authorId);
      authorId = author.id;
    }

    const where = {
      deleted: false,
      archived: false,
      authorId,
      OR: search
        ? [
            { title: { contains: search } },
            { description: { contains: search } },
          ]
        : undefined,
    };

    const [total, entities] = await this.prisma.$transaction([
      this.prisma.book.count({ where }),
      this.prisma.book.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: "desc" },
        include: {
          author: true,
        },
      }),
    ]);

    return {
      page,
      limit,
      total,
      data: entities.map(toBookDto),
    };
  }

  async selectById(id: string) {
    const entity = await this.prisma.book.findFirst({
      where: {
        id,
        deleted: false,
        archived: false,
      },
      include: {
        author: true,
      },
    });

    if (!entity) {
      throw new NotFoundException("Book not found");
    }

    return toBookDto(entity);
  }

  async update(id: string, body: UpdateBookDto) {
    await this.selectById(id);

    const entity = await this.prisma.book.update({
      where: { id },
      data: body,
      include: {
        author: true,
      },
    });

    return toBookDto(entity);
  }

  async remove(id: string) {
    await this.selectById(id);

    const entity = await this.prisma.book.update({
      where: { id },
      data: {
        deleted: true,
      },
      include: {
        author: true,
      },
    });

    return toBookDto(entity);
  }
}

8.12.6 books.module.ts

import { Module } from "@nestjs/common";
import { PrismaService } from "src/prisma.service";
import { AuthorsModule } from "src/authors/authors.module";
import { BooksController } from "./books.controller";
import { BooksService } from "./books.service";

@Module({
  imports: [AuthorsModule],
  controllers: [BooksController],
  providers: [BooksService, PrismaService],
  exports: [BooksService],
})
export class BooksModule {}

8.13 Adaptations for the other modules

Use the same pattern for all modules.

authors

Fields usually include:

  • name;
  • email.

Service responsibility:

  • create an author;
  • list authors;
  • select one author by ID;
  • update an author;
  • remove an author.

videos

Fields usually include:

  • title;
  • description;
  • url;
  • authorId.

The service must verify the author by calling AuthorsService.selectById(authorId).

book-views

Fields usually include:

  • bookId;
  • authorId;
  • viewedAt.

The service must verify:

  • the book exists through BooksService.selectById(bookId);
  • the author exists through AuthorsService.selectById(authorId).

video-views

Fields usually include:

  • videoId;
  • authorId;
  • viewedAt.

The service must verify:

  • the video exists through VideosService.selectById(videoId);
  • the author exists through AuthorsService.selectById(authorId).

book-comments

Fields usually include:

  • bookId;
  • authorId;
  • content.

The service must verify:

  • the book exists through BooksService.selectById(bookId);
  • the author exists through AuthorsService.selectById(authorId).

video-comments

Fields usually include:

  • videoId;
  • authorId;
  • content.

The service must verify:

  • the video exists through VideosService.selectById(videoId);
  • the author exists through AuthorsService.selectById(authorId).

8.14 Dependency organization between modules

Each module owns its own entity.

A module that needs another entity must call the corresponding service.

Examples:

  • BooksService calls AuthorsService;
  • VideosService calls AuthorsService;
  • BookViewsService calls BooksService and AuthorsService;
  • VideoViewsService calls VideosService and AuthorsService;
  • BookCommentsService calls BooksService and AuthorsService;
  • VideoCommentsService calls VideosService and AuthorsService.

Do not do this in BookCommentsService:

await this.prisma.book.findFirst(...);

Do this instead:

await this.booksService.selectById(bookId);

This keeps the module boundary clean.


8.15 Frontend example: code to write

The frontend mirrors the backend design.

For each module, create:

books/
  books.types.ts
  books.validation.ts
  books.adapter.ts
  books.http-client.ts
  books.service.ts

books.http-client.ts

The HttpClient keeps the same route logic as the backend controller.

export class BooksHttpClient {
  selectMany(filter: FilterBookDto) {
    return http.get("/books", { params: filter });
  }

  selectById(id: string) {
    return http.get(`/books/${id}`);
  }

  create(dto: CreateBookDto) {
    return http.post("/books", dto);
  }

  update(id: string, body: UpdateBookDto) {
    return http.put(`/books/${id}`, body);
  }

  remove(id: string) {
    return http.delete(`/books/${id}`);
  }
}

books.adapter.ts

The adapter transforms API data into UI-friendly data and form data into DTOs.

export function toBookDto(data: any): BookDto {
  return {
    id: data.id,
    title: data.title,
    description: data.description,
    authorId: data.authorId,
    createdAt: data.createdAt,
    updatedAt: data.updatedAt,
  };
}

export function toCreateBookDto(form: any): CreateBookDto {
  return {
    title: form.title,
    description: form.description,
    authorId: form.authorId,
  };
}

books.service.ts

The frontend service calls the HttpClient and uses the adapter.

export class BooksService {
  constructor(private readonly httpClient: BooksHttpClient) {}

  async selectMany(filter: FilterBookDto) {
    const response = await this.httpClient.selectMany(filter);
    return {
      ...response,
      data: response.data.map(toBookDto),
    };
  }

  async selectById(id: string) {
    const response = await this.httpClient.selectById(id);
    return toBookDto(response);
  }

  async create(form: any) {
    const dto = toCreateBookDto(form);
    const response = await this.httpClient.create(dto);
    return toBookDto(response);
  }

  async update(id: string, body: UpdateBookDto) {
    const response = await this.httpClient.update(id, body);
    return toBookDto(response);
  }

  async remove(id: string) {
    const response = await this.httpClient.remove(id);
    return toBookDto(response);
  }
}

The screen visible to the user consumes the frontend service. It should not directly call HTTP routes.


8.16 HTTP request examples

Create an author

POST /authors
Content-Type: application/json

{
  "name": "Alice Martin",
  "email": "alice@example.com"
}

List authors

GET /authors?page=1&limit=10&search=alice

Create a book

POST /books
Content-Type: application/json

{
  "title": "Understanding Modular NestJS",
  "description": "A practical introduction for new developers.",
  "authorId": "author-id"
}

List books

GET /books?page=1&limit=10&search=nestjs

Create a video

POST /videos
Content-Type: application/json

{
  "title": "NestJS Architecture",
  "description": "A video about modular backend architecture.",
  "url": "https://example.com/video.mp4",
  "authorId": "author-id"
}

Register a book view

POST /book-views
Content-Type: application/json

{
  "bookId": "book-id",
  "authorId": "viewer-author-id"
}

Add a book comment

POST /book-comments
Content-Type: application/json

{
  "bookId": "book-id",
  "authorId": "comment-author-id",
  "content": "This book helped me understand module boundaries."
}

Update a book comment

PUT /book-comments/comment-id
Content-Type: application/json

{
  "content": "Updated comment body"
}

Remove a video

DELETE /videos/video-id

8.17 End-of-tutorial checklist

Before considering the tutorial complete, verify the following points.

For each module

  • the Prisma model exists;
  • the module file exists;
  • the controller exists;
  • the service exists;
  • the types file exists;
  • the validation file exists;
  • the mapper file exists.

For DTOs

  • the output DTO exists;
  • the create DTO exists;
  • the update DTO exists;
  • the filter DTO exists;
  • all DTO classes end with Dto;
  • DTOs are documented with Swagger.

For validation

  • the create Joi schema exists;
  • the update Joi schema exists;
  • the filter Joi schema exists;
  • validations are applied in the controller.

For controller methods

  • create(dto) exists;
  • selectMany(filter) exists;
  • selectById(id) exists;
  • update(id, body) exists;
  • remove(id) exists.

For service methods

  • create(dto) exists;
  • selectMany(filter) exists;
  • selectById(id) exists;
  • update(id, body) exists;
  • remove(id) exists.

For architecture

  • each module manipulates its own entity;
  • external entities are checked through their service;
  • services do not return raw entities;
  • services use mappers before returning responses;
  • controllers remain thin;
  • no business logic is hidden inside controllers.

9. Recommendations for a New Developer

  1. Start by reading a simple module, then a more business-heavy module.
  2. Reproduce the structure before trying to innovate.
  3. Keep controllers thin.
  4. Put the real logic in services.
  5. Reuse shared helpers before creating new helpers.
  6. Do not access another module’s Prisma entity directly when that module already has a service.
  7. Keep naming conventions consistent everywhere.
  8. Use create, selectMany, selectById, update, and remove for CRUD methods.
  9. Apply pagination consistently.
  10. Use soft delete when the project convention requires it.
  11. Use a transaction when a business rule involves several writes.

10. Conclusion

The backend approach is based on a simple but essential idea:

a feature must be modular, readable, validated, and responsible for its own domain.

The guide formalizes these conventions:

  • MySQL database;
  • complete CRUD for each table;
  • strict module independence;
  • collaboration between modules through services;
  • no direct Prisma access to another module’s entity;
  • DTOs in *.types.ts;
  • Joi validation in *.validation.ts;
  • entity-to-DTO transformation in *.mapper.ts;
  • controllers with create, selectMany, selectById, update, remove;
  • services with create, selectMany, selectById, update, remove;
  • frontend analogy with HttpClient, Service, .adapter.ts, and UI screens.

If these rules are followed, each new module will remain coherent with the project architecture, easier to read, easier to test, and more robust in the long term.