Skip to content

9. Architecture en couches et accès aux données

Les exemples précédents gardaient leurs données en mémoire. Une vraie application les garde dans une base de données, et s'organise en couches (cf. schéma de l'introduction) :

  • la couche web (contrôleurs, vues) : lire la requête, appeler la couche métier, choisir la vue ;
  • la couche métier : les règles de gestion ;
  • la couche DAO : les requêtes à la base de données.

Chaque couche ne connaît que la couche du dessous, et le moins possible de ses détails : la couche web ignore le SQL, la couche métier ignore le HTML. Cette séparation rend chaque couche plus simple à comprendre, à tester et à faire évoluer. L'exemple 28 l'applique à la gestion d'une liste de personnes : c'est, en plus petit, la gestion des médecins et des clients de l'étude de cas.

9.1. Exemple 28 : une gestion de personnes en trois couches

28-couches/
├── database/
│   └── dbpersonnes.sql
├── public/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── src/
│   ├── config/
│   │   └── database.config.ts
│   ├── dao/
│   │   └── personnes.dao.ts
│   ├── entities/
│   │   └── personne.entity.ts
│   ├── metier/
│   │   ├── metier.exception.ts
│   │   └── personnes.metier.ts
│   ├── web/
│   │   ├── flash.ts
│   │   ├── formulaire-personne.dto.ts
│   │   ├── personnes.controller.ts
│   │   └── validation.ts
│   ├── app.module.ts
│   └── main.ts
├── views/
│   ├── partials/
│   │   └── confirmation.hbs
│   ├── formulaire.hbs
│   ├── layout.hbs
│   └── liste.hbs
├── nest-cli.json
└── tsconfig.json

9.1.1. La base de données

La base [dbpersonnes] est créée par un script SQL. Avec [Laragon], on l'exécute par exemple dans l'outil [HeidiSQL] (menu Fichier / Exécuter un fichier SQL), ou en ligne de commande :

mysql -u root < database/dbpersonnes.sql

Le fichier [database/dbpersonnes.sql] :

-- base de l'exemple 28 : une seule table, [personnes]
DROP DATABASE IF EXISTS dbpersonnes;
CREATE DATABASE dbpersonnes CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbpersonnes;

CREATE TABLE personnes (
  id INT NOT NULL AUTO_INCREMENT,
  version INT NOT NULL DEFAULT 1,
  prenom VARCHAR(30) NOT NULL,
  nom VARCHAR(30) NOT NULL,
  age INT NOT NULL,
  PRIMARY KEY (id),
  -- deux personnes ne peuvent pas avoir les mêmes prénom et nom
  CONSTRAINT unq_personnes_prenom_nom UNIQUE (prenom, nom)
) ENGINE = InnoDB;

INSERT INTO personnes (prenom, nom, age) VALUES
  ('Marie', 'Pélissier', 42),
  ('Jacques', 'Bromard', 35),
  ('Philippe', 'Jandot', 58);
  • ligne 3 : le jeu de caractères [utf8mb4] accepte tous les caractères (accents, emojis...). La collation [utf8mb4_unicode_ci] compare les chaînes sans tenir compte des majuscules ni des accents : pour MySQL, [pelissier] et [Pélissier] sont égaux ;
  • ligne 8 : un numéro de version, augmenté à chaque modification de la ligne (cf. verrou optimiste, plus bas) ;
  • ligne 14 : une contrainte d'unicité nommée : la base refuse deux personnes de mêmes prénom et nom. Son nom servira à reconnaître l'erreur.

9.1.2. La configuration de TypeORM

Le fichier [src/config/database.config.ts] :

import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Personne } from '../entities/personne.entity';

// la connexion à la base ; les valeurs par défaut sont celles de Laragon
export function configBase(): TypeOrmModuleOptions {
  return {
    type: 'mysql',
    host: process.env.DB_HOST ?? 'localhost',
    port: Number(process.env.DB_PORT ?? 3306),
    username: process.env.DB_USERNAME ?? 'root',
    password: process.env.DB_PASSWORD ?? '',
    database: 'dbpersonnes',
    entities: [Personne],
    // le schéma est créé par le script SQL, jamais modifié par TypeORM
    synchronize: false,
    // DB_LOGGING=true : affiche les ordres SQL envoyés à la base
    logging: process.env.DB_LOGGING === 'true',
  };
}
  • lignes 8-11 : les paramètres de connexion, lus dans des variables d'environnement, avec les valeurs par défaut de [Laragon] (utilisateur [root], sans mot de passe) ;
  • ligne 13 : les classes que [TypeORM] fait correspondre à des tables ;
  • ligne 15 : [TypeORM] pourrait créer ou modifier lui-même les tables à partir des classes. C'est pratique pour un prototype, dangereux pour une base qui contient des données : le schéma reste l'affaire du script SQL ;
  • ligne 17 : avec la variable d'environnement [DB_LOGGING=true], les ordres SQL envoyés à la base sont affichés dans la console.

9.1.3. L'entité Personne

Le fichier [src/entities/personne.entity.ts] :

import { Column, Entity, PrimaryGeneratedColumn, VersionColumn } from 'typeorm';

// une ligne de la table [personnes] <-> un objet Personne
@Entity('personnes')
export class Personne {
  @PrimaryGeneratedColumn()
  id: number;

  // incrémentée par TypeORM à chaque modification (verrou optimiste)
  @VersionColumn()
  version: number;

  @Column({ length: 30 })
  prenom: string;

  @Column({ length: 30 })
  nom: string;

  @Column()
  age: number;
}
  • ligne 4 : la classe [Personne] correspond à la table [personnes] : chaque ligne de la table sera un objet [Personne] ;
  • ligne 6 : la clé primaire, générée par la base ([AUTO_INCREMENT]) ;
  • ligne 10 : la colonne de version, gérée par [TypeORM] ;
  • lignes 13-20 : les autres colonnes.

9.1.4. La couche DAO

Le fichier [src/dao/personnes.dao.ts] :

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Not, Repository } from 'typeorm';
import { Personne } from '../entities/personne.entity';

// la couche d'ACCÈS AUX DONNÉES : des requêtes, aucune règle de gestion
@Injectable()
export class PersonnesDao {
  constructor(@InjectRepository(Personne) private readonly repository: Repository<Personne>) {}

  // dans une transaction, il faut passer par le "manager" de la transaction
  private repo(manager?: EntityManager): Repository<Personne> {
    return manager ? manager.getRepository(Personne) : this.repository;
  }

  lister(): Promise<Personne[]> {
    return this.repository.find({ order: { nom: 'ASC', prenom: 'ASC' } });
  }

  lire(id: number, manager?: EntityManager): Promise<Personne | null> {
    return this.repo(manager).findOneBy({ id });
  }

  // une AUTRE personne (id différent de [saufId]) qui porte ces prénom et nom
  homonyme(prenom: string, nom: string, saufId: number, manager?: EntityManager): Promise<Personne | null> {
    return this.repo(manager).findOneBy({ prenom, nom, id: Not(saufId) });
  }

  // INSERT si la personne n'a pas d'id, UPDATE sinon
  sauvegarder(personne: Personne, manager?: EntityManager): Promise<Personne> {
    return this.repo(manager).save(personne);
  }

  async supprimer(id: number, manager?: EntityManager): Promise<void> {
    await this.repo(manager).delete(id);
  }
}
  • ligne 9 : [TypeORM] fournit pour chaque entité un repository ([Repository<Personne>]) qui sait lire et écrire la table. [@InjectRepository] le fait injecter ;
  • lignes 12-14 : dans une transaction, toutes les requêtes doivent passer par le même objet [EntityManager], celui de la transaction. Chaque méthode accepte donc un [manager] facultatif ;
  • lignes 16-18 : [find] avec un tri : [SELECT ... ORDER BY nom, prenom] ;
  • lignes 25-27 : une personne de mêmes prénom et nom, autre que [saufId] ([Not]) : utile pour la modification d'une fiche, qui ne doit pas se trouver en conflit avec elle-même ;
  • lignes 30-32 : [save] fait un [INSERT] si l'objet n'a pas d'identifiant, un [UPDATE] sinon.

La couche DAO ne contient aucune règle : seulement des requêtes.

9.1.5. La couche métier

Le fichier [src/metier/metier.exception.ts] :

1
2
3
// une règle de gestion refuse l'opération : ce n'est pas une anomalie,
// le message est destiné à l'utilisateur
export class MetierException extends Error {}

Une [MetierException] signale qu'une règle de gestion refuse une opération. Ce n'est pas une anomalie du programme : son message est destiné à l'utilisateur.

Le fichier [src/metier/personnes.metier.ts] :

import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { PersonnesDao } from '../dao/personnes.dao';
import { Personne } from '../entities/personne.entity';
import { MetierException } from './metier.exception';

export interface DonneesPersonne {
  prenom: string;
  nom: string;
  age: number;
}

// la couche MÉTIER : les règles de gestion, et elles seules
@Injectable()
export class PersonnesMetier {
  constructor(
    private readonly dao: PersonnesDao,
    private readonly dataSource: DataSource,
  ) {}

  lister(): Promise<Personne[]> {
    return this.dao.lister();
  }

  async lire(id: number): Promise<Personne> {
    const personne = await this.dao.lire(id);
    if (!personne) {
      throw new MetierException("Cette personne n'existe pas (ou plus).");
    }
    return personne;
  }

  ajouter(donnees: DonneesPersonne): Promise<Personne> {
    return this.transaction(async (manager) => {
      // règle 1 : pas deux personnes de mêmes prénom et nom
      if (await this.dao.homonyme(donnees.prenom, donnees.nom, 0, manager)) {
        throw new MetierException('Une personne porte déjà ces prénom et nom.');
      }
      const personne = new Personne();
      personne.prenom = donnees.prenom;
      personne.nom = donnees.nom;
      personne.age = donnees.age;
      return this.dao.sauvegarder(personne, manager);
    });
  }

  modifier(id: number, version: number, donnees: DonneesPersonne): Promise<Personne> {
    return this.transaction(async (manager) => {
      const personne = await this.dao.lire(id, manager);
      if (!personne) {
        throw new MetierException("Cette personne n'existe pas (ou plus).");
      }
      // règle 2 : on ne modifie pas une fiche modifiée entre-temps par un autre
      if (personne.version !== version) {
        throw new MetierException('Cette fiche a été modifiée entre-temps : rechargez-la.');
      }
      if (await this.dao.homonyme(donnees.prenom, donnees.nom, id, manager)) {
        throw new MetierException('Une personne porte déjà ces prénom et nom.');
      }
      personne.prenom = donnees.prenom;
      personne.nom = donnees.nom;
      personne.age = donnees.age;
      return this.dao.sauvegarder(personne, manager);
    });
  }

  async supprimer(id: number): Promise<Personne> {
    const personne = await this.lire(id);
    await this.dao.supprimer(id);
    return personne;
  }

  // [travail] s'exécute dans une transaction : COMMIT s'il réussit, ROLLBACK
  // s'il lève une exception. Une violation de la contrainte d'unicité (deux
  // ajouts simultanés, par exemple) devient une MetierException
  private async transaction<R>(travail: (manager: EntityManager) => Promise<R>): Promise<R> {
    try {
      return await this.dataSource.transaction(travail);
    } catch (e) {
      const erreurSql = e as { code?: string; message?: string };
      if (erreurSql.code === 'ER_DUP_ENTRY' && erreurSql.message?.includes('unq_personnes_prenom_nom')) {
        throw new MetierException('Une personne porte déjà ces prénom et nom.');
      }
      throw e;
    }
  }
}

Commentons ce code :

  • lignes 16-19 : la couche métier utilise le DAO, et la [DataSource] de [TypeORM] (la connexion à la base) pour ouvrir des transactions ;
  • lignes 25-31 : une personne absente est une [MetierException] ;
  • lignes 33-45 : l'ajout, dans une transaction. Règle : pas deux personnes de mêmes prénom et nom. On la vérifie avant l'écriture pour donner un message clair ;
  • lignes 47-65 : la modification. Règle supplémentaire, le verrou optimiste (lignes 54-56) : le formulaire de modification a transporté la version de la fiche telle qu'elle était à l'ouverture du formulaire. Si elle a changé entre-temps (un autre utilisateur a modifié la même fiche), on refuse, plutôt que d'écraser silencieusement son travail ;
  • lignes 76-86 : [dataSource.transaction(travail)] ouvre une transaction, exécute [travail], puis la valide ([COMMIT]) ou l'annule ([ROLLBACK]) si [travail] lève une exception. Lignes 81-82 : la vérification préalable des homonymes ne suffit pas toujours : deux utilisateurs peuvent ajouter la même personne au même instant, et passer tous deux la vérification. La contrainte d'unicité de la base, elle, ne laisse rien passer ; l'erreur MySQL [ER_DUP_ENTRY], reconnue au nom de la contrainte, devient alors la même [MetierException].

9.1.6. La couche web

Le fichier [src/web/formulaire-personne.dto.ts] :

import { Transform, Type } from 'class-transformer';
import { IsInt, IsNotEmpty, Max, MaxLength, Min } from 'class-validator';

const nettoyer = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);

// les champs du formulaire et leurs contraintes (longueurs = colonnes SQL)
export class FormulairePersonneDto {
  @Transform(nettoyer)
  @IsNotEmpty({ message: 'Le prénom est obligatoire.' })
  @MaxLength(30, { message: 'Le prénom ne doit pas dépasser 30 caractères.' })
  prenom: string;

  @Transform(nettoyer)
  @IsNotEmpty({ message: 'Le nom est obligatoire.' })
  @MaxLength(30, { message: 'Le nom ne doit pas dépasser 30 caractères.' })
  nom: string;

  @Type(() => Number)
  @IsInt({ message: "L'âge doit être un nombre entier." })
  @Min(0, { message: "L'âge doit être positif." })
  @Max(150, { message: "L'âge ne peut dépasser 150." })
  age: number;
}

Le formulaire et ses contraintes (cf. exemple 19). La fonction [validerFormulaire] (fichier [web/validation.ts]) est celle de l'exemple 19, et [web/flash.ts] celle de l'exemple 20, complétée d'un type de message (succès ou erreur).

Le fichier [src/web/personnes.controller.ts] :

import { Body, Controller, Get, Param, ParseIntPipe, Post, Redirect, Render, Res } from '@nestjs/common';
import { Response } from 'express';
import { MetierException } from '../metier/metier.exception';
import { PersonnesMetier } from '../metier/personnes.metier';
import { flash } from './flash';
import { FormulairePersonneDto } from './formulaire-personne.dto';
import { validerFormulaire } from './validation';

// la couche WEB : lire la requête, appeler la couche métier, choisir la vue
@Controller()
export class PersonnesController {
  constructor(private readonly metier: PersonnesMetier) {}

  @Get()
  @Redirect('/personnes')
  racine(): void {}

  @Get('personnes')
  @Render('liste')
  async liste(): Promise<object> {
    return { personnes: await this.metier.lister() };
  }

  @Get('personnes/ajouter')
  @Render('formulaire')
  formulaireAjout(): object {
    return { titre: 'Ajouter une personne', action: '/personnes/ajouter', valeurs: {}, erreurs: {} };
  }

  @Post('personnes/ajouter')
  async ajouter(@Body() corps: Record<string, string>, @Res() res: Response): Promise<void> {
    const vue = { titre: 'Ajouter une personne', action: '/personnes/ajouter' };
    const { dto, erreurs } = await validerFormulaire(FormulairePersonneDto, corps);
    if (Object.keys(erreurs).length > 0) {
      res.render('formulaire', { ...vue, valeurs: corps, erreurs, erreur: 'Le formulaire contient des erreurs.' });
      return;
    }
    try {
      const p = await this.metier.ajouter(dto);
      flash(res, { type: 'success', texte: `${p.prenom} ${p.nom} a été ajouté(e).` });
      res.redirect('/personnes');
    } catch (e) {
      // une règle de gestion a refusé l'ajout : on réaffiche le formulaire
      if (!(e instanceof MetierException)) throw e;
      res.render('formulaire', { ...vue, valeurs: corps, erreurs: {}, erreur: e.message });
    }
  }

  @Get('personnes/:id/modifier')
  @Render('formulaire')
  async formulaireModification(@Param('id', ParseIntPipe) id: number): Promise<object> {
    const p = await this.metier.lire(id);
    return { titre: 'Modifier une personne', action: `/personnes/${id}/modifier`, valeurs: p, erreurs: {} };
  }

  @Post('personnes/:id/modifier')
  async modifier(
    @Param('id', ParseIntPipe) id: number,
    @Body() corps: Record<string, string>,
    @Res() res: Response,
  ): Promise<void> {
    const vue = { titre: 'Modifier une personne', action: `/personnes/${id}/modifier` };
    const { dto, erreurs } = await validerFormulaire(FormulairePersonneDto, corps);
    if (Object.keys(erreurs).length > 0) {
      res.render('formulaire', { ...vue, valeurs: corps, erreurs, erreur: 'Le formulaire contient des erreurs.' });
      return;
    }
    try {
      const p = await this.metier.modifier(id, Number(corps.version), dto);
      flash(res, { type: 'success', texte: `${p.prenom} ${p.nom} a été modifié(e).` });
      res.redirect('/personnes');
    } catch (e) {
      if (!(e instanceof MetierException)) throw e;
      res.render('formulaire', { ...vue, valeurs: corps, erreurs: {}, erreur: e.message });
    }
  }

  @Post('personnes/:id/supprimer')
  async supprimer(@Param('id', ParseIntPipe) id: number, @Res() res: Response): Promise<void> {
    try {
      const p = await this.metier.supprimer(id);
      flash(res, { type: 'success', texte: `${p.prenom} ${p.nom} a été supprimé(e).` });
    } catch (e) {
      if (!(e instanceof MetierException)) throw e;
      // pas de formulaire à réafficher : l'erreur voyage elle aussi en flash
      flash(res, { type: 'danger', texte: e.message });
    }
    res.redirect('/personnes');
  }
}

Commentons ce code :

  • ligne 12 : le contrôleur ne connaît que la couche métier ;
  • lignes 14-16 : la racine du site redirige vers la liste ;
  • lignes 18-22 : une action asynchrone : la liste est lue en base ;
  • lignes 30-47 : l'ajout, en trois temps : validation du formulaire (erreurs → réaffichage), appel de la couche métier (succès → message flash et redirection), [MetierException] (→ réaffichage avec le message en bas de page). Une autre exception est relancée ([throw e]) : c'est une anomalie, que le filtre d'exceptions de [NestJS] transformera en erreur 500 ;
  • lignes 49-54 : le formulaire de modification, prérempli avec la fiche lue en base (y compris sa version) ;
  • lignes 56-61 : même schéma que l'ajout ; la version renvoyée par le formulaire est transmise à la couche métier ;
  • lignes 78-89 : la suppression. Il n'y a pas de formulaire à réafficher : une éventuelle erreur voyage, elle aussi, dans le message flash.

Le fichier [src/app.module.ts] :

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { configBase } from './config/database.config';
import { PersonnesDao } from './dao/personnes.dao';
import { Personne } from './entities/personne.entity';
import { PersonnesMetier } from './metier/personnes.metier';
import { PersonnesController } from './web/personnes.controller';

@Module({
  imports: [
    // la connexion à la base, pour toute l'application
    TypeOrmModule.forRoot(configBase()),
    // les Repository<T> injectables dans ce module
    TypeOrmModule.forFeature([Personne]),
  ],
  controllers: [PersonnesController],
  // les trois couches : web (contrôleur) -> métier -> dao
  providers: [PersonnesMetier, PersonnesDao],
})
export class AppModule {}
  • ligne 12 : la connexion à la base, pour toute l'application ;
  • ligne 14 : rend injectable le [Repository<Personne>] ;
  • ligne 18 : les services des couches métier et DAO.

Le fichier [views/formulaire.hbs] :

<h1 class="h3">{{titre}}</h1>
<form method="post" action="{{action}}" novalidate>
  {{!-- la version de la fiche lue, renvoyée avec la modification --}}
  <input type="hidden" name="version" value="{{valeurs.version}}">
  <div class="row g-2 mb-3">
    <div class="col-sm-5">
      <label class="form-label" for="prenom">Prénom</label>
      <input class="form-control {{#if erreurs.prenom}}is-invalid{{/if}}" id="prenom" name="prenom"
             value="{{valeurs.prenom}}" maxlength="30" required>
      {{#if erreurs.prenom}}<div class="invalid-feedback">{{erreurs.prenom}}</div>{{/if}}
    </div>
    <div class="col-sm-5">
      <label class="form-label" for="nom">Nom</label>
      <input class="form-control {{#if erreurs.nom}}is-invalid{{/if}}" id="nom" name="nom"
             value="{{valeurs.nom}}" maxlength="30" required>
      {{#if erreurs.nom}}<div class="invalid-feedback">{{erreurs.nom}}</div>{{/if}}
    </div>
    <div class="col-sm-2">
      <label class="form-label" for="age">Âge</label>
      <input class="form-control {{#if erreurs.age}}is-invalid{{/if}}" id="age" name="age" type="number"
             value="{{valeurs.age}}" required>
      {{#if erreurs.age}}<div class="invalid-feedback">{{erreurs.age}}</div>{{/if}}
    </div>
  </div>
  <button class="btn btn-primary" type="submit">Enregistrer</button>
  <a class="btn btn-link" href="/personnes">Retour à la liste</a>
</form>

ligne 4 : le champ caché qui transporte la version de la fiche. La vue sert à l'ajout et à la modification : son titre et l'adresse d'envoi ([action]) sont fournis par le contrôleur. Les vues [liste.hbs] et [layout.hbs], et le script de confirmation, reprennent ceux des exemples 20 et 21.

9.1.7. Exécution

Les paramètres de connexion sont passés par des variables d'environnement si les valeurs par défaut ne conviennent pas. Sous [Windows] ([PowerShell]) :

$env:DB_USERNAME="root"; $env:DB_PASSWORD=""; $env:DB_LOGGING="true"; npx nest start

La liste :

Image

Un formulaire invalide :

Image

Un homonyme, écrit en minuscules et sans accent : la règle métier le détecte, car MySQL compare selon la collation de la base :

Image

Un ajout réussi, et les ordres SQL correspondants ([DB_LOGGING=true]) :

Image

1
2
3
4
5
6
query: START TRANSACTION
query: SELECT `Personne`.`id` AS `Personne_id`, `Personne`.`version` AS `Personne_version`, `Personne`.`prenom` AS `Personne_prenom`, `Personne`.`nom` AS `Personne_nom`, `Personne`.`age` AS `Personne_age` FROM `personnes` `Personne` WHERE ((`Personne`.`prenom` = ?) AND (`Personne`.`nom` = ?) AND (`Personne`.`id` != ?)) LIMIT 1 -- PARAMETERS: ["Sylvie","Durand",0]
query: INSERT INTO `personnes`(`id`, `version`, `prenom`, `nom`, `age`) VALUES (DEFAULT, 1, ?, ?, ?) -- PARAMETERS: ["Sylvie","Durand",29]
query: SELECT `Personne`.`id` AS `Personne_id`, `Personne`.`version` AS `Personne_version` FROM `personnes` `Personne` WHERE `Personne`.`id` = ? -- PARAMETERS: [4]
query: COMMIT
query: SELECT `Personne`.`id` AS `Personne_id`, `Personne`.`version` AS `Personne_version`, `Personne`.`prenom` AS `Personne_prenom`, `Personne`.`nom` AS `Personne_nom`, `Personne`.`age` AS `Personne_age` FROM `personnes` `Personne` ORDER BY `Personne`.`nom` ASC, `Personne`.`prenom` ASC

On reconnaît la transaction ([START TRANSACTION] ... [COMMIT]), la recherche d'homonyme, l'insertion avec la version 1, puis, après la redirection, la lecture de la liste. Le verrou optimiste : deux formulaires sont ouverts sur la même fiche ; le premier est enregistré (la version passe à 2) ; le second, qui transporte encore la version 1, est refusé :

Image

La suppression, avec confirmation :

Image

Image