Design Patterns : Une approche structurée pour la gestion des états avec le State Pattern

Introduction

Le design pattern State est un concept fondamental en génie logiciel, visant à encapsuler le comportement variable d’un objet dans des classes distinctes. Cette approche favorise la modularité, la flexibilité et la maintenabilité du code. Dans cet article, nous allons étudier l’application du design pattern State à travers un exemple concret : la gestion des statuts d’une offre d’embauche. Nous allons d’abord décrire le cas pratique, puis illustrer la mauvaise manière de concevoir cette fonctionnalité avant de proposer la meilleure implémentation utilisant TypeScript.

Prérequis

Avant de suivre ce guide, les prérequis suivants doivent être installés sur votre ordinateur :

Use case

Imaginons un système de gestion d’offres d’embauche. Chaque offre d’embauche peut avoir l’un des quatre statuts suivants : OPENED, FULL, WON, ou CLOSED. Ces statuts reflètent l’évolution du processus de recrutement. Un certain nombre de règles sont associées à ces transitions d’état.

  1. OPENED : L’offre d’embauche est ouverte, et tous les candidats peuvent postuler.
  2. FULL : L’offre d’embauche passe à cet état lorsqu’il y a trois candidats sélectionnés pour un entretien. Elle peut ensuite évoluer soit vers l’état WON (recrutement réussi) ou directement vers l’état CLOSED (aucun candidat retenu).
  3. WON : L’offre d’embauche est remportée lorsqu’un candidat est recruté depuis l’étape OPENED ou FULL.
  4. CLOSED : L’offre d’embauche est fermée, soit directement depuis l’état OPENED ou FULL lorsqu’aucun candidat n’est recruté, soit depuis l’état WON.

Un système de suivi sera mis en place pour enregistrer le nombre de candidatures reçues, ainsi que le nombre de candidats sélectionnés pour un entretien. Un indicateur booléen permettra de signaler si l’offre d’emploi a été pourvue ou non.

Initialisation du projet

Création de la structure du projet

Commençons par créer la structure de notre projet “stateDesignPattern” :

mkdir stateDesignPattern
mkdir stateDesignPattern/src
mkdir stateDesignPattern/src/models
mkdir stateDesignPattern/src/state

# Point d'entrée de notre projet
touch stateDesignPattern/src/main.ts

Initialisation

Créer le fichier package.json en saisissant la commande ci-dessous :

yarn init -y

Étant donné que notre projet est rédigé en TypeScript, nous devons convertir (transpiler) le code en JavaScript natif pour pouvoir l’exécuter avec Node.js. Pour ce faire, nous allons utiliser tsup, une bibliothèque de bundling conçue spécifiquement pour les projets TypeScript. En substance, tsup nous permet de convertir notre projet en JavaScript en le regroupant (bundling) dans un seul fichier, généralement localisé dans le répertoire par défaut /dist. Pour mettre en œuvre cette démarche, nous allons installer TypeScript et tsup en tant que dépendances de développement au niveau de notre projet.

yarn add -D typescript tsup

Pour finir, mettons à jour le fichier package.json afin de pouvoir exécuter notre projet en utilisant simplement la commande yarn start et de le construire (build) avec yarn build. Pour ce faire, ajoutez le code suivant dans le fichier package.json.

{
  "name": "stateDesignPattern",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "tsup": "^8.0.1",
    "typescript": "^5.3.3"
  },
  "scripts": {
    // Transpile en Javascript puis lance le code généré avec node.
    "start": "tsup src/main.ts --onSuccess 'node dist/main.js'",
  
    // Transpile et bundle le code source en un fichier unique.
    "build": "tsup src/main.ts"
  }
}

Configuration des imports

Créons le fichier tsconfig.json afin de pouvoir organiser notre projet de manière structurée en définissant des chemins d’importation facilitant l’accès à nos fichiers à l’aide de raccourcis de la forme @. Cela améliorera la clarté du code en permettant des imports plus concis et en simplifiant la gestion des dépendances internes. Cela nous permettra par exemple de transformer nos imports du type import { OpenedState } from '../../state/ClosedState' en import { ClosedState } from "@state". Cette approche offre une meilleure maintenabilité du code en facilitant la localisation des modules et en réduisant la dépendance à la structure physique du projet.

   yarn tsc --init

En exécutant cette commande, un fichier tsconfig.json par défaut est généré, accompagné de commentaires explicatifs détaillant chaque option. Vous pouvez laisser les valeurs par défaut inchangées et décommenter les options baseUrl et paths. Assurez-vous que votre configuration ressemble à l’exemple de code ci-dessous :

   "baseUrl": "./",                                 
   "paths": {
        "@*": ["src/*"],
   },       

Définition des modèles

Définition d’une évaluation

L’interface Evaluation définit la structure d’un objet qui représente une évaluation. Cette interface spécifie trois propriétés différentes pour caractériser l’évaluation d’un candidat recruté.

export interface Evaluation {
  title: string;
  message: string;
  note: number;
}

Définition d’une offre d’embauche

Cette interface définit la structure attendue pour un objet Job. Un Job a des propriétés telles que name (nom du poste), status (statut de l’offre), applicantCount (nombre de postulants) et candidateCount (nombre de candidats), completed (Offre pourvue si égale à true). Cette interface permet de définir et de garantir la structure des objets de type Job utilisés par notre implémentation.

import { Evaluation } from '@models';

export interface Job {
  name: string;
  status: string;
  applicantCount: number;
  candidateCount: number;
  completed: boolean;
  evaluation: Evaluation;
  // Autres propriétés de job
}

Définition d’un candidat

Cette interface définit la structure attendue pour un objet Candidate. Un Candidate a au moins une propriété firstName (prénom du candidat).

export interface Candidate {
  firstName: string;
  // Autres propriétés de candidat
}

Gestion des imports

Nous avons configuré le fichier tsconfig.json pour faciliter les imports de classes d’un fichier à l’autre. Pour simplifier davantage, créons un fichier index dans le dossier “models” qui permettra de pointer directement vers le dossier, permettant ainsi d’importer l’ensemble de nos modèles en une seule instruction. Cela évitera d’avoir à pointer spécifiquement vers chaque fichier à chaque fois que nous avons besoin d’importer un modèle.

export * from './evaluation';
export * from './job';
export * from './candidate';

Une approche rigide

Supposons une première implémentation sans l’utilisation du design pattern State. On pourrait envisager une classe unique badJobOffer dans le dossier src/ avec des méthodes conditionnelles pour gérer chaque transition de statut. Cependant, cette approche conduit à un code difficile à maintenir, avec des conditions imbriquées complexes et une responsabilité excessive.

Cette classe représente l’offre d’emploi et contient des méthodes pour effectuer des actions spécifiques liées à cette offre.

import { Evaluation, Job, Candidate } from "@models";

export class JobOffer {
  private job: Job;

  constructor(job: Job) {
    this.job = job;
  }

  apply(candidate: Candidate): void {
    if (this.job.status === 'OPENED') {
      console.log(`Le candidat ${candidate.firstName} a postulé à l'offre ${this.job.name}.`);
      this.job.applicantCount++;
    } else {
      console.log("Offre fermée : impossible de postuler.");
    }
  }

  selectCandidate(candidate: Candidate): void {
    if (this.job.status === 'OPENED' && this.job.candidateCount < 3) {
      console.log(`Le candidat ${candidate.firstName} est sélectionné pour un entretien pour le poste ${this.job.name}.`);
      this.job.candidateCount++;
      if (this.job.candidateCount >= 3) {
        console.log("L'offre est désormais pleine.");
        this.job.status = 'FULL';
      }
    } else {
      console.log("Impossible de sélectionner un candidat.");
    }
  }

  recruitCandidate(candidate: Candidate): void {
    if (this.job.candidateCount > 0) {
      console.log(`Le candidat ${candidate.firstName} est recruté sur l'offre ${this.job.name}.`);
      this.job.status = 'WON';
      this.job.completed = true;
    } else {
      console.log('Impossible de recruter sans sélection préalable pour un entretien.');
    }
  }

  evaluateCandidate(candidate: Candidate, evaluation: Evaluation): void {
    if (this.job.completed && this.job.status === 'CLOSED') {
      console.log(`La note de ${evaluation.note} étoiles est attribuée à ${candidate.firstName} sur l'offre ${this.job.name}.`);
    } else {
      console.log('Impossible de soumettre une évaluation.');
    }
  }

  close(): void {
    console.log("Offre fermée.");
    this.job.status = 'CLOSED';
  }

  getStatus(): string {
    return this.job.status;
  }
}

Cette approche devient rapidement inextensible à mesure que de nouveaux statuts ou de nouvelles transitions sont introduits.

Design pattern state

Introduisons maintenant le design pattern State pour résoudre ces problèmes. Il est un motif de conception comportemental qui permet à un objet de modifier son comportement lorsqu’il change son état interne. Il permet à un objet de paraître changer de classe.

Nous avons une interface JobOfferState qui représente un état, avec des classes concrètes pour chaque état spécifique (OpenedState, FullState, WonState,ClosedState). Cette interface JobOfferState est injectée dans notre classe JobOffer pour concrétiser l’état actuel de notre offre d’emploi. Ainsi, la classe JobOffer délègue toutes les responsabilités liées à la gestion des états à cet objet dédié. Cette approche simplifie la logique de la classe principale en répartissant les responsabilités entre différentes classes étatiques, rendant le code plus modulaire et facile à maintenir.

Interface commune pour tous les états

Définit l’interface pour les différents états que peut prendre une offre d’emploi. Les méthodes (apply, selectApplicant, recruitCandidate, transitionToNextState) seront implémentées par les sous-classes représentant chaque état particulier.

import { Candidate, Evaluation, Job } from '@models';
import { JobOffer } from "@jobOffer";

export interface JobOfferState {
  apply(jobOffer: JobOffer, candidate: Candidate): void;
  selectCandidate(jobOffer: JobOffer, candidate: Candidate): void;
  recruitCandidate(jobOffer: JobOffer, candidate: Candidate): void;
  getStatus(): string;
  close(jobOffer: JobOffer): void;
  evaluateCandidate(jobOffer: JobOffer, candidate: Candidate, evaluation: Evaluation): void;
}

Premier état : OPENED

Cette classe représente l’état où l’offre d’emploi est ouverte. Les candidats peuvent postuler et être sélectionnés, et il y a une transition vers l’état FullState si le nombre maximum de candidats est atteint.

import { Candidate, Evaluation, Job } from '@models';
import { JobOfferState, FullState, ClosedState,  WonState } from "@state";
import { JobOffer } from "@jobOffer";

export class OpenedState implements JobOfferState {
  apply(jobOffer: JobOffer, candidate: Candidate): void {
    // Logique pour permettre aux candidats de postuler
    console.log(`Le candidat ${candidate.firstName} à postulé à l'offre ${jobOffer.getJobName()}.`);
    jobOffer.getApplicantCount();
  }

  selectCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    // Logique pour sélectionner un candidat
    jobOffer.incrementCandidateCount();
    // Logique de sélection du candidat
    console.log(`Le candidat ${candidate.firstName} est sélectionné pour un entretien pour le poste ${jobOffer.getJobName()}.`);
    
    if (jobOffer.getCandidateCount() >= 3) {
      jobOffer.setState(new FullState());
    }
  }

  recruitCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    // Logique pour recruter un candidat
    if (jobOffer.getCandidateCount() == 0) {
      console.log('Impossible de recruter sans sélection pour un entretien préalable');
    }
    
    console.log(`Le candidat ${candidate.firstName} est recruté sur l'offre ${jobOffer.getJobName()}.`);
    jobOffer.setState(new WonState());
    jobOffer.setCompleted(true);
  }

  getStatus(): string{
    return 'OPENED';
  }

  close(jobOffer: JobOffer): void {
    // Logique pour fermer l'offre
    console.log("Offre fermée sans recrutement.");
    jobOffer.setState(new ClosedState());
  }

  evaluateCandidate(jobOffer: JobOffer, 
                    candidate: Candidate, 
                    evaluation: Evaluation): void 
  {
      console.log('Impossible de soumettre une evaluation');
  }
}

Deuxième état : FULL

import { Candidate, Evaluation, Job } from '@models';
import { JobOfferState, ClosedState, WonState } from "@state";
import { JobOffer } from "@jobOffer";

export class FullState implements JobOfferState {
  apply(jobOffer: JobOffer, candidate: Candidate): void {
    // Impossible de postuler, l'offre est pleine
    console.log("Offre pleine : impossible de postuler.");
  }

  selectCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    // Logique pour sélectionner un candidat
    jobOffer.incrementCandidateCount();
    console.log(`Le candidat ${candidate.firstName} est sélectionné pour un entretien pour le poste ${jobOffer.getJobName()}.`);
  }

  recruitCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    // Logique pour recruter un candidat
    console.log(`Le candidat ${candidate.firstName} est recruté sur l'offre ${jobOffer.getJobName()}.`);
    
    jobOffer.setState(new WonState());
    jobOffer.setCompleted(true);
  }

  getStatus(): string{
    return 'FULL';
  }

  close(jobOffer: JobOffer): void {
    // Logique pour fermer l'offre sans recrutement
    console.log("Offre fermée sans recrutement.");
    jobOffer.setState(new ClosedState());
  }

  evaluateCandidate(jobOffer: JobOffer, 
                    candidate: Candidate, 
                    evaluation: Evaluation): void 
  {
      console.log('Impossible de soumettre une evaluation');
  }
}

Troisième état : WON

Cette classe représente l’état où l’offre d’emploi a été remportée. Dans cet état, aucune nouvelle action n’est permise.

import { Candidate, Evaluation, Job } from '@models';
import { JobOfferState, OpenedState, ClosedState } from "@state";
import { JobOffer } from "@jobOffer";

export class WonState implements JobOfferState {
  apply(jobOffer: JobOffer, candidate: Candidate): void {
    console.log("Offre remportée : impossible de postuler.");
  }

  selectCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    console.log("Offre remportée : impossible de sélectionner un candidat.");
  }

  recruitCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    console.log("Offre remportée : impossible de recruter un candidat.");
  }

  getStatus(): string{
    return 'WON';
  }

  close(jobOffer: JobOffer): void {
    // Logique pour fermer l'offre après le recrutement
    console.log("Offre fermée après le recrutement.");
    jobOffer.setState(new ClosedState());
  }

  evaluateCandidate(jobOffer: JobOffer, 
                    candidate: Candidate, 
                    evaluation: Evaluation): void 
  {
      console.log('Impossible de soumettre une evaluation');
  }
}

Quatrième état : CLOSED

Cette classe représente l’état où l’offre d’emploi est fermée. Dans cet état, aucune nouvelle action n’est permise.

import { Candidate, Evaluation, Job } from '@models';
import { JobOfferState } from "@state";
import { JobOffer } from "@jobOffer";

export class ClosedState implements JobOfferState {
  apply(jobOffer: JobOffer, candidate: Candidate): void {
    console.log("Offre fermée : impossible de postuler.");
  }

  selectCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    console.log("Offre fermée : impossible de sélectionner un candidat.");
  }

  recruitCandidate(jobOffer: JobOffer, candidate: Candidate): void {
    console.log("Offre fermée : impossible de recruter un candidat.");
  }

  getStatus(): string{
    return 'CLOSED';
  }

  close(jobOffer: JobOffer): void {
    console.log("Offre déjà fermée.");
  }

  evaluateCandidate(jobOffer: JobOffer, 
                    candidate: Candidate, 
                    evaluation: Evaluation): void 
  {
    // Logique pour gérer une evaluation
    if(jobOffer.getCompleted() != true){
      console.log('Offre fermée sans recrutement, impossible de soumettre une evaluation');
      return;
    }
    console.log(`La note de ${evaluation.note} étoiles est attibuée à ${candidate.firstName} sur l'offre ${jobOffer.getJobName()}.`);
  }
}

Gestion des imports

Créons le fichier src/state/index.ts pour la gestion dynamique des importations de fichiers.

export * from './wonState';
export * from './jobOfferState';
export * from './openedState';
export * from './fullState';
export * from './closedState';

Classe principale

Cette classe représente l’offre d’emploi et utilise l’état actuel pour déléguer les différentes opérations. Elle permet également de gérer la transition d’un état à un autre.

import { Candidate, Evaluation, Job } from '@models';
import { JobOfferState, WonState, OpenedState, FullState, ClosedState } from '@state';

export class JobOffer {
  private job: Job;
  private state: JobOfferState;

  constructor(job: Job) {
    // Initialisation à l'état OPENED par défaut
    this.job = job;
    this.state = this.createStateInstance(this.job.status);
  }
  
  
  private createStateInstance(status: string): JobOfferState {
    const stateMappings: { [key: string]: new () => JobOfferState } = {
      opened: OpenedState,
      full: FullState,
      won: WonState,
      closed: ClosedState,
      // Ajoutez d'autres états au besoin
    };

    const className = status.toLowerCase();
    const stateClass = stateMappings[className];

    if (stateClass) {
      return new stateClass();
    } else {
      console.error(`Classe d'état non trouvée pour le statut : ${status}`);
      return new OpenedState();
    }
  }


  setState(state: JobOfferState): void {
    // Changer l'état de l'offre
    this.state = state;
    this.transitionToState(this.state.getStatus());
  }
  
  transitionToState(status: string) {
    this.job.status = status;
  }

  incrementApplicantCount(): void {
    // Incrémenter le nombre de candidats ayant postulé
    this.job.applicantCount++;
  }

  getApplicantCount(): number {
    // Obtenir le nombre de candidats ayant postulé
    return this.job.applicantCount;
  }

  incrementCandidateCount(): void {
    // Incrémenter le nombre de candidats sélectionnés
    this.job.candidateCount++;
  }

  getCandidateCount(): number {
    // Obtenir le nombre de candidats sélectionnés
    return this.job.candidateCount;
  }

  getJobName(): string {
    // Obtenir le job
    return this.job.name;
  }

  setCompleted(completed: boolean): void {
    this.job.completed = completed;
  }

  getCompleted(): boolean {
    return this.job.completed;
  }

  // Méthodes déléguées à l'état actuel
  apply(candidate: Candidate): void {
    this.state.apply(this, candidate);
  }

  selectCandidate(candidate: Candidate): void {
    this.state.selectCandidate(this, candidate);
  }

  recruitCandidate(candidate: Candidate): void {
    this.state.recruitCandidate(this, candidate);
  }

  evaluateCandidate(candidate: Candidate, evaluation: Evaluation): void {
    this.state.evaluateCandidate(this, candidate, evaluation);
  }

  close(): void {
    this.state.close(this);
  }
}

La classe JobOffer aura une référence vers l’objet JobOfferState actuel, qui peut être modifié dynamiquement en fonction des transitions de statut. La structure doit votre projet doit ressembler à la photographie ci-dessous :

├── node_modules
├── src
│   ├── badJobOffer.ts
│   ├── jobOffer.ts
│   ├── main.ts
│   ├── models
│   │   ├── candidate.ts
│   │   ├── evaluation.ts
│   │   ├── index.ts
│   │   └── job.ts
│   └── state
│       ├── closedState.ts
│       ├── fullState.ts
│       ├── index.ts
│       ├── jobOfferState.ts
│       ├── openedState.ts
│       └── wonState.ts
├── package.json
├── tsconfig.json
├── yarn-error.log
└── yarn.lock

Utilisation

Nous sommes maintenant en mesure d’exploiter notre classe principale pour mettre en œuvre les fonctionnalités que nous venons de développer. Ajoutez le code ci-dessous dans src/main.ts.

import { Evaluation, Job, Candidate } from "@models";
import { JobOffer } from "@jobOffer";


// Définir une évaluation
const evaluation: Evaluation = {
  title: "excellent",
  message: "Bravo pour le job",
  note: 5
};

// Définir un job initial
const job: Job = {
  name: 'Développeur Full Stack',
  status: 'OPENED',
  candidateCount: 0,
  applicantCount: 0,
  completed: false,
  evaluation: null
  // Autres propriétés du job
};

// Définir un candidat
const candidateJohn: Candidate = {
  firstName: 'John',
  // Autres propriétés du candidat
};

// Définir un second candidat
const candidateJane: Candidate = {
  firstName: 'Jane',
  // Autres propriétés du candidat
};

// Créer une instance de notre classe principale JobOffer
const jobOffer = new JobOffer(job);

// Exemple 1 : Postulation à une offre ouverte
jobOffer.apply(candidateJohn); 
jobOffer.apply(candidateJane); 
jobOffer.selectCandidate(candidateJane);
jobOffer.selectCandidate(candidateJohn);
jobOffer.recruitCandidate(candidateJane);
jobOffer.close();
jobOffer.evaluateCandidate(candidateJane, evaluation);

Tester le code

Pour exécuter notre projet, tapez la commande suivante :

yarn start

Résultats

Le candidat John à postulé à l'offre Développeur Full Stack.
Le candidat Jane à postulé à l'offre Développeur Full Stack.
Le candidat Jane est sélectionné pour un entretien pour le poste Développeur Full Stack.
Le candidat John est sélectionné pour un entretien pour le poste Développeur Full Stack.
Le candidat Jane est recruté sur l'offre Développeur Full Stack.
Offre fermée après le recrutement.
La note de 5 étoiles est attibuée à Jane sur l'offre Développeur Full Stack.

Conclusion

En adoptant le design pattern State, nous parvenons à une conception plus modulaire et évolutive pour la gestion des statuts d’une offre d’embauche. Chaque état est encapsulé dans une classe dédiée, facilitant l’ajout de nouveaux statuts et la modification du comportement sans altérer la classe principale. Cette approche offre une meilleure lisibilité, une maintenance simplifiée et une extensibilité accrue du code, autant d’avantages cruciaux dans le développement logiciel.

Architecte logiciel & CTO * Plus de publications

Architecte logiciel, Développeur d'application diplomé d'ETNA, la filière d'alternance d'Epitech, j'ai acquis une expertise solide dans le développement d'applications, travaillant sur des projets complexes et techniquement diversifiés. Mon expérience englobe l'utilisation de divers frameworks et langages, notamment Symfony, Api Platform, Drupal, Zend, React Native, Angular, Vue.js, Shell, Pro*C...

Contributeurs

0 0 votes
Évaluation de l'article
guest
0 Commentaires
Le plus ancien
Le plus récent Le plus populaire
Commentaires en ligne
Afficher tous les commentaires

Ingénierie informatique (SSII)

Applize crée des logiciels métiers pour accompagner les entreprises dans la transition vers le zéro papier.


Avez-vous un projet en tête ? Discutons-en.