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.
- OPENED : L’offre d’embauche est ouverte, et tous les candidats peuvent postuler.
- 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).
- WON : L’offre d’embauche est remportée lorsqu’un candidat est recruté depuis l’étape OPENED ou FULL.
- 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, 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...