Guide technique
Building Backend Modules with the Project Approach
Table of Contents
- Building Backend Modules with the Project Approach
- Table of Contents
- 1. Introduction
- 2. What Was Analyzed
- 3. Backend Approach Overview
- 4. Shared Foundations
- 4.1 Why these foundations exist
- 4.2 DTOs and shared base types
- 4.3 Input validation with Joi
- 4.4 Utility helpers
- 4.5 Decorators and NestJS conventions
- 4.6 Shared technical services
- 4.7 How a new developer should rely on these foundations
- 5. Detailed Documentation of the Analyzed Modules
- 5.1 Module
academic-levels - 5.2 Module
academic-years - 5.3 What these modules teach about architecture
- 6. Module Construction Logic
- 6.1 The complete request-processing chain
- 6.2 Exact responsibility of each file
- 6.3 Module independence rule
- 6.4 Frequent mistakes to avoid
- 7. Recommended Structure for a New Feature
- 8. Hands-on Tutorial: Building
authors,books,videos,book-views,video-views,book-comments, andvideo-comments - 8.1 Tutorial goal
- 8.2 Database Model Statement
- 8.3 Resources and CRUD endpoints
- 8.4 Database diagram
- 8.5 Backend module diagram
- 8.7 Target backend project structure
- 8.8 MySQL Prisma schema
- 8.9 DTOs, validations, and mappers to create
- 8.10 Controller pattern to write
- 8.11 Service pattern to write
- 8.12 Complete example: the
booksmodule - 8.13 Adaptations for the other modules
- 8.14 Dependency organization between modules
- 8.15 Frontend example: code to write
- 8.16 HTTP request examples
- 8.17 End-of-tutorial checklist
- 9. Recommendations for a New Developer
- 10. Conclusion
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:
- HTTP inputs are described with DTOs.
- Inputs are validated with Joi.
- Routes are defined in controllers.
- Business logic and Prisma access live in services.
- The module file wires dependencies.
- 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:
- Does a shared type already exist?
- Does a reusable Joi schema already exist?
- Does a utility helper already cover part of the need?
- Does a technical service already exist?
- 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:
- standard CRUD endpoints;
- 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:
- The controller receives the request.
- Joi validates the input through a pipe.
- The controller extracts useful parameters.
- The service executes business checks.
- The service calls Prisma.
- The service transforms entities when needed.
- 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:
AuthorsServicemanipulates authors;BooksServicemanipulates books;BookCommentsServicemanipulates 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.
7. Recommended Structure for a New Feature
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 /resourcesto create;GET /resourcesto list with query params;GET /resources/:idto select one record by ID;PUT /resources/:idto update;DELETE /resources/:idto 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.
| Operation | Description | Endpoint Example |
|---|---|---|
| Create | Create a new author | POST /authors |
| Read | Get all authors | GET /authors |
| Read | Get one author by ID | GET /authors/:id |
| Update | Update author information | PUT /authors/:id |
| Delete | Delete an author | DELETE /authors/:id |
2. Book CRUD
Books are published by authors. One author can have many books.
| Operation | Description | Endpoint Example |
|---|---|---|
| Create | Create a new book | POST /books |
| Read | Get all books | GET /books |
| Read | Get one book by ID | GET /books/:id |
| Read | Get all books by one author | GET /books?authorId=... |
| Update | Update a book | PUT /books/:id |
| Delete | Delete a book | DELETE /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.
| Operation | Description | Endpoint Example |
|---|---|---|
| Create | Create a new video | POST /videos |
| Read | Get all videos | GET /videos |
| Read | Get one video by ID | GET /videos/:id |
| Read | Get all videos by one author | GET /videos?authorId=... |
| Update | Update a video | PUT /videos/:id |
| Delete | Delete a video | DELETE /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.
| Operation | Description | Endpoint Example |
|---|---|---|
| Create | Register a view on a book | POST /book-views |
| Read | Get all book views | GET /book-views |
| Read | Get one book view by ID | GET /book-views/:id |
| Update | Update a book view | PUT /book-views/:id |
| Delete | Delete a book view record | DELETE /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.
| Operation | Description | Endpoint Example |
|---|---|---|
| Create | Register a view on a video | POST /video-views |
| Read | Get all video views | GET /video-views |
| Read | Get one video view by ID | GET /video-views/:id |
| Update | Update a video view | PUT /video-views/:id |
| Delete | Delete a video view record | DELETE /video-views/:id |
6. Book Comment CRUD
Authors can comment on books written by other authors.
| Operation | Description | Endpoint Example |
|---|---|---|
| Create | Add a comment to a book | POST /book-comments |
| Read | Get all book comments | GET /book-comments |
| Read | Get one book comment | GET /book-comments/:id |
| Update | Update a book comment | PUT /book-comments/:id |
| Delete | Delete a book comment | DELETE /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.
| Operation | Description | Endpoint Example |
|---|---|---|
| Create | Add a comment to a video | POST /video-comments |
| Read | Get all video comments | GET /video-comments |
| Read | Get one video comment | GET /video-comments/:id |
| Update | Update a video comment | PUT /video-comments/:id |
| Delete | Delete a video comment | DELETE /video-comments/:id |
Example body:
{
"videoId": "video-id",
"authorId": "comment-author-id",
"content": "Great video!"
}
8.4 Database 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.tsfile. - The entity is represented separately because it is manipulated by the service.
- Controller and service methods use the same CRUD nomenclature.

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.tsfile; - a
<module>.validation.tsfile; - a
<module>.mapper.tsfile.
| Module | Output DTO | Create DTO | Update DTO | Filter DTO | Mapper |
|---|---|---|---|---|---|
authors | AuthorDto | CreateAuthorDto | UpdateAuthorDto | FilterAuthorDto | toAuthorDto(entity) |
books | BookDto | CreateBookDto | UpdateBookDto | FilterBookDto | toBookDto(entity) |
videos | VideoDto | CreateVideoDto | UpdateVideoDto | FilterVideoDto | toVideoDto(entity) |
book-views | BookViewDto | CreateBookViewDto | UpdateBookViewDto | FilterBookViewDto | toBookViewDto(entity) |
video-views | VideoViewDto | CreateVideoViewDto | UpdateVideoViewDto | FilterVideoViewDto | toVideoViewDto(entity) |
book-comments | BookCommentDto | CreateBookCommentDto | UpdateBookCommentDto | FilterBookCommentDto | toBookCommentDto(entity) |
video-comments | VideoCommentDto | CreateVideoCommentDto | UpdateVideoCommentDto | FilterVideoCommentDto | toVideoCommentDto(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:
createexists in the controller;selectManyexists in the controller;selectByIdexists in the controller;updateexists in the controller;removeexists 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:
createexists in the service;selectManyexists in the service;selectByIdexists in the service;updateexists in the service;removeexists 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:
BooksServicecallsAuthorsService;VideosServicecallsAuthorsService;BookViewsServicecallsBooksServiceandAuthorsService;VideoViewsServicecallsVideosServiceandAuthorsService;BookCommentsServicecallsBooksServiceandAuthorsService;VideoCommentsServicecallsVideosServiceandAuthorsService.
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
- Start by reading a simple module, then a more business-heavy module.
- Reproduce the structure before trying to innovate.
- Keep controllers thin.
- Put the real logic in services.
- Reuse shared helpers before creating new helpers.
- Do not access another module’s Prisma entity directly when that module already has a service.
- Keep naming conventions consistent everywhere.
- Use
create,selectMany,selectById,update, andremovefor CRUD methods. - Apply pagination consistently.
- Use soft delete when the project convention requires it.
- 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.