Agile Software Development
Chapitre 9 / 15 · 16 min de lecture

L'étude de cas Payroll : conception et implémentation

Concevoir un système de paie réaliste en appliquant SOLID et les patterns : abstractions de classification, de planning, de paiement et d'affiliation.

Après avoir parcouru les principes SOLID et une poignée de patrons, il est temps de les voir à l'œuvre sur un problème réaliste : un système de paie (payroll) en traitement par lots. C'est l'étude de cas centrale du livre, en deux temps : une session d'analyse rapide, telle qu'on en mène au début d'une itération devant un tableau blanc, puis l'implémentation pilotée par les tests qui la valide et la corrige. Nous transposons en TypeScript le C++ de Martin, en conservant ses choix — Command, Strategy, Template Method, Façade, Null Object — et la manière dont la conception émerge des cas d'usage plutôt que d'un schéma de base de données.

La spécification : que demande le client ?

Les notes prises avec le client tiennent en quelques lignes. Elles sont volontairement simplistes — il n'est même pas question d'impôts —, ce qui est typique d'une première itération : on ne livre qu'une part minime de la valeur métier.

  • Certains employés sont payés à l'heure (hourly). Leur fiche contient un taux horaire ; ils soumettent des cartes de pointage (time cards) quotidiennes qui enregistrent la date et le nombre d'heures. Au-delà de 8 heures par jour, les heures supplémentaires sont rémunérées à 1,5 fois le taux normal. Ils sont payés chaque vendredi.
  • D'autres reçoivent un salaire fixe (salaried), versé le dernier jour ouvré du mois.
  • Certains salariés touchent en plus une commission (commissioned) sur leurs ventes. Ils soumettent des relevés de vente (sales receipts) — date et montant — et perçoivent leur paie un vendredi sur deux.
  • Chacun choisit sa méthode de paiement (payment method) : chèque envoyé par la poste, chèque conservé au guichet du payeur, ou dépôt direct (direct deposit) sur un compte bancaire.
  • Certains employés sont syndiqués (union member). Leur fiche porte un taux de cotisation hebdomadaire (dues) à déduire de la paie, et le syndicat peut leur imputer des frais de service (service charges) ponctuels, soumis chaque semaine.
  • L'application tourne une fois par jour ouvré et paie les employés concernés ce jour-là. On lui indique la date de paie, et elle génère les versements depuis la dernière paie de chaque employé jusqu'à cette date.

La tentation immédiate serait de dessiner le schéma relationnel : les tables et les colonnes se devinent sans peine. C'est précisément le piège.

À retenir

Les bases de données sont des détails d'implémentation. Considérer la base de données dès le départ produit une application irrémédiablement arrimée à son stockage. Souvenons-nous de la définition de l'abstraction : amplifier l'essentiel, éliminer l'accessoire. À ce stade, la base est accessoire — ce n'est qu'une technique pour ranger et lire des données. On diffère cette décision aussi longtemps que possible.

L'analyse par cas d'usage

Plutôt que de partir des données, partons du comportement : c'est lui que le client nous paie pour produire. Un cas d'usage (use case) est un récit utilisateur enrichi de juste assez de détails pour réfléchir au code qui le réalisera. Le client a retenu sept récits pour cette itération : ajouter un employé, en supprimer un, poster une carte de pointage, poster un relevé de vente, poster des frais de service syndicaux, changer les détails d'un employé, et lancer la paie du jour.

Ajouter un employé : le pattern Command apparaît

Un employé est ajouté à la réception d'une transaction AddEmp, qui existe sous trois formes — horaire, salarié, commissionné — partageant toutes trois les champs identifiant, nom et adresse :

AddEmp <EmpId> "<name>" "<address>" H <hourly-rate>
AddEmp <EmpId> "<name>" "<address>" S <monthly-salary>
AddEmp <EmpId> "<name>" "<address>" C <monthly-salary> <commission-rate>

Ce tronc commun et ces trois variantes suggèrent une abstraction. On applique le patron COMMANDE (Command) : une transaction est une action réifiée en objet, dotée d'une méthode execute. On crée donc une classe de base AddEmployeeTransaction et trois dérivées. Découper chaque tâche en sa propre classe respecte le principe de responsabilité unique (SRP) : l'alternative — tout fourrer dans un module unique — concentrerait tout le traitement transactionnel en un seul endroit volumineux et propice aux erreurs.

Une question subtile se pose ensuite : que créent ces trois transactions ? Naïvement, on serait tenté de répondre « trois sortes d'employés » et de dériver HourlyEmployee, SalariedEmployee et CommissionedEmployee de Employee. Le cas d'usage suivant va ruiner cette idée.

Poster pointages et relevés : des associations spécifiques

La transaction TimeCard <EmpId> <date> <hours> crée une carte de pointage et l'associe à l'employé — mais uniquement s'il est horaire ; sinon, erreur et abandon. De même, SalesReceipt <EmpId> <date> <amount> n'a de sens que pour un commissionné. Ces cas d'usage confirment que certaines transactions ne valent que pour certaines catégories, et révèlent des associations de composition : un horaire possède ses cartes de pointage, un commissionné ses relevés.

Changer les détails : la hiérarchie d'héritage s'effondre

Le cas d'usage le plus révélateur est le sixième. La transaction ChgEmp décline une dizaine de variantes : changer le nom, l'adresse, basculer d'horaire à salarié à commissionné, changer la méthode de paiement (conserver / dépôt direct / poste), ou affilier/désaffilier l'employé au syndicat.

Le fait qu'on puisse changer un employé d'horaire à salarié condamne la hiérarchie Employee envisagée plus haut : on ne reclasse pas un objet en changeant sa classe. La réponse est le patron STRATÉGIE (Strategy). Employee ne dérive plus en sous-types : elle délègue à des objets-stratégies interchangeables. Quand un horaire devient salarié, on remplace simplement sa HourlyClassification par une SalariedClassification, sans toucher au reste de l'objet.

Astuce

C'est la bascule conceptuelle clé de toute l'étude de cas : préférer la composition (déléguer à une stratégie) à l'héritage (être un sous-type) dès qu'un attribut peut changer au cours de la vie de l'objet. L'héritage fige la catégorie à la construction ; la délégation la rend mutable à volonté.

Quatre abstractions émergent ainsi, chacune injectée dans Employee :

  • PaymentClassification — comment calculer la paie. Trois dérivées : HourlyClassification (taux horaire + cartes de pointage), SalariedClassification (salaire mensuel), CommissionedClassification (salaire mensuel + taux de commission + relevés de vente).
  • PaymentMethod — comment verser la paie. Trois dérivées : HoldMethod, DirectMethod (banque + compte), MailMethod (adresse).
  • Affiliation — quelles déductions appliquer pour les organisations affiliées.

L'usage de ces patrons rend le système conforme au principe ouvert/fermé (OCP) : la classe Employee est fermée aux changements de méthode, de classification ou d'affiliation. On ajoute de nouvelles classifications, méthodes ou affiliations sans toucher à Employee.

                  «interface»          «interface»          «interface»
                PaymentMethod      PaymentClassification   PaymentSchedule
                      ▲                     ▲                    ▲
        ┌─────────────┤         ┌───────────┤          ┌────────┤
   HoldMethod   DirectMethod  Hourly   Salaried     Weekly   Monthly
   MailMethod                 Commissioned          Biweekly
                      ▲              ▲                    ▲
                      └──────────────┼────────────────────┘
                                 Employee

                                     ▼ 0..*
                                «interface» Affiliation  ◄── UnionAffiliation

Trouver les abstractions sous-jacentes

Pour appliquer l'OCP efficacement, il faut chasser les abstractions — y compris celles que ni les exigences ni les cas d'usage n'énoncent. Les exigences sont souvent trop noyées dans les détails pour exprimer les généralités.

La première généralisation se lit dans « certains travaillent à l'heure », « certains touchent un salaire fixe », « certains une commission » : tous sont payés, mais selon des schémas différents. C'est exactement ce qu'exprime PaymentClassification ; notre analyse l'a déjà trouvée.

L'abstraction du planning (schedule) est plus retorse : aucun cas d'usage ne la pointe. Les exigences disent « payés chaque vendredi », « le dernier jour ouvré du mois », « un vendredi sur deux ». La généralité est : tous les employés sont payés selon un calendrier. On pourrait loger ce calendrier dans PaymentClassification, puisque les exigences associent les deux. Ce serait une faute : un changement de politique de paie casserait alors le calendrier, et inversement — violation conjointe de l'OCP et du SRP. On en fait donc une abstraction indépendante, PaymentSchedule, avec ses dérivées WeeklySchedule, MonthlySchedule et BiweeklySchedule. La repérer exigeait de l'intelligence et de l'expérience, pas un outil.

Quant aux affiliations, le syndicat n'est sans doute pas la seule organisation à prélever sur une paie. La généralisation — « l'employé peut être affilié à plusieurs organisations » — pousserait à doter Employee d'une liste d'Affiliation. Nous verrons que le code nous fera revenir sur cette ambition.

L'implémentation pilotée par les tests

Place au code. Martin l'écrit par minuscules incréments, test d'abord, et n'en montre que des instantanés. L'UML n'est qu'un croquis de tableau blanc ; c'est le code qui tranche.

La transaction abstraite : Command

Toutes les transactions partagent une interface : une méthode execute. C'est le cœur du patron COMMANDE.

interface Transaction {
  execute(): void;
}

Ajouter un employé : Template Method

Les transactions d'ajout illustrent le patron PATRON DE MÉTHODE (Template Method). La méthode execute de la base orchestre le squelette invariant — créer l'employé, lui lier classification, planning et méthode par défaut, puis l'enregistrer — tandis que deux méthodes abstraites, déléguées aux dérivées, fournissent les pièces variables. C'est là, dans les transactions et non dans le modèle central, qu'on associe un planning à une classification : cette association est un artifice contingent, modifiable à tout moment, et le modèle central l'ignore.

abstract class AddEmployeeTransaction implements Transaction {
  constructor(
    private readonly empId: number,
    private readonly name: string,
    private readonly address: string,
  ) {}

  // Les « trous » du template, comblés par les dérivées.
  protected abstract makeClassification(): PaymentClassification;
  protected abstract makeSchedule(): PaymentSchedule;

  execute(): void {
    const classification = this.makeClassification();
    const schedule = this.makeSchedule();
    const method: PaymentMethod = new HoldMethod(); // défaut : conservé au guichet
    const employee = new Employee(this.empId, this.name, this.address);
    employee.setClassification(classification);
    employee.setSchedule(schedule);
    employee.setMethod(method);
    payrollDatabase.addEmployee(this.empId, employee);
  }
}

class AddSalariedEmployee extends AddEmployeeTransaction {
  constructor(empId: number, name: string, address: string, private readonly salary: number) {
    super(empId, name, address);
  }
  protected makeClassification(): PaymentClassification {
    return new SalariedClassification(this.salary);
  }
  protected makeSchedule(): PaymentSchedule {
    return new MonthlySchedule();
  }
}

Noter le choix par défaut : la méthode de paiement initiale est HoldMethod. Pour en changer, il faudra une transaction ChgEmp dédiée. Les variantes horaire et commissionnée se déduisent trivialement, en retournant respectivement une HourlyClassification + WeeklySchedule, et une CommissionedClassification + BiweeklySchedule.

La base de données : une Façade différée

AddEmployeeTransaction s'appuie sur un PayrollDatabase qui conserve les employés dans un dictionnaire indexé par identifiant, et un second dictionnaire reliant les numéros de membre syndical aux identifiants d'employé. C'est une Façade (Facade) : elle masque la mécanique de persistance derrière une API simple. À ce stade, l'implémentation est volontairement triviale — en mémoire — juste assez pour faire passer les premiers tests.

interface PayrollDatabase {
  addEmployee(empId: number, employee: Employee): void;
  getEmployee(empId: number): Employee | undefined;
  deleteEmployee(empId: number): void;
  addUnionMember(memberId: number, employee: Employee): void;
  getUnionMember(memberId: number): Employee | undefined;
  removeUnionMember(memberId: number): void;
  getAllEmployeeIds(): number[];
}

class InMemoryPayrollDatabase implements PayrollDatabase {
  private readonly employees = new Map<number, Employee>();
  private readonly unionMembers = new Map<number, Employee>();

  addEmployee(empId: number, employee: Employee): void {
    this.employees.set(empId, employee);
  }
  getEmployee(empId: number): Employee | undefined {
    return this.employees.get(empId);
  }
  deleteEmployee(empId: number): void {
    this.employees.delete(empId);
  }
  addUnionMember(memberId: number, employee: Employee): void {
    this.unionMembers.set(memberId, employee);
  }
  getUnionMember(memberId: number): Employee | undefined {
    return this.unionMembers.get(memberId);
  }
  removeUnionMember(memberId: number): void {
    this.unionMembers.delete(memberId);
  }
  getAllEmployeeIds(): number[] {
    return [...this.employees.keys()];
  }
}

Différer la base est inhabituel mais payant : on n'implémente que le strict nécessaire et on garde la liberté de choisir plus tard entre fichier plat, base relationnelle ou base objet. Du point de vue de l'application, la base de données n'existe presque pas.

En C++, Martin range l'instance de base dans une variable globale GpayrollDatabase, assumant pleinement ce choix : il n'existera jamais qu'une seule base, connue d'un très large public, et un Singleton ne ferait qu'envelopper la même globalité au prix d'une complexité superflue. En TypeScript, on exprime la même intention par un module exportant une unique instance — l'équivalent idiomatique, testable car l'interface PayrollDatabase reste injectable.

Poster un pointage : le downcast assumé

La transaction de pointage récupère l'employé, lui demande sa classification, et y ajoute une carte — à condition que cette classification soit horaire. Il faut donc un transtypage descendant (downcast) de PaymentClassification vers HourlyClassification. C'est un usage légitime du downcast : on ne peut ajouter une carte de pointage qu'à une classification horaire.

class TimeCardTransaction implements Transaction {
  constructor(
    private readonly date: number,
    private readonly hours: number,
    private readonly empId: number,
  ) {}

  execute(): void {
    const employee = payrollDatabase.getEmployee(this.empId);
    if (!employee) throw new Error("Aucun employé de ce numéro.");
    const classification = employee.getClassification();
    if (classification instanceof HourlyClassification) {
      classification.addTimeCard(new TimeCard(this.date, this.hours));
    } else {
      throw new Error("Pointage refusé : employé non horaire.");
    }
  }
}

Supprimer un employé : Command à l'état pur

Le cas le plus dépouillé du patron COMMANDE : le constructeur capture la donnée, et execute agit dessus plus tard.

class DeleteEmployeeTransaction implements Transaction {
  constructor(private readonly empId: number) {}
  execute(): void {
    payrollDatabase.deleteEmployee(this.empId);
  }
}

Changer un employé : encore Template Method

Toutes les transactions ChgEmp prennent un identifiant et doivent d'abord recharger l'employé depuis la base. On factorise ce comportement dans une base ChangeEmployeeTransaction, dont execute joue le rôle de squelette et délègue le changement proprement dit à une méthode abstraite change — Template Method, une fois de plus.

abstract class ChangeEmployeeTransaction implements Transaction {
  constructor(protected readonly empId: number) {}
  protected abstract change(employee: Employee): void;

  execute(): void {
    const employee = payrollDatabase.getEmployee(this.empId);
    if (employee) this.change(employee);
  }
}

class ChangeNameTransaction extends ChangeEmployeeTransaction {
  constructor(empId: number, private readonly name: string) {
    super(empId);
  }
  protected change(employee: Employee): void {
    employee.setName(this.name);
  }
}

Les transactions qui modifient la classification se regroupent sous une base ChangeClassificationTransaction : leur change crée la nouvelle classification et le nouveau planning associé, puis les pose sur l'employé — réintroduisant, dans cet artifice, l'association planning/classification que le modèle central refuse de connaître.

abstract class ChangeClassificationTransaction extends ChangeEmployeeTransaction {
  protected abstract makeClassification(): PaymentClassification;
  protected abstract makeSchedule(): PaymentSchedule;

  protected change(employee: Employee): void {
    employee.setClassification(this.makeClassification());
    employee.setSchedule(this.makeSchedule());
  }
}

« Mais qu'est-ce que je fumais ? » — quand le code corrige l'UML

L'affiliation a réservé une surprise instructive. L'UML disait : pour syndiquer un employé, lier une UnionAffiliation à l'Employee. Mais en écrivant le test de ChangeMemberTransaction, Martin constate qu'il faut aussi enregistrer le numéro de membre dans le PayrollDatabase — ce que l'UML n'avait jamais mentionné. Le test échoue, révélant l'oubli. La parade : ajouter à ChangeAffiliationTransaction une seconde méthode abstraite, recordMembership, qui inscrit l'appartenance pour ChangeMemberTransaction et l'efface pour ChangeUnaffiliatedTransaction.

abstract class ChangeAffiliationTransaction extends ChangeEmployeeTransaction {
  protected abstract makeAffiliation(): Affiliation;
  protected abstract recordMembership(employee: Employee): void;

  protected change(employee: Employee): void {
    this.recordMembership(employee);
    employee.setAffiliation(this.makeAffiliation());
  }
}

Au passage, Martin renonce à l'idée d'une liste d'affiliations : le test révèle qu'une seule setAffiliation suffit, les exigences n'en réclamant pas davantage. Maintenir une liste et ses downcasts serait une complexité gratuite. On conserve donc le patron OBJET NUL, avec une classe NoAffiliation dont la déduction vaut zéro.

Attention

Trop d'UML sans vérification par le code est dangereux. Le code dit sur la conception des choses que les diagrammes taisent — une association oubliée, une généralité inutile. Les diagrammes sont utiles pour communiquer, mais s'y fier sans retour du code et des tests, c'est jouer avec le feu. Ici, deux erreurs de conception (la liste d'affiliations, l'oubli de l'enregistrement syndical) n'ont été débusquées que par les tests.

Payer les employés : le polymorphisme à plein régime

Vient enfin la transaction reine, PaydayTransaction. Son execute parcourt tous les employés, demande à chacun si la date est son jour de paie, et le cas échéant lui fait remplir un bulletin (paycheck).

class PaydayTransaction implements Transaction {
  private readonly paychecks = new Map<number, Paycheck>();
  constructor(private readonly payDate: Date) {}

  execute(): void {
    for (const empId of payrollDatabase.getAllEmployeeIds()) {
      const employee = payrollDatabase.getEmployee(empId);
      if (employee && employee.isPayDate(this.payDate)) {
        const start = employee.getPayPeriodStartDate(this.payDate);
        const paycheck = new Paycheck(start, this.payDate);
        this.paychecks.set(empId, paycheck);
        employee.payday(paycheck);
      }
    }
  }

  getPaycheck(empId: number): Paycheck | undefined {
    return this.paychecks.get(empId);
  }
}

Cette poignée de lignes orchestre une débauche de polymorphisme : le calcul de la paie dépend de la PaymentClassification, le test du jour de paie de la PaymentSchedule, l'envoi du versement de la PaymentMethod, les déductions de l'Affiliation. Ce haut degré d'abstraction ferme l'algorithme contre l'ajout de nouvelles classifications, plannings, affiliations ou méthodes.

La classe Employee : un délégateur

Au centre, Employee ne calcule presque rien : elle délègue tout à ses stratégies. Sa méthode payday est l'algorithme générique de paie de tout employé, quel qu'il soit.

class Employee {
  private classification!: PaymentClassification;
  private schedule!: PaymentSchedule;
  private method!: PaymentMethod;
  private affiliation: Affiliation = new NoAffiliation();

  constructor(
    public readonly empId: number,
    private name: string,
    private address: string,
  ) {}

  setName(name: string): void { this.name = name; }
  setAddress(address: string): void { this.address = address; }
  setClassification(c: PaymentClassification): void { this.classification = c; }
  setSchedule(s: PaymentSchedule): void { this.schedule = s; }
  setMethod(m: PaymentMethod): void { this.method = m; }
  setAffiliation(a: Affiliation): void { this.affiliation = a; }

  getClassification(): PaymentClassification { return this.classification; }
  getAffiliation(): Affiliation { return this.affiliation; }

  isPayDate(date: Date): boolean {
    return this.schedule.isPayDate(date);
  }
  getPayPeriodStartDate(end: Date): Date {
    return this.schedule.getPayPeriodStartDate(end);
  }

  payday(paycheck: Paycheck): void {
    const grossPay = this.classification.calculatePay(paycheck);
    const deductions = this.affiliation.calculateDeductions(paycheck);
    paycheck.grossPay = grossPay;
    paycheck.deductions = deductions;
    paycheck.netPay = grossPay - deductions;
    this.method.pay(paycheck);
  }
}

Une décision métier déguisée, et une autre leçon de conception

Deux épisodes méritent l'attention. D'abord, Martin avait inventé une notion de postage (posting) pour empêcher de payer deux fois sur une même période — idée raisonnable, mais en réalité une décision métier prise sans consulter le client. Or celui-ci la rejette : il veut pouvoir relancer la paie après correction. Le postage saute.

Piège courant

Les développeurs ne devraient pas prendre les décisions métier à la place du client. Une « bonne idée » technique qui modifie le comportement observable du système — ici, l'idempotence de la paie — relève du métier, pas de la technique. Quand un tel choix se présente, on demande, on ne suppose pas.

Ensuite, le calcul des cotisations syndicales révèle un défaut de placement. Pour compter les vendredis d'une période de paie, UnionAffiliation doit connaître les bornes de la période. Or la fonction qui les détermine vivait au départ dans HourlyClassification — erreur : la période de paie relève du PaymentSchedule, pas de la classification ! La solution : faire transiter les dates de début et de fin par le Paycheck, calculées par le planning, de sorte que classification et affiliation y accèdent sans rien connaître l'une de l'autre. La logique « cette date est-elle entre ces deux ? » finit, neutre, dans un utilitaire Date.isBetween.

La persistance, traitée en dernier

Une fois l'itération analysée, conçue et implémentée, on choisit enfin comment PayrollDatabase persiste ses objets. Trois options : une base orientée objet (impact quasi nul sur le modèle), un simple fichier texte plat (relu au démarrage, réécrit à la fin — idéal pour les tests), ou une base relationnelle traduisant requêtes en objets. L'essentiel : pour l'application, la base n'est qu'un mécanisme de stockage. En la traitant en dernier, on garde toutes ces options ouvertes et on ne se lie à aucune technologie.

Le programme principal se réduit à une boucle : un TransactionSource abstrait livre des transactions, l'application les fait execute. La source peut être un analyseur de texte, une interface graphique ou un flux distant — l'abstraction les rend interchangeables.

En une cinquantaine de diagrammes et quelques milliers de lignes, on obtient une conception où de larges pans sont fermés aux changements de politique de paie. Ajouter une paie trimestrielle salaire + prime exigerait des ajouts, mais presque aucune modification de l'existant. C'est le dividende de l'abstraction et du polymorphisme appliqués aux bons axes — et l'on n'a presque jamais eu à se demander si l'on faisait de l'analyse, de la conception ou de l'implémentation : on cherchait la clarté et la fermeture, et le reste a suivi.

À retenir

  • Partir du comportement, pas des données : l'analyse par cas d'usage révèle les abstractions ; la base de données est un détail qu'on diffère jusqu'à la fin.
  • La conception émerge des cas d'usage : les trois formes d'AddEmp font surgir le pattern COMMANDE ; la possibilité de reclasser un employé condamne l'héritage et impose la STRATÉGIE.
  • Employee délègue tout à quatre abstractions — PaymentClassification, PaymentSchedule, PaymentMethod, Affiliation — ce qui la rend fermée (OCP) aux nouveaux modes de paie, plannings, méthodes et affiliations.
  • Le planning est une abstraction indépendante de la classification : les coupler violerait OCP et SRP. La trouver demandait de l'expérience, aucun cas d'usage ne la nommait.
  • Les transactions sont des objets COMMANDE ; leurs hiérarchies (AddEmployee..., Change...) exploitent le PATRON DE MÉTHODE pour partager le squelette et déléguer les variations.
  • Le code corrige l'UML : l'oubli de l'enregistrement syndical et la liste d'affiliations superflue n'ont été révélés que par les tests. Se fier aux diagrammes sans retour du code est risqué.
  • La persistance est une Façade traitée en dernier ; les décisions métier (le postage) reviennent au client, jamais au développeur.