Guide technique en français
Construire des modules backend avec l’approche du projet
Sommaire
- Construire des modules backend avec l’approche du projet
- Sommaire
- 1. Introduction
- Télécharger les fichiers sources
- 2. Ce qui a été analysé
- 3. Vue d’ensemble de l’approche backend
- 4. Fondations partagées
- 5. Documentation détaillée des modules analysés
- 5.1 Module
academic-levels - 5.2 Module
academic-years - 5.3 Ce que ces deux modules enseignent sur l’architecture
- 6. Logique de construction des modules
- 6.1 La chaîne complète de traitement
- 6.2 Les responsabilités exactes de chaque fichier
- 6.3 La règle d’indépendance des modules
- 6.4 Les erreurs fréquentes à éviter
- 7. Structure recommandée pour une nouvelle feature
- 8. Tutoriel pratique : construire
authors,books,videos,book-views,video-views,book-commentsetvideo-comments - 8.1 Objectif du tutoriel
- 8.2 Database Model Statement
- 8.3 Ressources et endpoints CRUD
- 8.4 Diagramme de base de données
- 8.5 Diagramme backend des modules
- 8.6 Diagramme frontend analogue
- 8.7 Structure cible du projet backend
- 8.8 Schéma Prisma MySQL
- 8.9 DTOs, validations et mappers à créer
- 8.10 Pattern du controller à écrire
- 8.11 Pattern du service à écrire
- 8.12 Exemple complet : module
books - 8.13 Adaptations à faire pour les autres modules
- 8.14 Organisation des dépendances entre modules
- 8.15 Exemple frontend : code à écrire
- 8.16 Exemples de requêtes HTTP
- Créer un auteur
- Créer un livre
- Créer une vidéo
- Enregistrer une vue sur un livre
- Enregistrer une vue sur une vidéo
- Ajouter un commentaire sur un livre
- Ajouter un commentaire sur une vidéo
- Lister les livres d’un auteur
- Lister les commentaires d’un livre
- Mettre à jour un livre
- Supprimer logiquement une vidéo
- 8.17 Checklist de fin de tutoriel
- 9. Recommandations pour un nouveau développeur
- 10. Conclusion
1. Introduction
Ce guide s’adresse au développeur qui rejoint le projet et doit comprendre comment écrire un module backend de la bonne manière.
Le premier guide avait été produit à partir d’une archive @1hand frontend. Cette version corrige ce point et s’appuie désormais sur les fichiers backend réellement fournis :
@1hand-backendacademic-levelsacademic-years
L’objectif n’est pas seulement de montrer du code. L’objectif est de faire comprendre :
- comment les modules sont pensés,
- pourquoi ils sont séparés,
- comment réutiliser les briques communes,
- comment créer une nouvelle feature sans casser l’architecture existante.
Le tutoriel final construit sept modules :
authorsbooksvideosbook-viewsvideo-viewsbook-commentsvideo-comments
avec les règles suivantes :
- base de données MySQL,
- environnement local avec Docker et Docker Compose,
- CRUD complet sur chaque table,
- indépendance stricte des modules,
- les modules qui ont besoin d’une autre ressource passent par le service du module concerné au lieu d’accéder directement à l’entité Prisma externe,
- chaque service retourne des DTOs propres à l’aide de son mapper.
Télécharger les fichiers sources
Ce guide est basé sur trois archives backend :
- le socle backend partagé :
@1hand-backend - le module exemple :
academic-levels - le module exemple :
academic-years
Vous pouvez télécharger les archives sources ici :
| Fichier | Description | Télécharger |
|---|---|---|
@1hand-backend.zip | Socle backend partagé contenant les types de base, validateurs Joi, pipes, décorateurs, helpers, modules techniques d’upload et de génération PDF. | Télécharger @1hand-backend.zip |
academic-levels.zip | Module backend d’exemple montrant la structure classique d’un module NestJS : types, validations, controller, service et module. | Télécharger academic-levels.zip |
academic-years.zip | Module backend d’exemple plus complet montrant la pagination, les règles métier, les relations avec d’autres entités, les transactions et la suppression logique. | Télécharger academic-years.zip |
2. Ce qui a été analysé
Archive @1hand-backend
Fichiers principaux observés :
@1hand/base.type.ts@1hand/base.validator.ts@1hand/utils.ts@1hand/pipes/JoiValidatorPipe.ts@1hand/decorators/current-user.decorator.ts@1hand/modules/upload/*@1hand/modules/pdf-module/*
Module academic-levels
Fichiers observés :
academic-levels.types.tsacademic-levels.validation.tsacademic-levels.controller.tsacademic-levels.service.tsacademic-levels.module.ts
Module academic-years
Fichiers observés :
academic-years.types.tsacademic-years.validation.tsacademic-years.controller.tsacademic-years.service.tsacademic-years.module.ts
Ce que cela révèle immédiatement
Même sur un petit échantillon, l’approche est très claire :
- les entrées HTTP sont décrites dans des DTOs,
- elles sont validées avec Joi,
- les routes sont définies dans un controller,
- la logique métier et Prisma vivent dans le service,
- le module assemble les dépendances,
- des helpers transverses évitent de dupliquer les mêmes règles partout.
3. Vue d’ensemble de l’approche backend
L’approche backend observée dans les fichiers suit une logique modulaire assez saine pour un projet NestJS.
Quand un module est bien construit, il répond à une chaîne de questions très simple.
Étape 1 : quelles données le module accepte-t-il ?
La réponse se trouve dans *.types.ts.
Exemple :
CreateAcademicLevelDtoUpdateAcademicYearDtoFilterAcademicYearDto
Ces classes décrivent la forme attendue des données.
Étape 2 : quelles règles s’appliquent aux entrées ?
La réponse se trouve dans *.validation.ts.
Ici, le projet utilise Joi pour valider les payloads et query params.
Étape 3 : quelle route HTTP déclenche quelle action ?
La réponse se trouve dans *.controller.ts.
Le controller reçoit la requête, applique les pipes, puis délègue au service.
Étape 4 : où se trouve le vrai comportement métier ?
La réponse se trouve dans *.service.ts.
C’est ici que le module :
- interroge Prisma,
- vérifie l’existence d’entités liées,
- lève les erreurs métier,
- applique le soft delete,
- gère les transactions,
- assemble la réponse finale.
Étape 5 : comment NestJS connaît-il ce module ?
La réponse se trouve dans *.module.ts.
Ce fichier branche :
- les controllers,
- les providers,
- les imports de modules nécessaires.
L’idée centrale à retenir
Un module n’est pas “un service avec quelques routes autour”.
Un module est une feature autonome, structurée de manière prévisible, où chaque fichier a une responsabilité claire.
4. Fondations partagées
À partir d’ici, le point de vue adopté est celui du développeur qui doit produire du code cohérent avec l’existant.
Avant d’écrire un nouveau module, il faut comprendre les briques transverses déjà présentes dans le backend. C’est ce socle qui donne la cohérence du projet.
4.1 Pourquoi ces fondations existent
Quand plusieurs développeurs travaillent sur le même backend, le vrai danger n’est pas seulement le bug. Le vrai danger, c’est la divergence.
Par divergence, il faut entendre :
- un module valide avec Joi, un autre non,
- un module fait du soft delete, un autre fait du hard delete,
- un module retourne une pagination cohérente, un autre non,
- un module appelle directement Prisma sur une entité externe, un autre passe par un service,
- un module traduit les erreurs via i18n, un autre renvoie des chaînes en dur.
Les fondations partagées existent pour éviter ce type de dérive.
En pratique, elles servent à standardiser :
- la forme des données,
- la validation,
- les conventions de nommage,
- les helpers techniques,
- certaines mécaniques communes comme les numéros générés, les dates, le hash de mot de passe, le slug, etc.
Quand un nouveau développeur ignore ces fondations, il peut tout de même écrire du code “qui marche”, mais il produit généralement un code :
- plus difficile à relire,
- moins homogène avec le reste du projet,
- plus coûteux à maintenir,
- plus risqué à faire évoluer.
Le bon réflexe est donc le suivant :
avant de coder une nouvelle feature, regarder d’abord les briques communes disponibles.
4.2 Les DTOs et types de base
Fichier observé : @1hand/base.type.ts
Le fichier analysé contient principalement PhoneNumberDto.
export class PhoneNumberDto {
dialCode: string;
iso2: string;
nationalNumber: string;
internationalNumber: string;
}
Ce point est très instructif.
Ce que cela signifie
Le projet préfère décrire certaines structures réutilisables dans un type dédié au lieu de recopier la même forme d’objet dans plusieurs modules.
Un numéro de téléphone n’est pas stocké comme une simple chaîne. Il est décrit comme un objet structuré.
Cela permet :
- une meilleure documentation Swagger,
- une validation plus précise,
- une réutilisation uniforme dans plusieurs modules,
- une logique métier plus claire.
Ce qu’un développeur doit en déduire
Si une structure de données est amenée à revenir dans plusieurs modules, il ne faut pas la recopier localement à chaque fois.
Exemples de bons candidats à mutualiser :
- numéro de téléphone,
- adresse postale,
- métadonnées de pagination,
- structure de fichier uploadé,
- objet d’audit,
- structure d’un utilisateur courant.
Exemple d’application
Si demain plusieurs modules manipulent une adresse :
export class AddressDto {
street: string;
city: string;
postalCode: string;
country: string;
}
alors il vaut mieux la placer dans une brique partagée plutôt que la réécrire dans schools, students, parents, teachers, etc.
Pourquoi c’est important pour un junior
Quand on débute, on pense souvent “je vais d’abord faire simple et dupliquer, je factoriserai plus tard”.
Dans un projet d’équipe, cette habitude coûte cher. La bonne pratique est de se demander tout de suite :
- cette structure est-elle locale à mon module ?
- ou est-elle transversale et réutilisable ?
4.3 La validation d’entrée avec Joi
Fichiers observés :
@1hand/base.validator.ts@1hand/pipes/JoiValidatorPipe.tsacademic-levels.validation.tsacademic-years.validation.ts
4.3.1 Le rôle de base.validator.ts
Le backend expose un schéma partagé PhoneNumberSchema.
export const PhoneNumberSchema = Joi.object({
dialCode: Joi.string()
.pattern(/^\+\d{1,4}$/)
.required(),
iso2: Joi.string().uppercase().length(2).required(),
nationalNumber: Joi.string()
.pattern(/^\d{4,14}$/)
.required(),
internationalNumber: Joi.string()
.pattern(/^\+[1-9]\d{1,14}$/)
.required(),
});
Ce fichier montre une idée très importante :
les règles de validation réutilisables doivent être centralisées.
Si le numéro de téléphone est utilisé dans cinq modules, il ne faut pas écrire cinq variantes légèrement différentes du même schéma.
4.3.2 Le rôle du JoiValidationPipe
Le pipe observé fait plusieurs choses utiles :
- il accepte un
ObjectSchemaJoi, - il ignore les primitives quand ce n’est pas un objet,
- il valide les objets et tableaux,
- il remonte une erreur
BadRequestExceptionstructurée avec les détails de chaque champ.
Cela signifie qu’un controller peut écrire :
@UsePipes(new JoiValidationPipe(CreateAcademicYearSchema))
create(@Body() dto: CreateAcademicYearDto) {
return this.service.create(dto);
}
4.3.3 Pourquoi cette approche est intéressante
Avec cette approche, le controller ne mélange pas validation et logique métier.
Le controller :
- reçoit les données,
- applique le pipe,
- délègue au service.
Le service peut alors supposer qu’il travaille déjà avec des données conformes.
4.3.4 Ce qu’un développeur doit faire dans un nouveau module
Pour chaque module, prévoir au minimum :
- un schéma de création,
- un schéma de mise à jour,
- un schéma de filtrage / listing.
Exemple simple pour un module authors :
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(),
});
4.3.5 Point d’attention important
Dans les fichiers analysés, certains DTOs et validations ne sont pas parfaitement alignés.
Par exemple, certains DTOs contiennent un id dans l’update alors que la route travaille avec code ou id en paramètre.
Leçon pour le développeur :
le DTO, le schéma Joi, la route et la méthode service doivent raconter exactement la même histoire.
Sinon, le module devient difficile à comprendre et plus fragile.
4.4 Les helpers utilitaires
Fichier observé : @1hand/utils.ts
Ce fichier contient plusieurs helpers importants.
4.4.1 Helpers de date
Fonctions observées :
formatDateOnly(date)truncateDate(date)parseDateAsLocalMidnight(input)
Ces fonctions indiquent que le projet cherche à contrôler les manipulations de dates plutôt que de laisser chaque module bricoler sa logique locale.
C’est une très bonne pratique, car les dates sont souvent une source de bugs :
- fuseau horaire,
- minuit local,
- formatage incohérent,
- comparaisons erronées.
Comment raisonner en tant que développeur
Si un comportement date/heure revient dans plusieurs modules, il doit être encapsulé dans un helper partagé.
Exemple de mauvaise pratique :
const date = new Date(input);
date.setHours(0, 0, 0, 0);
copié dans six services différents.
Exemple de bonne pratique :
const normalizedDate = parseDateAsLocalMidnight(input);
4.4.2 hashPassword
Le helper hashPassword centralise la logique de hash avec bcryptjs.
C’est important pour trois raisons :
- le nombre de rounds est défini à un seul endroit,
- tous les modules appliquent le même comportement,
- en cas d’évolution future, une seule fonction est à modifier.
4.4.3 generateMatricule
Ce helper génère un code métier basé sur :
- l’année courante,
- un code de rôle,
- une partie aléatoire.
Exemple :
const code = generateMatricule("ACADEMIC_YEAR");
Les modules academic-levels et academic-years montrent que la création d’une entité peut s’accompagner d’un identifiant métier généré automatiquement.
Ce que le développeur doit retenir
Quand le projet possède déjà une convention d’identifiant métier, il faut la suivre.
Il ne faut pas inventer un autre format localement dans un nouveau module sauf décision explicite d’architecture.
4.4.4 getSlug
La présence de getSlug(title) montre que certaines features peuvent dépendre d’un slug métier lisible.
Si un futur module posts expose une URL ou une recherche par slug, il faut passer par ce helper ou un helper équivalent partagé.
4.4.5 extractUserProfile
Même si cette fonction n’est pas directement utilisée dans les modules académiques analysés, elle montre une approche utile :
- récupérer un objet relationnel riche,
- le transformer en structure propre à exposer côté API.
Autrement dit, tout ce qui vient de Prisma n’est pas forcément ce qui doit être renvoyé tel quel au client.
4.5 Les décorateurs et conventions NestJS
Fichier observé : @1hand/decorators/current-user.decorator.ts
Ce décorateur permet de récupérer l’utilisateur courant depuis request.user.
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];
},
);
Pourquoi c’est intéressant
Cela évite de répéter dans chaque controller :
const user = req.user;
Le décorateur rend le code plus lisible.
Exemple :
create(@Body() dto: CreatePostDto, @CurrentUser('id') userId: string) {
return this.service.create(dto, userId);
}
Ce que le développeur doit comprendre
Quand un besoin technique ou ergonomique revient dans plusieurs controllers, il faut envisager :
- un décorateur,
- un pipe,
- un guard,
- un helper,
- un service partagé.
4.6 Les services techniques partagés
4.6.1 Module upload
Le module upload montre plusieurs bonnes idées d’architecture :
- validation métier sur le type MIME,
- contrôle d’existence du compte qui upload,
- enrichissement des métadonnées image avec
sharp, - persistance d’un enregistrement
mediavia Prisma, - encapsulation de la logique d’upload dans un service dédié.
La leçon importante est la suivante :
une capacité technique réutilisable mérite son propre module.
Si plusieurs features peuvent utiliser l’upload, alors UploadService devient une dépendance transverse claire, au lieu de dupliquer cette logique dans chaque feature métier.
4.6.2 Module pdf-module
Le module PDF contient :
- des types dédiés,
- un moteur de génération,
- une registry de templates,
- une gestion de concurrence,
- un contrôle fin autour de Puppeteer.
Même si ce module est plus technique, il donne une leçon fondamentale :
quand une logique devient complexe, elle doit être encapsulée derrière une interface claire.
Le développeur métier n’a pas besoin de savoir comment Puppeteer gère ses pages internes. Il a besoin d’un service capable de produire un PDF à partir de données.
4.7 Comment un nouveau développeur doit s’appuyer sur ces fondations
Avant d’ajouter une feature, suivre mentalement cette checklist :
- est-ce qu’un type partagé existe déjà ?
- est-ce qu’un schéma Joi réutilisable existe déjà ?
- est-ce qu’un helper utilitaire couvre déjà une partie du besoin ?
- est-ce qu’un service technique existe déjà au lieu de recoder la logique ?
- est-ce que le module à créer est purement métier ou dépend-il d’un module transverse ?
La réponse idéale ressemble souvent à ceci :
- le module métier porte uniquement la logique de domaine,
- les helpers partagés portent la logique transverse,
- les services techniques portent les comportements réutilisables,
- les relations entre modules passent par les services et non par des accès Prisma croisés désordonnés.
5. Documentation détaillée des modules analysés
5.1 Module academic-levels
Structure observée
academic-levels.types.tsacademic-levels.validation.tsacademic-levels.controller.tsacademic-levels.service.tsacademic-levels.module.ts
Ce que gère ce module
Ce module gère une entité academicLevel avec :
- création,
- mise à jour,
- suppression,
- récupération par identifiant,
- récupération filtrée.
Types
Le module définit :
AcademicLevelTypeEnumCreateAcademicLevelDtoUpdateAcademicLevelDtoFilterAcademicLevelDto
Cela montre une structure classique :
- un DTO pour créer,
- un DTO pour modifier,
- un DTO pour filtrer les listings.
Validation
Le module définit trois schémas Joi :
CreateAcademicLevelSchemaUpdateAcademicLevelSchemaFilterAcademicLevelSchema
C’est exactement le trio à reproduire dans une feature simple.
Controller
Le controller expose les endpoints suivants :
POST /academic-levelsPATCH /academic-levels(incohérence, voir ci-dessous)DELETE /academic-levels/:idGET /academic-levels/:idGET /academic-levels
Ce qu’il faut noter
La méthode update reçoit @Param('code') code, mais la route @Patch() ne déclare pas :code.
C’est une incohérence.
Leçon pour le développeur
Quand vous écrivez un update, il faut choisir une convention claire :
- soit
PATCH /resource/:id, - soit
PATCH /resource/:code, - mais le controller, le DTO, la validation et le service doivent être alignés.
Service
Le service contient :
createupdatedeletefindOneByIdfindOneByCodefindAll
Points intéressants
creategénère un code métier avecgenerateMatricule('ACADEMIC_LEVEL').updatevérifie l’existence avant modification.findAllapplique pagination et tri.- la suppression ici est un
deletephysique.
Leçon importante
Le module montre la forme générale d’un CRUD, mais il n’applique pas partout les mêmes conventions que academic-years.
Cela rappelle qu’en rejoignant le projet, le développeur doit viser une architecture plus cohérente que l’échantillon existant quand plusieurs variantes sont observées.
5.2 Module academic-years
Structure observée
academic-years.types.tsacademic-years.validation.tsacademic-years.controller.tsacademic-years.service.tsacademic-years.module.ts
Ce que gère ce module
Ce module est plus riche que academic-levels.
Il gère :
- création,
- mise à jour,
- suppression logique,
- listing paginé,
- lecture par code,
- recherche de l’année courante,
- activation d’une année avec désactivation des autres.
Ce que cela révèle
Ce module n’est pas seulement un CRUD brut. Il inclut une logique métier plus forte.
C’est un bon exemple de ce qui distingue un service applicatif d’un simple repository.
Controller
Endpoints observés :
POST /academic-yearsGET /academic-years/current?schoolId=...GET /academic-years/school/:schoolId/currentGET /academic-yearsGET /academic-years/:codePATCH /academic-years/:codeDELETE /academic-years/:codePATCH /academic-years/:code/activate
Ce qu’un développeur doit comprendre
Un module peut exposer deux types d’endpoints :
- des endpoints CRUD standards,
- des endpoints métier dédiés.
Ici, activateAcademicYear est un endpoint métier.
C’est une bonne pratique quand une action a un sens métier propre.
Service
Le service montre plusieurs comportements intéressants.
Vérification d’existence d’une relation externe
Avant de créer une année académique, le service vérifie que l’école existe.
const existing = await this.prisma.school.findUnique({
where: { id: data.schoolId },
});
Cela évite d’écrire des données incohérentes.
Utilisation de l’i18n pour les erreurs
Le service traduit ses erreurs avec I18nService.
Le développeur doit retenir que les messages d’erreur métier ne devraient pas être dispersés en chaînes dures dans toute l’application.
Soft delete
La suppression ne fait pas un delete physique.
Elle met :
data: {
deleted: true;
}
Cela indique que le projet préfère conserver l’historique métier.
Listing paginé cohérent
Le service retourne :
{
page,
limit,
total,
data,
}
C’est une bonne structure à reproduire.
Transaction métier
La méthode activateAcademicYear utilise une transaction Prisma pour garantir qu’une seule année est active par école.
C’est exactement le bon usage d’une transaction : protéger une règle métier impliquant plusieurs écritures.
5.3 Ce que ces deux modules enseignent sur l’architecture
En combinant academic-levels et academic-years, on observe la logique suivante :
- un module simple commence par un CRUD clair,
- un module plus mature ajoute des opérations métier dédiées,
- les contrôles de cohérence doivent vivre dans le service,
- la validation doit être séparée,
- la pagination doit être explicite,
- les relations externes doivent être vérifiées,
- les conventions doivent être alignées entre route, DTO, validation et service.
6. Logique de construction des modules
6.1 La chaîne complète de traitement
Quand une requête HTTP arrive, un module bien construit suit généralement cette séquence :
- le controller reçoit la requête,
- le pipe Joi valide les données,
- le controller extrait les paramètres utiles,
- le service exécute les contrôles métier,
- le service appelle Prisma,
- le service retourne une réponse propre,
- le controller renvoie cette réponse au client.
Exemple mental :
- le client appelle
POST /posts, CreatePostSchemavalide le body,PostsController.create()transmet au service,PostsService.create()vérifie que l’auteur existe,- Prisma crée le post,
- la réponse est renvoyée.
6.2 Les responsabilités exactes de chaque fichier
*.types.ts
Ce fichier définit les contrats des entrées et parfois des sorties.
Il ne doit pas contenir la logique métier.
*.validation.ts
Ce fichier contient les schémas Joi.
Il ne doit pas contenir d’accès base de données.
*.controller.ts
Ce fichier mappe les routes HTTP vers les méthodes du service.
Il doit rester mince.
*.service.ts
Ce fichier porte le comportement métier.
Il peut :
- vérifier des entités,
- décider si une action est autorisée,
- faire des transactions,
- appeler Prisma,
- appeler d’autres services.
*.module.ts
Ce fichier assemble les dépendances.
C’est ici que l’indépendance d’un module devient visible.
Si PostsService dépend de CommentsService, alors PostsModule doit importer CommentsModule et utiliser un export propre.
6.3 La règle d’indépendance des modules
C’est une règle centrale pour ce guide corrigé.
La règle
Chaque module doit rester responsable de sa propre entité.
Donc :
AuthorsServicemanipuleauthor,PostsServicemanipulepost,CommentsServicemanipulecomment.
Ce qu’il ne faut pas faire
Dans PostsService, il ne faut pas écrire :
await this.prisma.comment.create(...)
si comments possède déjà son propre module et son propre service.
Ce qu’il faut faire
PostsService doit appeler CommentsService.
Exemple :
return this.commentsService.createForPost(postId, dto);
Pourquoi cette règle est importante
Parce qu’elle garantit :
- une seule source de vérité pour la logique des commentaires,
- moins de duplication,
- une meilleure testabilité,
- des règles métier centralisées,
- une meilleure lisibilité du projet.
Comment reconnaître une bonne frontière de module
Un module est bien découpé quand :
- toutes les règles métier de son entité sont chez lui,
- les autres modules le consomment via son service,
- personne n’écrit sa logique à sa place.
6.4 Les erreurs fréquentes à éviter
Erreur 1 : mélanger validation et logique métier
La validation structurelle relève du pipe Joi. Les contrôles métier relèvent du service.
Erreur 2 : écrire un service “omnipotent”
Si PostsService commence à gérer les auteurs, les commentaires, les likes, les pièces jointes et la modération directement via Prisma, le module devient illisible.
Erreur 3 : faire du CRUD incomplet
Si une table existe, le développeur doit se demander si elle mérite son CRUD complet.
Dans ce guide, oui :
authorsa son CRUD,booksa son CRUD,videosa son CRUD,book-viewsa son CRUD,video-viewsa son CRUD,book-commentsa son CRUD,video-commentsa son CRUD.
Erreur 4 : accéder directement à Prisma sur une entité étrangère
C’est précisément le problème à éviter dans le tutoriel posts/comments.
Erreur 5 : oublier le soft delete si le projet le privilégie
Si le projet a une convention deleted / archived, la nouvelle feature doit suivre cette convention.
7. Structure recommandée pour une nouvelle 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
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
Cette structure est volontairement répétitive.
Elle permet à un développeur qui découvre le projet de savoir immédiatement :
- où se trouvent les routes,
- où se trouvent les DTOs,
- où se trouvent les validations,
- où se trouve la logique métier,
- où se trouve la transformation de l’entité vers le DTO.
8. Tutoriel pratique : construire authors, books, videos, book-views, video-views, book-comments et video-comments
8.1 Objectif du tutoriel
Ce tutoriel est un Nest.js hands-on dont le but est de construire une API complète autour d’auteurs qui publient des livres et des vidéos.
L’objectif n’est pas seulement de coder des endpoints CRUD. L’objectif est de reproduire la logique de conception du projet :
- chaque ressource possède son propre module ;
- chaque module possède son controller, son service, ses DTOs, ses validations et son mapper ;
- les controllers restent fins ;
- les services portent la logique métier ;
- les DTOs sont documentés avec Swagger ;
- les entrées sont validées avec Joi ;
- les services ne retournent pas directement les entités Prisma ;
- les services utilisent un mapper pour transformer l’entité en DTO ;
- les routes de lecture, mise à jour et suppression utilisent l’
id; - la mise à jour utilise
PUT; - les listings utilisent
selectMany(filter); - la lecture d’un élément utilise
selectById(id)côté controller et côté service.
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 Ressources et endpoints CRUD
Les endpoints ci-dessous sont normalisés selon la convention du projet :
POST /resourcespour créer ;GET /resourcespour lister avec des filtres en query params ;GET /resources/:idpour lire un élément ;PUT /resources/:idpour mettre à jour ;DELETE /resources/:idpour supprimer ;- le controller utilise
selectById(id); - le service utilise
selectById(id).
8.3.1 Author CRUD
Authors are users who can publish books and videos, view other authors’ content, and comment on it.
| Operation | Description | Endpoint | Controller method | Service method |
|---|---|---|---|---|
| Create | Create a new author | POST /authors | create(dto) | create(dto) |
| Read | Get all authors | GET /authors | selectMany(filter) | selectMany(filter) |
| Read | Get one author by ID | GET /authors/:id | selectById(id) | selectById(id) |
| Update | Update author information | PUT /authors/:id | update(id, body) | update(id, body) |
| Delete | Delete an author | DELETE /authors/:id | remove(id) | remove(id) |
8.3.2 Book CRUD
Books are published by authors. One author can have many books.
| Operation | Description | Endpoint | Controller method | Service method |
|---|---|---|---|---|
| Create | Create a new book | POST /books | create(dto) | create(dto) |
| Read | Get all books | GET /books | selectMany(filter) | selectMany(filter) |
| Read | Get one book by ID | GET /books/:id | selectById(id) | selectById(id) |
| Read | Get all books by one author | GET /books?authorId=... | selectMany(filter) | selectMany(filter) |
| Update | Update a book | PUT /books/:id | update(id, body) | update(id, body) |
| Delete | Delete a book | DELETE /books/:id | remove(id) | remove(id) |
Example book creation body:
{
"title": "My First Book",
"description": "A short description of the book",
"authorId": "author-id-1"
}
8.3.3 Video CRUD
Videos are also published by authors. One author can have many videos.
| Operation | Description | Endpoint | Controller method | Service method |
|---|---|---|---|---|
| Create | Create a new video | POST /videos | create(dto) | create(dto) |
| Read | Get all videos | GET /videos | selectMany(filter) | selectMany(filter) |
| Read | Get one video by ID | GET /videos/:id | selectById(id) | selectById(id) |
| Read | Get all videos by one author | GET /videos?authorId=... | selectMany(filter) | selectMany(filter) |
| Update | Update a video | PUT /videos/:id | update(id, body) | update(id, body) |
| Delete | Delete a video | DELETE /videos/:id | remove(id) | remove(id) |
Example video creation body:
{
"title": "My First Video",
"description": "A short description of the video",
"url": "https://example.com/video.mp4",
"authorId": "author-id-1"
}
8.3.4 Book View CRUD
Book views represent authors viewing books published by other authors.
| Operation | Description | Endpoint | Controller method | Service method |
|---|---|---|---|---|
| Create | Register a view on a book | POST /book-views | create(dto) | create(dto) |
| Read | Get all book views | GET /book-views | selectMany(filter) | selectMany(filter) |
| Read | Get one book view by ID | GET /book-views/:id | selectById(id) | selectById(id) |
| Read | Get views by book | GET /book-views?bookId=... | selectMany(filter) | selectMany(filter) |
| Read | Get views by author | GET /book-views?authorId=... | selectMany(filter) | selectMany(filter) |
| Update | Update a book view | PUT /book-views/:id | update(id, body) | update(id, body) |
| Delete | Delete a book view record | DELETE /book-views/:id | remove(id) | remove(id) |
Example body:
{
"bookId": "book-id-1",
"authorId": "author-id-2"
}
8.3.5 Video View CRUD
Video views represent authors viewing videos published by other authors.
| Operation | Description | Endpoint | Controller method | Service method |
|---|---|---|---|---|
| Create | Register a view on a video | POST /video-views | create(dto) | create(dto) |
| Read | Get all video views | GET /video-views | selectMany(filter) | selectMany(filter) |
| Read | Get one video view by ID | GET /video-views/:id | selectById(id) | selectById(id) |
| Read | Get views by video | GET /video-views?videoId=... | selectMany(filter) | selectMany(filter) |
| Read | Get views by author | GET /video-views?authorId=... | selectMany(filter) | selectMany(filter) |
| Update | Update a video view | PUT /video-views/:id | update(id, body) | update(id, body) |
| Delete | Delete a video view record | DELETE /video-views/:id | remove(id) | remove(id) |
Example body:
{
"videoId": "video-id-1",
"authorId": "author-id-2"
}
8.3.6 Book Comment CRUD
Authors can comment on books written by other authors.
| Operation | Description | Endpoint | Controller method | Service method |
|---|---|---|---|---|
| Create | Add a comment to a book | POST /book-comments | create(dto) | create(dto) |
| Read | Get all book comments | GET /book-comments | selectMany(filter) | selectMany(filter) |
| Read | Get one book comment by ID | GET /book-comments/:id | selectById(id) | selectById(id) |
| Read | Get comments by book | GET /book-comments?bookId=... | selectMany(filter) | selectMany(filter) |
| Read | Get comments by author | GET /book-comments?authorId=... | selectMany(filter) | selectMany(filter) |
| Update | Update a book comment | PUT /book-comments/:id | update(id, body) | update(id, body) |
| Delete | Delete a book comment | DELETE /book-comments/:id | remove(id) | remove(id) |
Example body:
{
"bookId": "book-id-1",
"authorId": "author-id-2",
"content": "This is a very interesting book."
}
8.3.7 Video Comment CRUD
Authors can also comment on videos published by other authors.
| Operation | Description | Endpoint | Controller method | Service method |
|---|---|---|---|---|
| Create | Add a comment to a video | POST /video-comments | create(dto) | create(dto) |
| Read | Get all video comments | GET /video-comments | selectMany(filter) | selectMany(filter) |
| Read | Get one video comment by ID | GET /video-comments/:id | selectById(id) | selectById(id) |
| Read | Get comments by video | GET /video-comments?videoId=... | selectMany(filter) | selectMany(filter) |
| Read | Get comments by author | GET /video-comments?authorId=... | selectMany(filter) | selectMany(filter) |
| Update | Update a video comment | PUT /video-comments/:id | update(id, body) | update(id, body) |
| Delete | Delete a video comment | DELETE /video-comments/:id | remove(id) | remove(id) |
Example body:
{
"videoId": "video-id-1",
"authorId": "author-id-2",
"content": "Great video!"
}
8.4 Diagramme de base de données

8.5 Diagramme backend des modules
Ce diagramme représente la structure backend à reproduire pour chaque module.
- Le bloc bleu représente le
Controller. - Le bloc vert représente le
Service. - Le fichier
.mapper.tsest placé dans le bloc du service, car le service l’utilise au moment de préparer la réponse. - L’entité représente l’objet manipulé côté base de données.
- Les méthodes du controller et du service doivent respecter les noms du projet.

8.6 Diagramme frontend analogue
Le frontend reprend la même logique, mais le bloc Controller devient un HttpClient, le fichier .mapper.ts devient un fichier .adapter.ts, et l’entité est remplacée par l’écran visible par l’utilisateur.
- Le bloc bleu représente le
HttpClient. - Le bloc vert représente le
Servicefrontend. - Le fichier
.adapter.tsest placé dans le bloc du service. - Le rectangle violet représente l’écran ou l’interface graphique vue par l’utilisateur.
flowchart TD
APP[Frontend Application]
APP --> AUTHORS
APP --> BOOKS
APP --> VIDEOS
APP --> BOOK_VIEWS
APP --> VIDEO_VIEWS
APP --> BOOK_COMMENTS
APP --> VIDEO_COMMENTS
%% AUTHORS
subgraph AUTHORS[Authors Module]
direction TB
subgraph A_HTTP_BOX[AuthorsHttpClient]
direction TB
A_C1["GET /authors?filters<br/>selectMany(filter: FilterAuthorDto)"]
A_C2["GET /authors/:id<br/>selectById(id)"]
A_C3["POST /authors<br/>create(dto: CreateAuthorDto)"]
A_C4["PUT /authors/:id<br/>update(id, body: UpdateAuthorDto)"]
A_C5["DELETE /authors/:id<br/>remove(id)"]
end
subgraph A_SRV_BOX[AuthorsService]
direction TB
A_S1["selectMany(filter: FilterAuthorDto)"]
A_S2["selectById(id)"]
A_S3["create(dto: CreateAuthorDto)"]
A_S4["update(id, body: UpdateAuthorDto)"]
A_S5["remove(id)"]
A_ADAPTER["authors.adapter.ts<br/>toAuthorViewModel(dto): AuthorViewModel"]
end
A_TYPES["authors.types.ts<br/>AuthorDto<br/>CreateAuthorDto<br/>UpdateAuthorDto<br/>FilterAuthorDto"]
A_VALID["authors.validation.ts<br/>Joi validation"]
A_SCREEN["Authors Screen<br/>Graphical Interface"]
A_C1 --> A_S1
A_C2 --> A_S2
A_C3 --> A_S3
A_C4 --> A_S4
A_C5 --> A_S5
A_S1 --> A_SCREEN
A_S2 --> A_SCREEN
A_S3 --> A_SCREEN
A_S4 --> A_SCREEN
A_S5 --> A_SCREEN
A_HTTP_BOX -. uses .-> A_TYPES
A_HTTP_BOX -. validates with .-> A_VALID
end
%% BOOKS
subgraph BOOKS[Books Module]
direction TB
subgraph B_HTTP_BOX[BooksHttpClient]
direction TB
B_C1["GET /books?filters<br/>selectMany(filter: FilterBookDto)"]
B_C2["GET /books/:id<br/>selectById(id)"]
B_C3["POST /books<br/>create(dto: CreateBookDto)"]
B_C4["PUT /books/:id<br/>update(id, body: UpdateBookDto)"]
B_C5["DELETE /books/:id<br/>remove(id)"]
end
subgraph B_SRV_BOX[BooksService]
direction TB
B_S1["selectMany(filter: FilterBookDto)"]
B_S2["selectById(id)"]
B_S3["create(dto: CreateBookDto)"]
B_S4["update(id, body: UpdateBookDto)"]
B_S5["remove(id)"]
B_ADAPTER["books.adapter.ts<br/>toBookViewModel(dto): BookViewModel"]
end
B_TYPES["books.types.ts<br/>BookDto<br/>CreateBookDto<br/>UpdateBookDto<br/>FilterBookDto"]
B_VALID["books.validation.ts<br/>Joi validation"]
B_SCREEN["Books Screen<br/>Graphical Interface"]
B_C1 --> B_S1
B_C2 --> B_S2
B_C3 --> B_S3
B_C4 --> B_S4
B_C5 --> B_S5
B_S1 --> B_SCREEN
B_S2 --> B_SCREEN
B_S3 --> B_SCREEN
B_S4 --> B_SCREEN
B_S5 --> B_SCREEN
B_HTTP_BOX -. uses .-> B_TYPES
B_HTTP_BOX -. validates with .-> B_VALID
end
%% VIDEOS
subgraph VIDEOS[Videos Module]
direction TB
subgraph V_HTTP_BOX[VideosHttpClient]
direction TB
V_C1["GET /videos?filters<br/>selectMany(filter: FilterVideoDto)"]
V_C2["GET /videos/:id<br/>selectById(id)"]
V_C3["POST /videos<br/>create(dto: CreateVideoDto)"]
V_C4["PUT /videos/:id<br/>update(id, body: UpdateVideoDto)"]
V_C5["DELETE /videos/:id<br/>remove(id)"]
end
subgraph V_SRV_BOX[VideosService]
direction TB
V_S1["selectMany(filter: FilterVideoDto)"]
V_S2["selectById(id)"]
V_S3["create(dto: CreateVideoDto)"]
V_S4["update(id, body: UpdateVideoDto)"]
V_S5["remove(id)"]
V_ADAPTER["videos.adapter.ts<br/>toVideoViewModel(dto): VideoViewModel"]
end
V_TYPES["videos.types.ts<br/>VideoDto<br/>CreateVideoDto<br/>UpdateVideoDto<br/>FilterVideoDto"]
V_VALID["videos.validation.ts<br/>Joi validation"]
V_SCREEN["Videos Screen<br/>Graphical Interface"]
V_C1 --> V_S1
V_C2 --> V_S2
V_C3 --> V_S3
V_C4 --> V_S4
V_C5 --> V_S5
V_S1 --> V_SCREEN
V_S2 --> V_SCREEN
V_S3 --> V_SCREEN
V_S4 --> V_SCREEN
V_S5 --> V_SCREEN
V_HTTP_BOX -. uses .-> V_TYPES
V_HTTP_BOX -. validates with .-> V_VALID
end
%% BOOK VIEWS
subgraph BOOK_VIEWS[BookViews Module]
direction TB
subgraph BV_HTTP_BOX[BookViewsHttpClient]
direction TB
BV_C1["GET /book-views?filters<br/>selectMany(filter: FilterBookViewDto)"]
BV_C2["GET /book-views/:id<br/>selectById(id)"]
BV_C3["POST /book-views<br/>create(dto: CreateBookViewDto)"]
BV_C4["PUT /book-views/:id<br/>update(id, body: UpdateBookViewDto)"]
BV_C5["DELETE /book-views/:id<br/>remove(id)"]
end
subgraph BV_SRV_BOX[BookViewsService]
direction TB
BV_S1["selectMany(filter: FilterBookViewDto)"]
BV_S2["selectById(id)"]
BV_S3["create(dto: CreateBookViewDto)"]
BV_S4["update(id, body: UpdateBookViewDto)"]
BV_S5["remove(id)"]
BV_ADAPTER["book-views.adapter.ts<br/>toBookViewViewModel(dto): BookViewViewModel"]
end
BV_TYPES["book-views.types.ts<br/>BookViewDto<br/>CreateBookViewDto<br/>UpdateBookViewDto<br/>FilterBookViewDto"]
BV_VALID["book-views.validation.ts<br/>Joi validation"]
BV_SCREEN["BookViews Screen<br/>Graphical Interface"]
BV_C1 --> BV_S1
BV_C2 --> BV_S2
BV_C3 --> BV_S3
BV_C4 --> BV_S4
BV_C5 --> BV_S5
BV_S1 --> BV_SCREEN
BV_S2 --> BV_SCREEN
BV_S3 --> BV_SCREEN
BV_S4 --> BV_SCREEN
BV_S5 --> BV_SCREEN
BV_HTTP_BOX -. uses .-> BV_TYPES
BV_HTTP_BOX -. validates with .-> BV_VALID
end
%% VIDEO VIEWS
subgraph VIDEO_VIEWS[VideoViews Module]
direction TB
subgraph VV_HTTP_BOX[VideoViewsHttpClient]
direction TB
VV_C1["GET /video-views?filters<br/>selectMany(filter: FilterVideoViewDto)"]
VV_C2["GET /video-views/:id<br/>selectById(id)"]
VV_C3["POST /video-views<br/>create(dto: CreateVideoViewDto)"]
VV_C4["PUT /video-views/:id<br/>update(id, body: UpdateVideoViewDto)"]
VV_C5["DELETE /video-views/:id<br/>remove(id)"]
end
subgraph VV_SRV_BOX[VideoViewsService]
direction TB
VV_S1["selectMany(filter: FilterVideoViewDto)"]
VV_S2["selectById(id)"]
VV_S3["create(dto: CreateVideoViewDto)"]
VV_S4["update(id, body: UpdateVideoViewDto)"]
VV_S5["remove(id)"]
VV_ADAPTER["video-views.adapter.ts<br/>toVideoViewViewModel(dto): VideoViewViewModel"]
end
VV_TYPES["video-views.types.ts<br/>VideoViewDto<br/>CreateVideoViewDto<br/>UpdateVideoViewDto<br/>FilterVideoViewDto"]
VV_VALID["video-views.validation.ts<br/>Joi validation"]
VV_SCREEN["VideoViews Screen<br/>Graphical Interface"]
VV_C1 --> VV_S1
VV_C2 --> VV_S2
VV_C3 --> VV_S3
VV_C4 --> VV_S4
VV_C5 --> VV_S5
VV_S1 --> VV_SCREEN
VV_S2 --> VV_SCREEN
VV_S3 --> VV_SCREEN
VV_S4 --> VV_SCREEN
VV_S5 --> VV_SCREEN
VV_HTTP_BOX -. uses .-> VV_TYPES
VV_HTTP_BOX -. validates with .-> VV_VALID
end
%% BOOK COMMENTS
subgraph BOOK_COMMENTS[BookComments Module]
direction TB
subgraph BC_HTTP_BOX[BookCommentsHttpClient]
direction TB
BC_C1["GET /book-comments?filters<br/>selectMany(filter: FilterBookCommentDto)"]
BC_C2["GET /book-comments/:id<br/>selectById(id)"]
BC_C3["POST /book-comments<br/>create(dto: CreateBookCommentDto)"]
BC_C4["PUT /book-comments/:id<br/>update(id, body: UpdateBookCommentDto)"]
BC_C5["DELETE /book-comments/:id<br/>remove(id)"]
end
subgraph BC_SRV_BOX[BookCommentsService]
direction TB
BC_S1["selectMany(filter: FilterBookCommentDto)"]
BC_S2["selectById(id)"]
BC_S3["create(dto: CreateBookCommentDto)"]
BC_S4["update(id, body: UpdateBookCommentDto)"]
BC_S5["remove(id)"]
BC_ADAPTER["book-comments.adapter.ts<br/>toBookCommentViewModel(dto): BookCommentViewModel"]
end
BC_TYPES["book-comments.types.ts<br/>BookCommentDto<br/>CreateBookCommentDto<br/>UpdateBookCommentDto<br/>FilterBookCommentDto"]
BC_VALID["book-comments.validation.ts<br/>Joi validation"]
BC_SCREEN["BookComments Screen<br/>Graphical Interface"]
BC_C1 --> BC_S1
BC_C2 --> BC_S2
BC_C3 --> BC_S3
BC_C4 --> BC_S4
BC_C5 --> BC_S5
BC_S1 --> BC_SCREEN
BC_S2 --> BC_SCREEN
BC_S3 --> BC_SCREEN
BC_S4 --> BC_SCREEN
BC_S5 --> BC_SCREEN
BC_HTTP_BOX -. uses .-> BC_TYPES
BC_HTTP_BOX -. validates with .-> BC_VALID
end
%% VIDEO COMMENTS
subgraph VIDEO_COMMENTS[VideoComments Module]
direction TB
subgraph VC_HTTP_BOX[VideoCommentsHttpClient]
direction TB
VC_C1["GET /video-comments?filters<br/>selectMany(filter: FilterVideoCommentDto)"]
VC_C2["GET /video-comments/:id<br/>selectById(id)"]
VC_C3["POST /video-comments<br/>create(dto: CreateVideoCommentDto)"]
VC_C4["PUT /video-comments/:id<br/>update(id, body: UpdateVideoCommentDto)"]
VC_C5["DELETE /video-comments/:id<br/>remove(id)"]
end
subgraph VC_SRV_BOX[VideoCommentsService]
direction TB
VC_S1["selectMany(filter: FilterVideoCommentDto)"]
VC_S2["selectById(id)"]
VC_S3["create(dto: CreateVideoCommentDto)"]
VC_S4["update(id, body: UpdateVideoCommentDto)"]
VC_S5["remove(id)"]
VC_ADAPTER["video-comments.adapter.ts<br/>toVideoCommentViewModel(dto): VideoCommentViewModel"]
end
VC_TYPES["video-comments.types.ts<br/>VideoCommentDto<br/>CreateVideoCommentDto<br/>UpdateVideoCommentDto<br/>FilterVideoCommentDto"]
VC_VALID["video-comments.validation.ts<br/>Joi validation"]
VC_SCREEN["VideoComments Screen<br/>Graphical Interface"]
VC_C1 --> VC_S1
VC_C2 --> VC_S2
VC_C3 --> VC_S3
VC_C4 --> VC_S4
VC_C5 --> VC_S5
VC_S1 --> VC_SCREEN
VC_S2 --> VC_SCREEN
VC_S3 --> VC_SCREEN
VC_S4 --> VC_SCREEN
VC_S5 --> VC_SCREEN
VC_HTTP_BOX -. uses .-> VC_TYPES
VC_HTTP_BOX -. validates with .-> VC_VALID
end
style A_HTTP_BOX fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style A_SRV_BOX fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style A_ADAPTER fill:#ffedd5,stroke:#f97316,stroke-width:2px
style A_SCREEN fill:#f3e8ff,stroke:#9333ea,stroke-width:2px
style B_HTTP_BOX fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style B_SRV_BOX fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style B_ADAPTER fill:#ffedd5,stroke:#f97316,stroke-width:2px
style B_SCREEN fill:#f3e8ff,stroke:#9333ea,stroke-width:2px
style V_HTTP_BOX fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style V_SRV_BOX fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style V_ADAPTER fill:#ffedd5,stroke:#f97316,stroke-width:2px
style V_SCREEN fill:#f3e8ff,stroke:#9333ea,stroke-width:2px
style BV_HTTP_BOX fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style BV_SRV_BOX fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style BV_ADAPTER fill:#ffedd5,stroke:#f97316,stroke-width:2px
style BV_SCREEN fill:#f3e8ff,stroke:#9333ea,stroke-width:2px
style VV_HTTP_BOX fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style VV_SRV_BOX fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style VV_ADAPTER fill:#ffedd5,stroke:#f97316,stroke-width:2px
style VV_SCREEN fill:#f3e8ff,stroke:#9333ea,stroke-width:2px
style BC_HTTP_BOX fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style BC_SRV_BOX fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style BC_ADAPTER fill:#ffedd5,stroke:#f97316,stroke-width:2px
style BC_SCREEN fill:#f3e8ff,stroke:#9333ea,stroke-width:2px
style VC_HTTP_BOX fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style VC_SRV_BOX fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style VC_ADAPTER fill:#ffedd5,stroke:#f97316,stroke-width:2px
style VC_SCREEN fill:#f3e8ff,stroke:#9333ea,stroke-width:2px
8.7 Structure cible du projet backend
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 Schéma Prisma MySQL
Créer ou compléter 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
firstName String
lastName String
email String @unique
bio String?
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? @db.Text
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? @db.Text
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
viewedAt DateTime @default(now())
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_views")
}
model VideoView {
id String @id @default(uuid())
code String @unique
videoId String
authorId String
viewedAt DateTime @default(now())
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_views")
}
model BookComment {
id String @id @default(uuid())
code String @unique
bookId String
authorId String
content String @db.Text
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 @db.Text
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")
}
Après avoir défini le schéma :
npx prisma generate
npx prisma migrate dev --name init_authors_books_videos
8.9 DTOs, validations et mappers à créer
Chaque module doit avoir un fichier <module>.types.ts, un fichier <module>.validation.ts et un fichier <module>.mapper.ts.
| Module | DTO de sortie | 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) |
Les classes DTO doivent être documentées avec Swagger :
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
Les validations doivent utiliser Joi :
import * as Joi from "joi";
Les mappers ne doivent pas faire d’appel Prisma. Ils doivent seulement transformer une entité en DTO.
8.10 Pattern du controller à écrire
Tous les controllers CRUD doivent suivre la même nomenclature de méthodes.
Les noms des méthodes du controller sont toujours :
create(dto);
selectMany(filter);
selectById(id);
update(id, body);
remove(id);
Il ne faut pas créer des méthodes comme :
createBook(dto);
selectManyBooks(filter);
selectBookById(id);
updateBook(id, body);
removeBook(id);
Le nom de la ressource est déjà porté par le controller lui-même, par exemple BooksController. Les méthodes restent donc simples et génériques.
Exemple de pattern correct avec 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);
}
}
À retenir :
createexiste côté controller ;selectManyexiste côté controller ;selectByIdexiste côté controller ;updateexiste côté controller ;removeexiste côté controller ;- le controller délègue au service ;
- le controller ne fait pas de logique métier ;
- le controller ne fait pas d’appel Prisma.
8.11 Pattern du service à écrire
Tous les services CRUD doivent suivre la même nomenclature de méthodes.
Les noms des méthodes du service sont toujours :
create(dto);
selectMany(filter);
selectById(id);
update(id, body);
remove(id);
Il ne faut pas créer des méthodes comme :
createBook(dto);
selectManyBooks(filter);
selectBookById(id);
updateBook(id, body);
removeBook(id);
Le nom de la ressource est déjà porté par le service lui-même, par exemple BooksService. Les méthodes restent donc simples et génériques.
Exemple de pattern correct avec 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);
}
}
À retenir :
createexiste côté service ;selectManyexiste côté service ;selectByIdexiste côté service ;updateexiste côté service ;removeexiste côté service ;- le service manipule son entité ;
- le service vérifie les relations avec les autres services ;
- le service ne retourne pas directement l’entité ;
- le service utilise son mapper avant de retourner une réponse ;
remove(id)applique le soft delete si la convention du projet le prévoit.
8.12 Exemple complet : module books
Cette section montre le code détaillé à écrire pour un module. Les autres modules reprennent le même pattern avec leurs champs spécifiques.
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()
authorName: 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.author?.id,
authorName: entity.author
? `${entity.author.firstName} ${entity.author.lastName}`
: undefined,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}
8.12.4 books.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Put,
Post,
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 à faire pour les autres modules
Le module books sert de modèle. Les autres modules suivent la même structure avec leurs champs spécifiques.
8.13.1 Module authors
Champs principaux :
firstNamelastNameemailbio
Méthodes à écrire :
create(dto: CreateAuthorDto)
selectMany(filter: FilterAuthorDto)
selectById(id: string)
update(id: string, body: UpdateAuthorDto)
delete(id: string)
Le service doit vérifier l’unicité de l’email avant la création et avant la mise à jour.
8.13.2 Module videos
Champs principaux :
titledescriptionurlauthorId
Le service doit :
- appeler
AuthorsService.selectById(dto.authorId); - créer la vidéo avec
authorId; - inclure l’auteur dans la requête Prisma ;
- retourner
toVideoDto(entity).
8.13.3 Module book-views
Champs principaux :
bookIdauthorIdviewedAt
Le service doit :
- appeler
BooksService.selectById(dto.bookId); - appeler
AuthorsService.selectById(dto.authorId); - créer la vue avec
bookIdetauthorId; - retourner
toBookViewDto(entity).
8.13.4 Module video-views
Champs principaux :
videoIdauthorIdviewedAt
Le service doit :
- appeler
VideosService.selectById(dto.videoId); - appeler
AuthorsService.selectById(dto.authorId); - créer la vue avec
videoIdetauthorId; - retourner
toVideoViewDto(entity).
8.13.5 Module book-comments
Champs principaux :
bookIdauthorIdcontent
Le service doit :
- appeler
BooksService.selectById(dto.bookId); - appeler
AuthorsService.selectById(dto.authorId); - créer le commentaire avec
bookId,authorIdetcontent; - retourner
toBookCommentDto(entity).
8.13.6 Module video-comments
Champs principaux :
videoIdauthorIdcontent
Le service doit :
- appeler
VideosService.selectById(dto.videoId); - appeler
AuthorsService.selectById(dto.authorId); - créer le commentaire avec
videoId,authorIdetcontent; - retourner
toVideoCommentDto(entity).
8.14 Organisation des dépendances entre modules
Les modules doivent collaborer via leurs services.
Exemples :
BooksServiceutiliseAuthorsServicepour vérifier l’auteur ;VideosServiceutiliseAuthorsServicepour vérifier l’auteur ;BookViewsServiceutiliseBooksServiceetAuthorsService;VideoViewsServiceutiliseVideosServiceetAuthorsService;BookCommentsServiceutiliseBooksServiceetAuthorsService;VideoCommentsServiceutiliseVideosServiceetAuthorsService.
Il ne faut pas accéder directement à une entité externe avec Prisma.
Incorrect :
const author = await this.prisma.author.findFirst({
where: { id: dto.authorId },
});
Correct :
const author = await this.authorsService.selectById(dto.authorId);
Pour permettre cela, chaque module doit exporter son service.
Exemple :
@Module({
controllers: [AuthorsController],
providers: [AuthorsService, PrismaService],
exports: [AuthorsService],
})
export class AuthorsModule {}
8.15 Exemple frontend : code à écrire
Le frontend suit la même logique que le backend, mais avec trois éléments principaux :
- un
HttpClientqui connaît les routes HTTP ; - un
Servicequi contient la logique d’orchestration frontend ; - un
Adapterqui transforme les DTOs en données adaptées à l’écran.
8.15.1 Exemple books.http-client.ts
import { HttpClient } from "@/core/http-client";
import {
BookDto,
CreateBookDto,
UpdateBookDto,
FilterBookDto,
} from "./books.types";
export class BooksHttpClient {
constructor(private readonly http: HttpClient) {}
selectMany(filter: FilterBookDto) {
return this.http.get<BookDto[]>("/books", { params: filter });
}
selectById(id: string) {
return this.http.get<BookDto>(`/books/${id}`);
}
create(dto: CreateBookDto) {
return this.http.post<BookDto>("/books", dto);
}
update(id: string, body: UpdateBookDto) {
return this.http.put<BookDto>(`/books/${id}`, body);
}
remove(id: string) {
return this.http.delete<BookDto>(`/books/${id}`);
}
}
8.15.2 Exemple books.adapter.ts
import { BookDto } from "./books.types";
export type BookViewModel = {
id: string;
title: string;
description: string;
authorLabel: string;
};
export function toBookViewModel(dto: BookDto): BookViewModel {
return {
id: dto.id,
title: dto.title,
description: dto.description ?? "",
authorLabel: dto.authorName,
};
}
8.15.3 Exemple books.service.ts côté frontend
import { BooksHttpClient } from "./books.http-client";
import { CreateBookDto, UpdateBookDto, FilterBookDto } from "./books.types";
import { toBookViewModel } from "./books.adapter";
export class BooksService {
constructor(private readonly httpClient: BooksHttpClient) {}
async selectMany(filter: FilterBookDto) {
const response = await this.httpClient.selectMany(filter);
return response.data.map(toBookViewModel);
}
async selectById(id: string) {
const response = await this.httpClient.selectById(id);
return toBookViewModel(response.data);
}
async create(dto: CreateBookDto) {
const response = await this.httpClient.create(dto);
return toBookViewModel(response.data);
}
async update(id: string, body: UpdateBookDto) {
const response = await this.httpClient.update(id, body);
return toBookViewModel(response.data);
}
async remove(id: string) {
const response = await this.httpClient.remove(id);
return toBookViewModel(response.data);
}
}
Le service frontend retourne des objets prêts pour l’écran. Il ne doit pas exposer toute la structure brute de l’API si l’écran n’en a pas besoin.
8.16 Exemples de requêtes HTTP
Créer un auteur
POST /authors
Content-Type: application/json
{
"firstName": "Alice",
"lastName": "Martin",
"email": "alice@example.com",
"bio": "Backend developer"
}
Créer un livre
POST /books
Content-Type: application/json
{
"title": "My First Book",
"description": "A short description of the book",
"authorId": "author-id-1"
}
Créer une vidéo
POST /videos
Content-Type: application/json
{
"title": "My First Video",
"description": "A short description of the video",
"url": "https://example.com/video.mp4",
"authorId": "author-id-1"
}
Enregistrer une vue sur un livre
POST /book-views
Content-Type: application/json
{
"bookId": "book-id-1",
"authorId": "author-id-2"
}
Enregistrer une vue sur une vidéo
POST /video-views
Content-Type: application/json
{
"videoId": "video-id-1",
"authorId": "author-id-2"
}
Ajouter un commentaire sur un livre
POST /book-comments
Content-Type: application/json
{
"bookId": "book-id-1",
"authorId": "author-id-2",
"content": "This is a very interesting book."
}
Ajouter un commentaire sur une vidéo
POST /video-comments
Content-Type: application/json
{
"videoId": "video-id-1",
"authorId": "author-id-2",
"content": "Great video!"
}
Lister les livres d’un auteur
GET /books?authorId=author-id-1&page=1&limit=10
Lister les commentaires d’un livre
GET /book-comments?bookId=book-id-1&page=1&limit=10
Mettre à jour un livre
PUT /books/book-id-1
Content-Type: application/json
{
"title": "Updated Book Title"
}
Supprimer logiquement une vidéo
DELETE /videos/video-id-1
8.17 Checklist de fin de tutoriel
Pour chaque module, vérifier que les fichiers suivants existent :
<module>.module.ts<module>.controller.ts<module>.service.ts<module>.types.ts<module>.validation.ts<module>.mapper.ts
Pour chaque controller, vérifier que les méthodes suivantes existent :
create(dto)selectMany(filter)selectById(id)update(id, body)remove(id)
Pour chaque service, vérifier que les méthodes suivantes existent :
create(dto)selectMany(filter)selectById(id)update(id, body)remove(id)
Pour chaque module, vérifier que :
- les DTOs sont documentés avec Swagger ;
- les validations Joi existent ;
- le mapper transforme l’entité en DTO ;
- le service utilise le mapper avant de retourner une réponse ;
- les routes utilisent
:id; - l’update utilise
PUT; - les listings sont paginés ;
- le soft delete est appliqué si la convention du projet le prévoit ;
- les accès aux entités externes passent par les services des modules concernés.
9. Recommandations pour un nouveau développeur
- Commencer par lire un module simple, puis un module plus métier.
- Reproduire d’abord la structure avant d’innover.
- Garder les controllers fins.
- Mettre la vraie logique dans les services.
- Réutiliser les helpers partagés avant de créer de nouveaux helpers.
- Ne jamais faire d’accès direct à Prisma pour l’entité d’un autre module si ce module possède déjà son service.
- Préférer des conventions cohérentes partout :
:id, pagination, soft delete, format des réponses. - Quand une règle métier touche plusieurs écritures, utiliser une transaction Prisma.
10. Conclusion
L’approche observée dans ce backend repose sur une idée simple mais essentielle :
une feature doit être modulaire, lisible, validée, et responsable de son propre domaine.
Les fichiers analysés montrent plusieurs briques importantes :
- DTOs,
- validation Joi,
- services métier,
- helpers partagés,
- modules techniques réutilisables,
- conventions d’identifiants,
- soft delete,
- pagination,
- transactions.
Le tutoriel corrigé va plus loin et formalise les règles à suivre pour les nouvelles features :
- MySQL pour la base,
- CRUD complet pour chaque table,
- indépendance stricte des modules,
- collaboration entre modules via les services,
- pas d’accès Prisma direct à l’entité d’un autre module.
Si le développeur applique ces principes, il produira un code :
- plus cohérent avec l’architecture,
- plus simple à relire,
- plus facile à tester,
- plus robuste à long terme.
Building Backend Modules with the Project Approach
Table of Contents
- Building Backend Modules with the Project Approach
- Table of Contents
- 1. Introduction
- Download the source files
- 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.
Download the source files
This guide is based on three backend archives:
- the shared backend foundation:
@1hand-backend - the example module:
academic-levels - the example module:
academic-years
You can download the source archives here:
| File | Description | Download |
|---|---|---|
@1hand-backend.zip | Shared backend foundation containing base types, Joi validators, pipes, decorators, helpers, upload utilities, and PDF generation modules. | Download @1hand-backend.zip |
academic-levels.zip | Example backend module showing the standard NestJS module structure: types, validations, controller, service, and module file. | Download academic-levels.zip |
academic-years.zip | More complete backend example module showing pagination, business rules, relationships with other entities, transactions, and soft delete. | Download academic-years.zip |
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.