Les principes SOLID par l'exemple



Lorsque l’on commence à parler de “bon code”, de qualité, ou encore de conception logicielle plus généralement, on finit toujours par tomber nez à nez avec les principes dit SOLID.

Le but de ce billet n’est pas de savoir pourquoi ni comment les principes SOLID existent. Ce n’est pas non plus un énième article essayant d’expliquer la théorie. Il existe une plétore de ressources sur ces sujets, à commencer par le livre Clean Code de Robert Martin. Récemment “Uncle Bob” a remit au goût du jour les principes SOLID sur son blog.

Bien que SOLID soit devenu une commodité dans notre industrie, ce sont des concepts qui restent difficile à saisir pour beaucoup d’entre nous. Généralement la théorie ne suffit pas. Les développeurs ont ce besoin de voir du code pour comprendre.

L’objectif de cet article est de partir d’un code simple mais cohérent et de le faire évoluer. Chaque étape sera une occasion de mettre en lumière un concept en particulier.

Est-ce que ce code est conforme aux principes SOLID ?

class Formatter
{
	public String format(String format, String text)
	{
		if (format == 'lower')
			return text.toLowerCase();
		if (format == 'html')
			return '<p>' + text + '</p>';
	}
}

Le principe de responsabilité unique (SRP)

Ma classe Formatter doit formater un texte brut dans un format donné. Mon code porte une triple responsabilité: celle de transformer le texte en minuscule ou d’appliquer une balise HTML et celle de décider quel formatage appliquer.

Single Responsability Principle

Le principe d’ouverture à l’extensibilité et de fermeture à la modification (OCP)

Que se passe t’il si j’ai besoin de pouvoir formater aussi mon texte en JSON par exemple ?

class Formatter
{
	public String format(String format, String text)
	{
		if (format == 'lower')
			return text.toLowerCase();
		if (format == 'html')
			return '<p>' + text + '</p>';
		if (format == 'json')
			return '{' + '"text":"' + text + '"' + '}';
	}
}

Pour étendre le comportement de ma classe j’ai dû la modifier.

Open-Closed Principle

Le principe de substitution de Liskov (LSP)

Le code ne dépend (pour le moment) pas de sous-type.

Liskov Substitution Principle

Le principe de ségrégation d’interface (ISP)

L’API publique de la classe n’a qu’une seule méthode. Cela veut dire qu’un utilisateur qui pourrait en dépendre ne finit pas avec des choses dont il n’a pas besoin.

Interface Segregation Principle

Et enfin le principe d’invertion de dépendance (DIP)

La classe Formatter n’a pas (encore) de dépendance.

Dependency Inversion Principle

En résumé

  • S ❌
  • O ❌
  • L ✅
  • I ✅
  • D ✅

Le code reste très simple. Je pourrais très bien ne plus y toucher. SOLID n’est pas une fin en soi. Néanmoins, si le code tend à évoluer, il sera de plus en plus difficile à maintenir comme dans l’exemple du JSON.

Essayons de rendre notre code conforme aux principes SOLID pour voir où cela nous mène.

Chacun chez soi et les moutons seront bien gardés

Ma première idée est de réduire ma charge cognitive: à chacun son problème.

class DelegatorFormatter
{
	private LowercaseFormatter lowercaseFormatter;
	private HtmlFormatter htmlFormatter;

	public DelegatorFormatter()
	{
		this.lowercaseFormatter = new LowercaseFormatter();
		this.htmlFormatter = new HtmlFormatter();
	}

	public String format(String format, String text)
	{
		if (format == 'text')
			return this.lowercaseFormatter.format(text);
		if (format == 'html')
			return this.htmlFormatter.format(text);
	}
}

class HtmlFormatter
{
	public String format(String text)
	{
		return '<p>' + text + '</p>';
	}
}

class LowercaseFormatter
{
	public String format(String text)
	{
		return text.toLowerCase();
	}
}

J’ai extrait la logique de formatage dans des implémentations dédiées. Maintenant Formatter a seulement la responsabilité de déléguer le travail à un autre collaborateur. C’est pourquoi j’ai changé son nom en DelegatorFormatter.

Ce code est maintenant conforme au principe de responsabilité unique ! 🙂

Cependant, ma classe n’est toujours pas extensible sans modifier son implémentation.
Pire, j’ai “cassé” le principe d’inversion de dépendance. DelegatorFormatter dépend maintenant d’un niveau de détail qui ne le concerne théoriquement pas. 😓

  • S ✅
  • O ❌
  • L ✅
  • I ✅
  • D ❌

Le DIP est fondamental pour une architecture modulaire

Je fais en sorte que ma classe puisse recevoir, via injection, des formatters sans en connaître concrètement les implémentations.

class DelegatorFormatter
{
	private Map<String, Object> formatters;

	public DelegatorFormatter(Map<String, Object> formatters)
	{
		this.formatters = formatters;
	}

	public String format(String format, String text)
	{
		return this.formatters.get(format).format(text);
	}
}

Ma classe n’a plus de dépendance spécifique. Elle est isolée et n’a plus conscience de détail d’implémentation d’une couche inférieure.

Mieux, le code est devenu aussi conforme avec OCP ! 😀
Il est maintenant possible d’étendre le comportement de ma classe en lui injectant les formatters nécessaires.

Attendez… Qu’en est-il de la substitution de Liskov maintenant ? 🤔
En tant que DelegatorFormatter rien ne m’assure que les formatters respectent le comportement attendu.

Prenons un exemple:

class VendorXMLFormatter
{
	public String formatInXml(String text) {...} // La signature de la méthode est différente.
}

Si je ne veux pas que mon programme lève une erreur au runtime je dois agir en fonction du collaborateur à qui je vais déléguer le travail.

// DelegatorFormatter class
public String format(String format, String text)
{
	if (format == 'xml') {
		return this.formatters.get(format).formatInXml(text);
	}

	return this.formatters.get(format).format(text);
}

Mon code n’est plus conforme au LSP !

En plus j’ai aussi cassé OCP ! 🤬

  • S ✅
  • O ❌
  • L ❌
  • I ✅
  • D ✅

Une question de confiance

L’idée d’injecter les formatters est bonne. Il suffit simplement de faire respecter un contrat entre les parties.

interface Formatter
{
	public String format(String text);
}

class DelegatorFormatter
{
	private Map<String, Formatter> formatters;

	public DelegatorFormatter(Map<String, Formatter> formatters)
	{
		this.formatters = formatters;
	}

	public String format(String format, String text)
	{
		return this.formatters.get(format).format(text);
	}
}

En utilisant le Strategy pattern je n’ai plus à me soucier de comment DelegatorFormatter doit déléguer le travail.

Mon code est maintenant conforme à la substitution de Liskov et de nouveau au principe d’ouverture-fermeture ! 🥳

📣 Il existe plein de manières différentes pour casser la substitution de Liskov. Le typage fort aide mais il est toujours possible de commettre des confusions.
Gardez simplement en tête qu’il s’agit de respecter un contrat afin que les consommateurs puissent agir en toute confiance.

  • S ✅
  • O ✅
  • L ✅
  • I ✅
  • D ✅

Un petit mot sur la Ségrégation d’Interface

Jusque là rien ne me poussait à contrevenir à ce principe. Cependant imaginez un instant que notre application ai besoin de valider une chaîne de caractères en fonction du format attendu.

interface Formatter
{
	public String format(String text);
	public boolean isValid(String text);
}

class LowercaseFormatter implements Formatter
{
	public String format(String text) {...}

	public boolean isValid(String text)
	{
		// Est-ce que le texte est en minuscule ?
	}
}

Le problème avec cette solution c’est que pour le DelegatorFormatter la partie validation ne sert à rien puisque ce n’est pas l’objectif. Il n’en a pas besoin et pourtant chaque collaborateur dont il héritera devra implémenter une méthode qui n’a pas de sens dans ce context particulier.

  • S ✅
  • O ✅
  • L ✅
  • I ❌
  • D ✅

L’idée ici serait de séparer la notion de formatage et de validation et d’utiliser l’une ou l’autre des interfaces en fonction du contexte ! 👍

interface Formatter
{
	public String format(String text);
}

class LowercaseFormatter implements Formatter
{
	public String format(String text) {...}
}

interface Validator
{
	public boolean isValid(String text);
}

class LowercaseValidator implements Validator
{
	public boolean isValid(String text) {...}
}
  • S ✅
  • O ✅
  • L ✅
  • I ✅
  • D ✅

Conclusion

J’ai toujours trouvé les principes SOLID délicat à appréhender à partir du moment où je me concentrais uniquement sur la théorie.

Cependant c’est aussi une question d’automatisme. Avec un peu de pratique il est possible de produire du code solide très naturellement.

L’implémentation que j’ai proposé peut paraître trop simpliste pour être crédible.
En réalité il n’y a aucune raison d’écrire du code plus compliqué que ça.