Interfaces et implémentations uniques

Il y a encore quelques années, il semblait avisé de ne déclarer une interface qu’après 3 implémentations. Cette pratique visait à apporter de la généricité au code uniquement lorsque l’on était certain d’avoir pris connaissance de la totalité des besoins et des cas d’utilisation. Ce principe, nommé le RAP (Reuse Abstraction Principle) a été abandonné dans les années 90, avant l’avènement des méthodes agiles, au moment ou il a été démontré qu’il était économiquement judicieux de réutiliser un maximum de code. Ce principe millésimé visait aussi à encourager un design émergent de l’application. De nos jours, la ligne directrice a évolué. Il est maintenant préconisé de coder contre des abstractions, et cela, dès les premières lignes de code.

“Un bon développeur code contre des interfaces.”

Pourquoi utiliser des interfaces ?

Les interfaces sont très utiles. Elles permettent de créer des abstractions autour de certains composants, et de rendre ces composants enfichables. Elles permettent de définir des contrats d’interface, et de préciser les responsabilités des classes. Elles ne font que déclarer des aptitudes. Elle ne comprennent aucune de ces fonctionnalités, et permettent de dissimuler les détails de leur mise en oeuvre. Elles nous indiquent les méthodes publiques que l’on peut invoquer sur les classes qui en héritent, à savoir le “quoi”, et le développeur choisit de ne pas se préoccuper du “comment”, si cela n’a aucune importance sur son périmètre. Elles garantissent par la même occasion que les développeurs ne puissent pas être en mesure de modifier par inadvertance les responsabilités de certains services. Avec elles, il devient possible de tester beaucoup plus efficacement et simplement les systèmes, car il devient possible d’enficher des composants dans le système, et de substituer un comportement par un autre. Les implémentations peuvent quand à elles être résolues par des moteurs d’injection de dépendances, ou même, être totalement symboliques (si on utilise des librairies de mocking, comme Moq). Génial me direz-vous, mais alors, ou est le problème ?

Un problème, quel problème ?

Concrètement, cette ligne directrice, appliquée sans discernement, donne lieu à des pratiques qui complexifient lourdement le code. Chaque interface donnant lieu à une implémentation unique, on trouve dans beaucoup de solutions des librairies entières dédiées à la définition d’interfaces. On ne mesure pas la qualité d’un code au nombre d’interfaces déclarées. Le volume de code est plus important, et du code est répété. Les définitions des interfaces sont dupliquées sur les classes héritant de ces interfaces (en violation de DRY). La structure de la solution est plus lourde, et il est parfois très compliqué de résoudre les mises en oeuvre de ces interfaces, qui figurent, tantôt des fichiers de configuration, tantôt dans des factories, tantôt des classes de bootstrapping, etc… Si l’adoption d’interfaces est pratique, elle rajoute un niveau de complexité supplémentaire aux solutions, dès qu’elle est massive, systématique, et appliquée sans analyse (en violation de KISS).

Usage de la guideline

Le problème, c’est donc d’appliquer cette guideline de façon systématique, de peur de ne pas être reconnu comme un bon développeur. Beaucoup de développeurs rajoutent donc ces interfaces contre des composants techniques, ou métier, très profondément au coeur de l’application. Quel intérêt avons-nous à promouvoir des aptitudes qui ne seront pas exposées vers l’extérieur ? Aucune. Cette pratique a émergé avec le TDD. Pour les développeurs férus de TDD, une parfaite couverture de tests unitaires implique que l’on puisse être en mesure de mocker absolument tous les composants de la solution : métier comme transverse, de façon très spéculative. Il est déjà difficile de coder un système conforme aux attentes, il l’est encore plus de l’encombrer de scénarios visant à prévoir un futur qui ne verra peut-être jamais le jour. Voilà une approche étrange (en violation de YAGNI). Maintenant, si nous nous concentrons sur l’aspect fonctionnel, il est totalement paradoxal de vouloir abstraire les composants métier qui sont au coeur de tous les enjeux des tests. Si le composant en question est mocké, ou s’il dépend d’autres composants métier mockés, le test est une perte de temps. Chaque bouchon mis en place nous amène à déclarer certaines préconditions qui nous font supposer que le composant en question se comporte bien. Quel intérêt avons-nous à tester un système construit pour fonctionner de la manière attendue ? L’intention du test est complètement biaisée. Même si la couverture de tests est parfaite, elle est inutile, car nous ne sommes pas en mesure de garantir que le système se comportera bien, une fois que tous les bouchons auront été supprimés, et que les composants interagiront tous ensemble.

Couplage fort

Le couplage entre les instances et les interfaces est réel, et fort, et par conséquent, celui des librairies l’est aussi. Si vous utilisez un moteur d’injection de dépendances comme Unity, vous aurez besoin de référencer dans le projet les librairies déclarant les interfaces, et les librairies implémentant les classes correspondantes. Rien n’est magique. Le système n’est pas aussi faiblement couplé qu’on le pense à priori. Certaines IDE poussent au crime. Visual Studio incite lourdement à multiplier les interfaces en proposant l’extraction des interfaces de façon très simple, en créant potentiellement de l’adhérence entre des classes que l’on souhaite séparer, sans lever la moindre alerte.

var logger = new Logger();
// Classe liée à l'infrastructure
var fileWriter = new FileWriter(@"C:\tmp\errors.txt");
// Implémentation d'une IoC
logger.Log("An error occured...", fileWriter);
public interface ILogger
{
    public void Log(string message, FileWriter writer);
}

Et cela peut inciter à définir d’avantage d’interfaces.

public interface ILogger
{
    public void Log(string message, IWriter writer);
}
public class FileWriter : IWriter
{
}

Comme les constructeurs ne peuvent pas être déclarés dans des interfaces, il était préférable de passer l’instance de FileWriter au constructeur de la classe Logger. Il s’agit uniquement d’un exemple.

Mocker, oui mais…

Savoir mocker est capital. Les mocks permettent de différer les tests d’intégration et d’implémenter un maximum de tests fonctionnels sur les machines des développeurs.

Sans mocks, il devient difficile de mener à bien ces tests de façon efficace :

  • Accès aux données : les tests d’intégration consomment des données. Dès lors que les données ont été altérées, elles sont rendues obsolètes car elles ne correspondent plus aux préconditions du test. Il est nécessaire de mettre au point une usine de tests afin de pouvoir remonter simplement un environnement conforme aux préconditions. Même si on dispose maintenant d’outils performants, le coût de cette maintenance est important et laisse trop peu de latitude à entreprendre des tests.
  • Accès à des services distants : à l’heure des architectures distribuées, il est probable que le système à tester doive interagir avec des services tiers, de partenaires par exemple. Il est important de pouvoir s’affranchir de ces adhérences.
  • Simulation d’erreurs : il peut être important de simuler des comportements défaillants afin de bien tester la résilience de la solution mise en oeuvre. Ceci afin d’être en mesure de prévoir le comportement global du système et de permettre un fonctionnement dégradé, mais à minima opérationnel. Par exemple, l’indisponibilité temporaire d’un support de log ne doit jamais faire dérailler le système. Celui-ci doit être résilient. De tels mocks permettent d’implémenter des contre-mesures pertinentes, en amont, et de ne pas nous retrouver trop gênés lorsque le problème arrive en production.

Cependant, les mocks doivent être installés avec parcimonie, et ce, afin de ne pas inutilement complexifier l’écriture du test. Par exemple, disposer de mocks métier à l’intérieur du Bounded Context (BC) ne répond aucun besoin précis. Si on ne prévoit pas de mocker à cet endroit, il n’y a aucun intérêt à implémenter des interfaces. Si nous prenons les quelques exemples de mocks énumérés ci-dessus, il apparaît que les mocks sont surtout utiles aux limites du BC. C’est à dire, à chaque point d’entrée ou de sortie impliquant plus ou moins l’infrastructure, ou justifiant une interaction avec un service tiers. Aborder les développements par ce prisme incite complètement le développeur à bien séparer les responsabilités des composants, comme on peut l’entreprendre avec une architecture en oignon, au sein de laquelle le métier ne référence aucun des artéfacts technologiques.

Le mot de la fin

“Un bon développeur doit surtout savoir ou il doit déclarer des interfaces.”

En résumé, et comme souvent en développement, il est capital d’aligner les besoins du code sur ceux du métier, et de ne pas transformer la solution en une démonstration de patterns afin d’éviter un procès en insuffisance. Mortifère. Et ne pas surjouer des abstractions. Certains développements accèdent aux données par un ORM (première abstraction). Cela n’étant pas suffisant, la forme des repositories est déclarée dans des interfaces (seconde abstraction). Ensuite, celles-ci sont accédées à travers une API REST (troisième abstraction). Cela demande des contrats d’interfaces à fournir aux clients (par un contrat d’interface, aka une quatrième abstraction). Cela rend l’accès aux données particulièrement coûteux, et peu performant.

Add a Comment

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

85 − 84 =