Factory et le packaging du payroll
Le pattern Factory pour ne dépendre que d'abstractions, et l'application des principes de packaging au système de paie.
Le principe d'inversion des dépendances (Dependency-Inversion Principle, DIP) nous dit de préférer les dépendances vers des abstractions et d'éviter celles vers des classes concrètes, surtout quand ces dernières sont volatiles. Or chaque mot-clé new viole ce principe : il cloue le code appelant à une classe concrète. Le pattern Factory (fabrique) offre une issue : créer des instances d'objets concrets en ne dépendant que d'interfaces abstraites. Ce chapitre déroule d'abord ce pattern tel que Martin le présente — avec son compromis et son usage pour les tests — puis applique les principes de packaging au système de paie (Payroll), pour découper son code en packages cohérents et en chasser les cycles.
Le coût caché du new
Considérez cette ligne, parfaitement banale en apparence :
const c: Forme = new Cercle(origine, 1); Cercle est une classe concrète. Tout module qui crée des instances de Cercle doit donc dépendre de cette classe concrète, et viole le DIP. Toute ligne qui utilise new viole le DIP.
Faut-il pour autant bannir new ? Non. Il y a des cas où violer le DIP est presque sans danger. Le critère est la volatilité : plus une classe concrète est susceptible de changer, plus dépendre d'elle conduit aux ennuis. Créer des instances de string ne dérange personne — string ne changera pas de sitôt, dépendre d'elle est très sûr. En revanche, lorsqu'on développe activement une application, beaucoup de classes concrètes sont très volatiles ; dépendre d'elles est problématique. On préfère alors dépendre d'une interface abstraite, qui nous protège de la majorité des changements.
Note
Le DIP n'est pas une croisade contre new. C'est une question de gestion de la volatilité : on isole le code des classes concrètes instables, on laisse tranquilles les classes stables. La Factory est l'outil qui permet cet isolement quand il devient nécessaire.
Le pattern Factory
Reprenons le scénario problématique. Une classe SomeApp dépend de l'interface Forme (Shape) et n'utilise les formes qu'à travers cette interface — elle n'appelle jamais les méthodes spécifiques de Carre (Square) ou Cercle (Circle). Malheureusement, SomeApp crée aussi des instances de Carre et de Cercle, et doit donc, à cause de cela seul, dépendre de ces classes concrètes.
SomeApp
/ |
«creates»/ | «uses»
v v v
Carre Cercle «interface»
Forme La solution consiste à appliquer le pattern Factory. On introduit une interface FabriqueForme (ShapeFactory) dotée de deux méthodes, creerCarre et creerCercle. Toutes deux renvoient une Forme, même si l'une fabrique un Carre et l'autre un Cercle. Une classe concrète FabriqueFormeImpl implémente l'interface.
SomeApp
/
v v
«interface» «interface»
FabriqueForme Forme
+ creerCarre() ^
+ creerCercle() |
^ Carre, Cercle
| ^
FabriqueFormeImpl ······┘ «creates» Voici le code. L'interface ne connaît que l'abstraction Forme ; l'implémentation est la seule à toucher le concret.
// L'abstraction partagée par tous les produits.
interface Forme {
dessiner(): void;
}
class Cercle implements Forme {
dessiner(): void {
/* ... */
}
}
class Carre implements Forme {
dessiner(): void {
/* ... */
}
}
// L'interface de fabrique : elle ne renvoie que des Forme.
interface FabriqueForme {
creerCercle(): Forme;
creerCarre(): Forme;
}
// L'unique endroit qui dépend des classes concrètes.
class FabriqueFormeImpl implements FabriqueForme {
creerCercle(): Forme {
return new Cercle();
}
creerCarre(): Forme {
return new Carre();
}
} Le problème est entièrement résolu. Le code applicatif ne dépend plus de Cercle ni de Carre, et pourtant il parvient à en créer des instances. Il les manipule à travers l'interface Forme et n'invoque jamais de méthode spécifique à Carre ou Cercle. La dépendance vers le concret n'a pas disparu — elle a déménagé. Quelqu'un doit bien créer FabriqueFormeImpl, mais plus personne d'autre n'a besoin de créer Carre ou Cercle. Ce « quelqu'un » sera très probablement le main du programme, ou une fonction d'initialisation rattachée à main.
À retenir
La Factory est au service du DIP. Elle permet aux modules de politique de haut niveau de créer des instances sans dépendre des implémentations concrètes. On repousse les new vers un point unique et bas niveau (FabriqueFormeImpl, créé par main), de sorte que tout le reste du système ne dépende plus que d'abstractions.
Un cycle de dépendances
Un lecteur attentif repérera un défaut dans cette forme du pattern. L'interface FabriqueForme possède une méthode pour chaque dérivée de Forme. Cela crée un cycle de dépendances qui rend difficile l'ajout de nouvelles formes : à chaque nouvelle dérivée, il faut ajouter une méthode à l'interface FabriqueForme, ce qui force, dans la plupart des cas, à recompiler et redéployer tous les utilisateurs de la fabrique.
On peut briser ce cycle en sacrifiant un peu de sûreté de typage. Au lieu d'une méthode par dérivée, on donne à la fabrique une unique méthode creer qui reçoit une chaîne. L'implémentation utilise alors une chaîne de if/else sur l'argument pour choisir la dérivée à instancier.
// Une seule méthode, paramétrée par un nom.
interface FabriqueForme {
creer(nomForme: string): Forme;
}
class FabriqueFormeImpl implements FabriqueForme {
creer(nomForme: string): Forme {
if (nomForme === "Cercle") return new Cercle();
if (nomForme === "Carre") return new Carre();
throw new Error(`FabriqueForme ne peut pas créer ${nomForme}`);
}
} On pourrait objecter que c'est dangereux : un appelant qui se trompe dans l'orthographe du nom obtiendra une erreur à l'exécution plutôt qu'à la compilation. C'est vrai. Mais si vous écrivez le nombre approprié de tests unitaires et que vous pratiquez le développement piloté par les tests (Test-Driven Development, TDD), vous attraperez ces erreurs d'exécution bien avant qu'elles ne deviennent des problèmes.
// Le test attrape immédiatement toute régression.
function testCreerCercle(fabrique: FabriqueForme): void {
const s = fabrique.creer("Cercle");
assert(s instanceof Cercle);
} Des fabriques substituables
L'un des grands bénéfices des fabriques est la possibilité de substituer une implémentation de fabrique à une autre. On échange ainsi des familles d'objets entières au sein d'une application.
Imaginez une application qui doit s'adapter à plusieurs implémentations de base de données : les utilisateurs peuvent travailler avec des fichiers plats ou acheter un adaptateur Oracle. On utilise le pattern Proxy pour isoler l'application de la base de données, et des fabriques pour instancier les proxies. Il existe alors deux implémentations de FabriqueEmploye : l'une crée des proxies qui travaillent avec des fichiers plats, l'autre des proxies qui travaillent avec Oracle. L'application ne sait pas — et ne se soucie pas — de laquelle est utilisée.
Application ─────────────> «interface»
/ FabriqueEmploye
v v + creerEmploye()
«interface» «interface» + creerFicheTemps()
Employe FicheTemps ^
^ ^ ┌──────┴───────┐
OracleEmploye OracleFicheTemps │ │
FichierEmploye FichierFicheTemps FabriqueOracle FabriqueFichier Les fabriques au service des tests
Quand on écrit des tests unitaires, on veut souvent tester un module isolé des modules qu'il utilise. Prenons une application Payroll (paie) qui utilise une base de données. On souhaite tester la fonction du module Payroll sans toucher à la vraie base.
On y parvient en passant par une interface abstraite Database. Une implémentation utilise la vraie base ; une autre est du code de test, écrit pour simuler le comportement de la base et vérifier que les appels sont émis correctement. Le module de test PayrollTest teste Payroll en l'appelant, et implémente l'interface Database pour piéger les appels que Payroll adresse à la base. Cela permet à PayrollTest de s'assurer que Payroll se comporte correctement, mais aussi de simuler toutes sortes de pannes et de problèmes de base de données difficiles à provoquer autrement. C'est la technique du leurre (spoofing).
// L'abstraction qui découple Payroll de la persistance.
interface Database {
ajouterEmploye(emp: Employe): void;
obtenirEmploye(id: number): Employe;
}
// Le test implémente Database pour piéger les appels.
class PayrollTest implements Database {
private appels: string[] = [];
ajouterEmploye(emp: Employe): void {
this.appels.push(`ajouter:${emp.id}`);
}
obtenirEmploye(id: number): Employe {
this.appels.push(`obtenir:${id}`);
return new Employe(id);
}
testPayday(): void {
const payroll = new Payroll(this); // on injecte le leurre
payroll.payday();
assert(this.appels.includes("obtenir:1"));
}
} Reste une question : comment Payroll obtient-il l'instance de PayrollTest qu'il utilise comme Database ? Dans certains cas, il est naturel que PayrollTest passe la référence Database à Payroll (injection par constructeur, comme ci-dessus). Dans d'autres, PayrollTest doit positionner une variable globale qui pointe vers la Database. Dans d'autres encore, Payroll s'attend pleinement à créer lui-même l'instance de Database. C'est dans ce dernier cas qu'une fabrique permet de berner Payroll : en lui passant une fabrique alternative, on l'amène à créer la version de test de la base.
«global» GfabriqueDatabase ──> «interface»
^ FabriqueDatabase
┌─────────────┤ ^
Payroll │ ┌────────┴─────────┐
^ │ FabriqueDatabaseImpl (PayrollTest
PayrollTest ───────┘ «creates» implémente
v la fabrique)
Database Payroll acquiert la fabrique via une variable globale GfabriqueDatabase. PayrollTest implémente FabriqueDatabase et y dépose une référence vers lui-même. Quand Payroll utilise la fabrique pour créer une Database, PayrollTest piège l'appel et renvoie une référence vers lui-même. Payroll est ainsi convaincu d'avoir créé la vraie base, alors que PayrollTest peut intégralement le leurrer et capturer tous les appels.
Faut-il toujours utiliser des fabriques ?
Une interprétation stricte du DIP exigerait une fabrique pour chaque classe volatile du système. De plus, la puissance du pattern Factory est séduisante. Ces deux facteurs poussent parfois les développeurs à l'employer par défaut. C'est un excès que Martin déconseille.
Attention
Ne commencez pas par des fabriques. Ne les introduisez que lorsque le besoin devient suffisamment pressant : par exemple s'il faut utiliser le pattern Proxy (alors il faudra probablement une fabrique pour créer les objets persistants), ou si, en testant, vous tombez sur une situation où vous devez leurrer le créateur d'un objet. Ne partez pas du principe que les fabriques seront nécessaires.
Les fabriques sont une complexité qu'on peut souvent éviter, surtout aux premières phases d'une conception évolutive. Employées par défaut, elles augmentent radicalement la difficulté d'étendre la conception : pour créer une nouvelle classe, on peut devoir en créer jusqu'à quatre (les deux interfaces — celle de la classe et celle de sa fabrique — et les deux classes concrètes qui les implémentent). Les fabriques sont des outils puissants, d'un grand secours pour respecter le DIP et pour échanger des familles d'implémentations entières ; mais les utiliser par défaut est rarement la meilleure décision.
Le packaging du Payroll
Le système de paie a fait l'objet d'une analyse, d'une conception et d'une implémentation considérables. Mais une décision reste à prendre. Jusqu'ici, un seul programmeur a travaillé dessus, et la structure le reflète : tous les fichiers vivent dans un unique répertoire, sans aucune structure d'ordre supérieur — pas de packages, pas de sous-systèmes, aucune unité livrable autre que l'application entière. Cela ne tiendra pas à l'avenir.
L'application compte 3280 lignes de code réparties dans une cinquantaine de classes et une centaine de fichiers source. Ce n'est pas énorme, mais cela représente un fardeau organisationnel. À mesure que le programme grossit, le nombre de développeurs grossira aussi ; pour leur permettre de travailler sans se gêner, il faut partitionner le code source en packages que l'on puisse récupérer, modifier et tester commodément.
Une première découpe naïve
Une première structure consiste à regrouper les classes « qui semblent aller ensemble ». On obtient huit packages : PayrollApplication, Transactions, PayrollDatabase, Methods, Schedules, Classifications, Affiliations, Application. Par convention, on dessine les diagrammes de packages avec les dépendances pointant vers le bas : les packages du haut dépendent, ceux du bas sont dépendus.
PayrollApplication
|
Application
|
PayrollDatabase
/ | |
Methods Schedules Classifications Affiliations Comme nous l'avons appris avec les principes de packaging, ce regroupement intuitif est probablement une mauvaise idée. Que se passe-t-il si l'on modifie le package Classifications ? Cela force, à juste titre, la recompilation et le retest de PayrollDatabase. Mais cela force aussi la recompilation du package Transactions — alors que seules ChangeClassificationTransaction et ses dérivées en avaient réellement besoin. Les classes de Transactions ne partagent pas la même clôture : ServiceChargeTransaction est sensible aux changements de ServiceCharge, tandis que TimeCardTransaction est sensible à ceux de TimeCard. Le package Transactions dépend de presque tout le reste, et souffre donc d'un taux de rerelease très élevé. PayrollApplication est pire encore : tout changement, où qu'il soit, l'affecte.
Piège courant
Cette structure place les détails (Methods, Schedules, Classifications…) en bas, où ils deviennent indépendants et très responsables — exactement le mauvais endroit. Les détails devraient dépendre des décisions architecturales majeures, pas l'inverse. C'est une violation du principe des abstractions stables (Stable-Abstractions Principle, SAP) : il vaudrait mieux que l'architecture contraigne les détails.
Appliquer le principe de fermeture commune (CCP)
Regroupons à présent les classes selon leur clôture, en application du principe de fermeture commune (Common-Closure Principle, CCP). Le package le plus frappant est PayrollDomain : il contient l'essence du système entier — Employe, PaymentClassification, PaymentMethod, PaymentSchedule, Affiliation, Transaction — et pourtant il ne dépend de rien. Pourquoi ? Parce que presque toutes ces classes sont abstraites.
TextParser PayrollApplication
|
v
Application
|
v v
Classifications, Methods, (détails : dérivées
Schedules, Affiliations concrètes + transactions)
|
v
PayrollDomain <── abstractions, ne dépend de rien
^
PayrollDatabase Cette structure est renversée par rapport à la précédente : les détails dépendent des généralités, et les généralités sont indépendantes — ce qui est conforme au DIP. Examinons le package Classifications, qui contient les trois dérivées de PaymentClassification, plus ChangeClassificationTransaction et ses dérivées, ainsi que TimeCard et SalesReceipt. Tout changement apporté à ces neuf classes est isolé : aucun autre package n'est affecté (hormis TextParser). La même isolation vaut pour Methods, Schedules et Affiliations.
L'essentiel du code exécutable se trouve désormais dans des packages qui n'ont que peu ou pas de dépendants : on les dit irresponsables, car presque rien ne dépend d'eux ; leur code est extrêmement flexible, modifiable sans affecter le reste. À l'inverse, les packages les plus généraux contiennent le moins de code exécutable : très dépendus mais ne dépendant de rien, ils sont responsables et indépendants. Le volume de code responsable (dont les changements affecteraient beaucoup d'autre code) est donc minuscule, et de surcroît indépendant. Cette structure renversée — généralités indépendantes et responsables en bas, détails irresponsables et dépendants en haut — est la marque de fabrique de la conception orientée objet.
Appliquer le principe d'équivalence réutilisation/release (REP)
Quelles parties du Payroll peut-on réutiliser ? Si une autre division de l'entreprise voulait reprendre notre système mais avec des politiques différentes, elle ne pourrait pas réutiliser Classifications, Methods, Schedules ni Affiliations — mais elle pourrait réutiliser PayrollDomain, PayrollApplication, Application, PayrollDatabase et éventuellement PDImplementation. Le grain de réutilisation est le package, en application du principe d'équivalence réutilisation/release (Reuse-Release Equivalency Principle, REP). On ne réutilise quasiment jamais une seule classe d'un package, car les classes d'un package doivent être cohésives : réutiliser Employe sans PaymentMethod n'aurait aucun sens.
La première découpe (la naïve) violait le principe de réutilisation commune (Common-Reuse Principle, CRP) : les packages que l'on aimerait réutiliser, comme Transactions, traînaient trop de bagages. Pour réutiliser leurs fragments, il aurait fallu prélever des classes individuelles ici et là, détruisant la structure de release : on ne pourrait plus dire que « la release 3.2 de PayrollApplication est réutilisable ». Le réutilisateur finirait par copier les composants et les faire évoluer séparément — ce qui n'est pas de la réutilisation, mais un doublement de la charge de maintenance.
Astuce
CCP, CRP et REP tirent dans des directions parfois opposées. Le CCP rassemble ce qui change ensemble (utile au développeur), le CRP sépare ce qui ne se réutilise pas ensemble (utile au réutilisateur), le REP fait du package le grain de release. Trouver le bon équilibre est un jugement, qui doit être guidé par les données (taux de changement, profils de réutilisation réels) plutôt que par la spéculation.
La structure par clôture ne conforme pas tout à fait au CRP : dans PayrollDomain, la classe Transaction n'a pas besoin d'être réutilisée avec le reste — on peut concevoir des applications qui lisent Employe sans jamais toucher à une Transaction. On sépare donc les transactions des éléments qu'elles manipulent : les classes de MethodTransactions manipulent celles de Methods, et ainsi de suite. Transaction migre vers un nouveau package TransactionApplication (avec TransactionSource et TransactionApplication), qui forme une unité réutilisable par toute application qui obtient des Transaction d'une source et les exécute. On peut désormais réutiliser PayrollDomain sans aucune transaction. Le prix : cinq packages de plus et une architecture de dépendances plus complexe. Il vaut mieux commencer simple et faire croître la structure au besoin.
Briser les cycles avec des fabriques
Pourquoi Classifications et ClassificationTransactions sont-ils si lourdement dépendus ? Parce que leurs classes doivent être instanciées. TextParserTransactionSource doit pouvoir créer des AddHourlyEmployeeTransaction : d'où un couplage afférent vers ClassificationTransactions. De même, ChangeHourlyTransaction doit créer des HourlyClassification : d'où un couplage afférent vers Classification. Presque tout autre usage de ces objets passe par leur interface abstraite. Sans le besoin de créer chaque objet concret, ces couplages afférents n'existeraient pas.
Ce problème se résorbe nettement grâce au pattern Factory : chaque package fournit une fabrique d'objets responsable de créer tous ses objets publics. Le package TransactionFactory contient la classe de base abstraite, dont les méthodes représentent les constructeurs des transactions concrètes ; le package TransactionImplementation contient la fabrique concrète et utilise toutes les transactions concrètes pour les créer.
// L'abstraction de fabrique, dépendue par les clients.
interface FabriqueTransaction {
creerAddSalary(): Transaction;
creerAddHourly(): Transaction;
creerAddCommissioned(): Transaction;
creerDeleteEmploye(): Transaction;
creerPayday(): Transaction;
creerTimecard(): Transaction;
creerSalesReceipt(): Transaction;
}
// La concrétisation, seule à dépendre des transactions concrètes.
class FabriqueTransactionImpl implements FabriqueTransaction {
creerAddHourly(): Transaction {
return new AddHourlyEmployeeTransaction();
}
// ... une méthode par transaction concrète.
} Initialiser les fabriques
Pour créer des objets via ces fabriques, le membre statique de chaque fabrique abstraite doit pointer vers la fabrique concrète appropriée, avant toute utilisation. Le meilleur endroit pour cela est généralement le programme main. Conséquence : main dépend de toutes les fabriques et de tous les packages concrets ; chaque package concret reçoit donc au moins un couplage afférent depuis main. Cela écarte un peu les packages concrets de la séquence principale (main sequence), mais on n'y peut rien — et en pratique, on ignore souvent les couplages venant de main, puisqu'il faut de toute façon retester main à chaque changement.
// main initialise les fabriques, puis tout le reste
// ne dépend plus que des interfaces de fabrique.
function main(): void {
GfabriqueTransaction.set(new FabriqueTransactionImpl());
GfabriquePayroll.set(new FabriquePayrollImpl());
new PayrollApplication().run();
} La structure finale
Le diagramme par clôture restait très enchevêtré — difficile à gérer à la main sans outil de planification automatisé. Pour le simplifier, on tranche : le partitionnement par transaction prime sur le partitionnement fonctionnel. On fusionne donc toutes les transactions concrètes dans un unique TransactionImplementation, et l'on fusionne Classifications, Schedules, Methods et Affiliations dans un unique PayrollImplementation. Les fabriques (TransactionFactory, PayrollFactory) rapprochent ces packages concrets de la séquence principale.
PayrollApplication
/ |
TextParser TransactionImplementation PayrollImplementation
Transaction ^ | | ^ |
Source | | └──> AbstractTransactions |
| | | |
TransactionFactory | v v v
TransactionApplication PayrollFactory
| |
v v v
Application PayrollDatabaseImplementation |
| |
v v v
PayrollDatabase ──> PayrollDomain <──┘ Les métriques sur cette structure finale sont rassurantes : les cohésions relationnelles sont toutes élevées (en partie grâce aux relations des fabriques concrètes vers les objets qu'elles créent), et aucun package ne s'écarte significativement de la séquence principale. Les packages abstraits (PayrollDomain, TransactionApplication, AbstractTransactions) sont clos, réutilisables et fortement dépendus, tout en ayant peu de dépendances propres. Les packages concrets (TransactionImplementation, PayrollImplementation) sont ségrégués sur la base de la réutilisation, fortement dépendants des packages abstraits, et peu dépendus eux-mêmes. C'est l'équilibre d'un environnement de développement sain.
Note
On peut quantifier cohésion, couplage, stabilité et généralité avec quelques métriques simples : cohésion relationnelle (H), couplages afférent (Ca) et efférent (Ce), abstraction (A = classes abstraites / total), instabilité (I = Ce / (Ce + Ca)) et distance à la séquence principale (D' = |A + I − 1|, idéalement proche de 0). Pour paraphraser Tom DeMarco : on ne peut maîtriser que ce que l'on mesure.
Le besoin de gérer la structure des packages est fonction de la taille du programme et de l'équipe. Même de petites équipes doivent partitionner le code pour ne pas se gêner ; les grands programmes deviennent des masses opaques de fichiers sans une structure de partitionnement. Mais on n'y arrive pas du premier coup : on part simple, on mesure, et l'on fait évoluer le découpage à mesure que la pression du changement et de la réutilisation se révèle.
À retenir
- Tout
newviole le DIP : il couple le code à une classe concrète. Le problème ne devient sérieux que pour les classes volatiles ; dépendre d'une classe stable commestringest sans danger. - La Factory crée des objets concrets en ne dépendant que d'abstractions : le code applicatif ne connaît que l'interface, et les
newsont repoussés dans une seule implémentation créée parmain. - N'abusez pas des fabriques : ne les introduisez que quand le besoin est réel (Proxy, persistance, leurre de test). Par défaut, elles multiplient les classes et alourdissent l'extension.
- Les fabriques substituables permettent d'échanger des familles d'objets entières, et de leurrer un module en test en lui injectant une fabrique alternative.
- Le packaging du Payroll illustre les principes : CCP (rassembler ce qui change ensemble), REP/CRP (le package est le grain de réutilisation et de release), SAP/DIP (architecture renversée : abstractions stables en bas, détails volatils en haut).
- Les fabriques d'objets brisent les cycles et les couplages afférents nés du besoin d'instancier des classes concrètes, rapprochant les packages concrets de la séquence principale.
- Commencez simple, mesurez (H, A, I, D'), et faites croître la structure des packages selon les données plutôt que la spéculation.