One hand business logo

Documentation

NestJS with Prisma

Guide technique en français

Construire des modules backend avec l’approche du projet

Sommaire


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-backend
  • academic-levels
  • academic-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 :

  • authors
  • books
  • videos
  • book-views
  • video-views
  • book-comments
  • video-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 :

FichierDescriptionTélécharger
@1hand-backend.zipSocle 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.zipModule 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.zipModule 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.ts
  • academic-levels.validation.ts
  • academic-levels.controller.ts
  • academic-levels.service.ts
  • academic-levels.module.ts

Module academic-years

Fichiers observés :

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

Ce que cela révèle immédiatement

Même sur un petit échantillon, l’approche est très claire :

  1. les entrées HTTP sont décrites dans des DTOs,
  2. elles sont validées avec Joi,
  3. les routes sont définies dans un controller,
  4. la logique métier et Prisma vivent dans le service,
  5. le module assemble les dépendances,
  6. 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 :

  • CreateAcademicLevelDto
  • UpdateAcademicYearDto
  • FilterAcademicYearDto

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.ts
  • academic-levels.validation.ts
  • academic-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 ObjectSchema Joi,
  • il ignore les primitives quand ce n’est pas un objet,
  • il valide les objets et tableaux,
  • il remonte une erreur BadRequestException structuré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 media via 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 :

  1. est-ce qu’un type partagé existe déjà ?
  2. est-ce qu’un schéma Joi réutilisable existe déjà ?
  3. est-ce qu’un helper utilitaire couvre déjà une partie du besoin ?
  4. est-ce qu’un service technique existe déjà au lieu de recoder la logique ?
  5. 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.ts
  • academic-levels.validation.ts
  • academic-levels.controller.ts
  • academic-levels.service.ts
  • academic-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 :

  • AcademicLevelTypeEnum
  • CreateAcademicLevelDto
  • UpdateAcademicLevelDto
  • FilterAcademicLevelDto

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 :

  • CreateAcademicLevelSchema
  • UpdateAcademicLevelSchema
  • FilterAcademicLevelSchema

C’est exactement le trio à reproduire dans une feature simple.

Controller

Le controller expose les endpoints suivants :

  • POST /academic-levels
  • PATCH /academic-levels (incohérence, voir ci-dessous)
  • DELETE /academic-levels/:id
  • GET /academic-levels/:id
  • GET /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 :

  • create
  • update
  • delete
  • findOneById
  • findOneByCode
  • findAll

Points intéressants

  1. create génère un code métier avec generateMatricule('ACADEMIC_LEVEL').
  2. update vérifie l’existence avant modification.
  3. findAll applique pagination et tri.
  4. la suppression ici est un delete physique.

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.ts
  • academic-years.validation.ts
  • academic-years.controller.ts
  • academic-years.service.ts
  • academic-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-years
  • GET /academic-years/current?schoolId=...
  • GET /academic-years/school/:schoolId/current
  • GET /academic-years
  • GET /academic-years/:code
  • PATCH /academic-years/:code
  • DELETE /academic-years/:code
  • PATCH /academic-years/:code/activate

Ce qu’un développeur doit comprendre

Un module peut exposer deux types d’endpoints :

  1. des endpoints CRUD standards,
  2. 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 :

  1. le controller reçoit la requête,
  2. le pipe Joi valide les données,
  3. le controller extrait les paramètres utiles,
  4. le service exécute les contrôles métier,
  5. le service appelle Prisma,
  6. le service retourne une réponse propre,
  7. le controller renvoie cette réponse au client.

Exemple mental :

  • le client appelle POST /posts,
  • CreatePostSchema valide 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 :

  • AuthorsService manipule author,
  • PostsService manipule post,
  • CommentsService manipule comment.

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 :

  • authors a son CRUD,
  • books a son CRUD,
  • videos a son CRUD,
  • book-views a son CRUD,
  • video-views a son CRUD,
  • book-comments a son CRUD,
  • video-comments a 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 /resources pour créer ;
  • GET /resources pour lister avec des filtres en query params ;
  • GET /resources/:id pour lire un élément ;
  • PUT /resources/:id pour mettre à jour ;
  • DELETE /resources/:id pour 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.

OperationDescriptionEndpointController methodService method
CreateCreate a new authorPOST /authorscreate(dto)create(dto)
ReadGet all authorsGET /authorsselectMany(filter)selectMany(filter)
ReadGet one author by IDGET /authors/:idselectById(id)selectById(id)
UpdateUpdate author informationPUT /authors/:idupdate(id, body)update(id, body)
DeleteDelete an authorDELETE /authors/:idremove(id)remove(id)

8.3.2 Book CRUD

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

OperationDescriptionEndpointController methodService method
CreateCreate a new bookPOST /bookscreate(dto)create(dto)
ReadGet all booksGET /booksselectMany(filter)selectMany(filter)
ReadGet one book by IDGET /books/:idselectById(id)selectById(id)
ReadGet all books by one authorGET /books?authorId=...selectMany(filter)selectMany(filter)
UpdateUpdate a bookPUT /books/:idupdate(id, body)update(id, body)
DeleteDelete a bookDELETE /books/:idremove(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.

OperationDescriptionEndpointController methodService method
CreateCreate a new videoPOST /videoscreate(dto)create(dto)
ReadGet all videosGET /videosselectMany(filter)selectMany(filter)
ReadGet one video by IDGET /videos/:idselectById(id)selectById(id)
ReadGet all videos by one authorGET /videos?authorId=...selectMany(filter)selectMany(filter)
UpdateUpdate a videoPUT /videos/:idupdate(id, body)update(id, body)
DeleteDelete a videoDELETE /videos/:idremove(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.

OperationDescriptionEndpointController methodService method
CreateRegister a view on a bookPOST /book-viewscreate(dto)create(dto)
ReadGet all book viewsGET /book-viewsselectMany(filter)selectMany(filter)
ReadGet one book view by IDGET /book-views/:idselectById(id)selectById(id)
ReadGet views by bookGET /book-views?bookId=...selectMany(filter)selectMany(filter)
ReadGet views by authorGET /book-views?authorId=...selectMany(filter)selectMany(filter)
UpdateUpdate a book viewPUT /book-views/:idupdate(id, body)update(id, body)
DeleteDelete a book view recordDELETE /book-views/:idremove(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.

OperationDescriptionEndpointController methodService method
CreateRegister a view on a videoPOST /video-viewscreate(dto)create(dto)
ReadGet all video viewsGET /video-viewsselectMany(filter)selectMany(filter)
ReadGet one video view by IDGET /video-views/:idselectById(id)selectById(id)
ReadGet views by videoGET /video-views?videoId=...selectMany(filter)selectMany(filter)
ReadGet views by authorGET /video-views?authorId=...selectMany(filter)selectMany(filter)
UpdateUpdate a video viewPUT /video-views/:idupdate(id, body)update(id, body)
DeleteDelete a video view recordDELETE /video-views/:idremove(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.

OperationDescriptionEndpointController methodService method
CreateAdd a comment to a bookPOST /book-commentscreate(dto)create(dto)
ReadGet all book commentsGET /book-commentsselectMany(filter)selectMany(filter)
ReadGet one book comment by IDGET /book-comments/:idselectById(id)selectById(id)
ReadGet comments by bookGET /book-comments?bookId=...selectMany(filter)selectMany(filter)
ReadGet comments by authorGET /book-comments?authorId=...selectMany(filter)selectMany(filter)
UpdateUpdate a book commentPUT /book-comments/:idupdate(id, body)update(id, body)
DeleteDelete a book commentDELETE /book-comments/:idremove(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.

OperationDescriptionEndpointController methodService method
CreateAdd a comment to a videoPOST /video-commentscreate(dto)create(dto)
ReadGet all video commentsGET /video-commentsselectMany(filter)selectMany(filter)
ReadGet one video comment by IDGET /video-comments/:idselectById(id)selectById(id)
ReadGet comments by videoGET /video-comments?videoId=...selectMany(filter)selectMany(filter)
ReadGet comments by authorGET /video-comments?authorId=...selectMany(filter)selectMany(filter)
UpdateUpdate a video commentPUT /video-comments/:idupdate(id, body)update(id, body)
DeleteDelete a video commentDELETE /video-comments/:idremove(id)remove(id)

Example body:

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

8.4 Diagramme de base de données

Book api db diagram

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.ts est 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.
  • Book api db diagram

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 Service frontend.
  • Le fichier .adapter.ts est 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.

ModuleDTO de sortieCreate DTOUpdate DTOFilter DTOMapper
authorsAuthorDtoCreateAuthorDtoUpdateAuthorDtoFilterAuthorDtotoAuthorDto(entity)
booksBookDtoCreateBookDtoUpdateBookDtoFilterBookDtotoBookDto(entity)
videosVideoDtoCreateVideoDtoUpdateVideoDtoFilterVideoDtotoVideoDto(entity)
book-viewsBookViewDtoCreateBookViewDtoUpdateBookViewDtoFilterBookViewDtotoBookViewDto(entity)
video-viewsVideoViewDtoCreateVideoViewDtoUpdateVideoViewDtoFilterVideoViewDtotoVideoViewDto(entity)
book-commentsBookCommentDtoCreateBookCommentDtoUpdateBookCommentDtoFilterBookCommentDtotoBookCommentDto(entity)
video-commentsVideoCommentDtoCreateVideoCommentDtoUpdateVideoCommentDtoFilterVideoCommentDtotoVideoCommentDto(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 :

  • create existe côté controller ;
  • selectMany existe côté controller ;
  • selectById existe côté controller ;
  • update existe côté controller ;
  • remove existe 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 :

  • create existe côté service ;
  • selectMany existe côté service ;
  • selectById existe côté service ;
  • update existe côté service ;
  • remove existe 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 :

  • firstName
  • lastName
  • email
  • bio

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 :

  • title
  • description
  • url
  • authorId

Le service doit :

  1. appeler AuthorsService.selectById(dto.authorId) ;
  2. créer la vidéo avec authorId ;
  3. inclure l’auteur dans la requête Prisma ;
  4. retourner toVideoDto(entity).

8.13.3 Module book-views

Champs principaux :

  • bookId
  • authorId
  • viewedAt

Le service doit :

  1. appeler BooksService.selectById(dto.bookId) ;
  2. appeler AuthorsService.selectById(dto.authorId) ;
  3. créer la vue avec bookId et authorId ;
  4. retourner toBookViewDto(entity).

8.13.4 Module video-views

Champs principaux :

  • videoId
  • authorId
  • viewedAt

Le service doit :

  1. appeler VideosService.selectById(dto.videoId) ;
  2. appeler AuthorsService.selectById(dto.authorId) ;
  3. créer la vue avec videoId et authorId ;
  4. retourner toVideoViewDto(entity).

8.13.5 Module book-comments

Champs principaux :

  • bookId
  • authorId
  • content

Le service doit :

  1. appeler BooksService.selectById(dto.bookId) ;
  2. appeler AuthorsService.selectById(dto.authorId) ;
  3. créer le commentaire avec bookId, authorId et content ;
  4. retourner toBookCommentDto(entity).

8.13.6 Module video-comments

Champs principaux :

  • videoId
  • authorId
  • content

Le service doit :

  1. appeler VideosService.selectById(dto.videoId) ;
  2. appeler AuthorsService.selectById(dto.authorId) ;
  3. créer le commentaire avec videoId, authorId et content ;
  4. retourner toVideoCommentDto(entity).

8.14 Organisation des dépendances entre modules

Les modules doivent collaborer via leurs services.

Exemples :

  • BooksService utilise AuthorsService pour vérifier l’auteur ;
  • VideosService utilise AuthorsService pour vérifier l’auteur ;
  • BookViewsService utilise BooksService et AuthorsService ;
  • VideoViewsService utilise VideosService et AuthorsService ;
  • BookCommentsService utilise BooksService et AuthorsService ;
  • VideoCommentsService utilise VideosService et AuthorsService.

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 HttpClient qui connaît les routes HTTP ;
  • un Service qui contient la logique d’orchestration frontend ;
  • un Adapter qui 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

  1. Commencer par lire un module simple, puis un module plus métier.
  2. Reproduire d’abord la structure avant d’innover.
  3. Garder les controllers fins.
  4. Mettre la vraie logique dans les services.
  5. Réutiliser les helpers partagés avant de créer de nouveaux helpers.
  6. Ne jamais faire d’accès direct à Prisma pour l’entité d’un autre module si ce module possède déjà son service.
  7. Préférer des conventions cohérentes partout : :id, pagination, soft delete, format des réponses.
  8. 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.