1. Problème du singleton mutable dans Spring
Le singleton dans Spring est une chose absolument normale et même confortable : par défaut, presque tous vos @Service et @Component sont des singleton-bean. Mais le singleton a un « super-pouvoir » qui est en même temps son point faible : le même objet sert à tous les appels. Cela signifie que tout ce que vous placez dans les champs de cet objet devient commun — pour tous les scénarios, tous les appels et toute la durée de vie du conteneur. Et c'est là que commencent les histoires du genre « pourquoi ma commande ORD-1 s'est soudainement envoyée par SMS alors que je faisais EMAIL ».
Reprenons calmement deux notions que les débutants confondent souvent :
- scope répond à la question : combien d'instances et combien de temps le conteneur les gère.
- « état » (state) répond à la question : que stocke l'objet lui-même entre les appels.
Et dans cette leçon, c'est la seconde notion qui nous intéresse.
Mini-schéma : où se niche le problème
Lorsque votre service est un singleton, il existe une seule instance pour tout le conteneur. Tout appel, depuis n'importe quel endroit de l'application, atteint le même objet.
flowchart LR
A[ScenarioRunner: créer commande] --> S[OrderCancellationService singleton]
B[ScenarioRunner: annuler commande] --> S
C[Reporting] --> S
S -->|champs de l'objet| State[(état mutable partagé)]
Si State (les champs) est modifié pendant un appel, l'appel suivant peut voir les « traces » du précédent. Parfois cela se manifeste immédiatement. Parfois seulement une semaine plus tard, lorsque vous ajoutez un nouveau scénario et qu'un service se retrouve utilisé plus fréquemment.
2. Stateful bean vs stateless service
Quand on dit « faites des services sans état », le débutant a souvent l'impression qu'on lui demande l'impossible : « comment ça, ne rien stocker du tout ? ». Bien sûr, vous allez stocker : dépendances, configurations, éventuellement un cache de référentiel. Ce qui importe, c'est autre chose : ne pas stocker dans les champs ce qui concerne une opération particulière (une commande, un rapport, un lancement de scénario). C'est exactement ce qu'on appelle « état temporaire d'une opération ».
Pour éviter la confusion, il est utile d'avoir en tête un tableau simple. Il ne s'agit pas d'une théorie stricte, mais d'une « aide-mémoire » d'ingénieur vraiment pratique.
| Ce qui se trouve dans le champ d'un singleton-bean | Est-ce un état ? | Généralement sûr ? | Pourquoi |
|---|---|---|---|
| private final OrderStore orderStore; | oui | oui | c'est une dépendance, identique pour tous les appels |
| private final Clock clock; | oui | oui | dépendance, comportement stable |
| private Map<String, String> templates; (rempli une fois et jamais modifié) | oui | généralement oui | état « effectivement immuable » après initialisation |
| private String currentOrderId; | oui | non | c'est l'état d'une seule opération ; il va « fuiter » entre les appels |
| private List<String> buffer = new ArrayList<>(); comme tampon temporaire | oui | presque toujours non | tampon mutable partagé pour tous les appels |
| private int processedCount; | oui | dépend | un compteur peut avoir du sens, mais nécessite discipline et compréhension des conséquences |
Idée clé : le singleton en lui-même n'est pas un méchant. Le méchant, ce sont les « données temporaires dans les champs » qui transforment le singleton en un « détenteur d'état » partagé.
3. Comment naît l'état mutable partagé
Le plus perfide dans l'état mutable partagé, c'est qu'il apparaît d'une manière très humaine. Non pas parce que vous êtes un mauvais développeur, mais parce que vous êtes fatigué, pressé et que vous vous êtes dit « pourquoi vais-je trimballer orderId à travers dix méthodes, je le mets dans un champ et les méthodes privées le verront d'elles-mêmes ». Cela semble soigné… jusqu'à ce que le service reste seul et vive dans votre tête. Mais Spring le rend impitoyablement partagé.
Regardons un exemple très typique dans le style ContextFlow. Supposons que nous ayons un service d'annulation de commande. Vous voulez découper proprement la logique en petites méthodes privées. Et vous avez la flemme de passer orderId.
Mauvaise version : « currentOrderId » comme paramètre caché
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
// ❌ État partagé masqué : tous les appels de ce singleton-bean le voient
private String currentOrderId;
public void cancel(String orderId) {
// ❌ « Préparation » de l'état pour la durée de l'opération — et on l'a laissé dans le champ
this.currentOrderId = orderId;
// Ces méthodes dépendent désormais implicitement du fait que le champ est déjà défini
writeAudit();
notifyCustomer();
}
private void writeAudit() {
// Nous utilisons ici non pas un paramètre, mais l'état resté dans l'objet
System.out.println(currentOrderId + ": CANCELLED"); // ORD-1: CANCELLED
}
private void notifyCustomer() {
System.out.println(currentOrderId + ": notify customer"); // ORD-1: notify customer
}
}
À première vue, tout fonctionne. C'est même joli : méthodes privées sans paramètres, tout est « proprement ».
Imaginez maintenant que quelqu'un (ou vous, dans un mois) décide de réutiliser writeAudit autrement. Par exemple, l'appeler depuis un autre endroit, ajouter une branche de logique, faire une annulation répétée, ou simplement oublier que cancel doit impérativement être appelé en premier.
Le problème n'est pas que les méthodes privées soient mauvaises. Le problème est que le contrat du service est devenu implicite : le service dépend d'un état interne qui doit être « correctement préparé ».
Bonne version : paramètres explicites, service sans état
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
public void cancel(String orderId) {
// ✅ Les données de l'opération sont transmises explicitement : pas de « commande en cours » dans le service
writeAudit(orderId);
notifyCustomer(orderId);
}
private void writeAudit(String orderId) {
// ✅ La source de données est immédiatement visible — c'est un paramètre, pas un champ caché
System.out.println(orderId + ": CANCELLED"); // ORD-1: CANCELLED
}
private void notifyCustomer(String orderId) {
System.out.println(orderId + ": notify customer"); // ORD-1: notify customer
}
}
Oui, c'est devenu un peu plus long de quelques caractères. Mais en échange, le service est désormais :
- ne stocke pas la commande « en cours » dans un champ ;
- facile à lire (toutes les dépendances de données sont visibles dans les signatures des méthodes) ;
- beaucoup plus simple à tester unitairement (même sans Spring).
Et le plus important : il a cessé d'être un « petit état global ».
4. Danger en non-web et sans threads
À ce moment, une pensée surgit souvent : « Ok, dans le web ce serait effrayant, il y a beaucoup de requêtes. Mais nous avons une application console. Nous n'exécutons pas cela en parallèle. » Et là, on a envie de dire gentiment mais fermement : vous êtes trop optimiste.
Même dans une application non-web, le problème se manifeste pour deux raisons.
La première raison est très simple : vous pouvez exécuter plusieurs scénarios à la suite, et l'état « migre » d'une exécution à l'autre. Cela ne vient pas du parallélisme, mais du fait que le singleton vit longtemps et que vos opérations sont courtes.
La deuxième raison est un peu plus « du futur », mais réelle : dès que l'application fait autre chose qu'une seule commande, elle a des raisons d'exécuter de la logique dans différents threads (même si vous ne le planifiez pas maintenant). Vous n'avez pas besoin de connaître la concurrence pour comprendre le risque : si l'état est partagé, il peut être corrompu par un appel étranger.
Illustration sans threads : oubli de préparation
Prenons un exemple très quotidien. Supposons que vous ayez créé un service de notification qui stocke le « canal courant » dans un champ parce que « c'est plus pratique ».
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
// ❌ Le canal actuel est l'état d'une opération spécifique, pas une « propriété du service »
private String currentChannel;
public void useChannel(String channel) {
// ❌ Maintenant, l'exactitude de send() dépend du fait que useChannel() ait été appelé avant
this.currentChannel = channel;
}
public void send(String message) {
// Si currentChannel n'est pas défini — vous obtiendrez null dans la sortie (ou NPE dans le code réel)
System.out.println(currentChannel + ": " + message);
}
}
Maintenant, le code ci-dessous compile et « fonctionne » parfois :
NotificationDispatchService s = ctx.getBean(NotificationDispatchService.class);
s.useChannel("EMAIL");
s.send("Order created"); // EMAIL: Order created
s.send("Order cancelled"); // EMAIL: Order cancelled (vous auriez pu attendre SMS)
Le problème est que send a désormais une dépendance cachée : useChannel doit être appelé avant lui. C'est presque comme une variable globale, mais soigneusement emballée dans un @Service.
Illustration du « mélange » des appels
Même si vous ne lancez pas de threads pour l'instant, il est utile de voir le principe :
sequenceDiagram
participant A as Opération A
participant S as Service singleton
participant B as Opération B
A->>S: setCurrentOrder ORD-1
B->>S: setCurrentOrder ORD-2
A->>S: writeAudit attendait ORD-1
Note right of S: Imprime ORD-2 oops
Si votre service stocke la « commande en cours » dans un champ, tout autre appel peut « basculer » ce champ. Et peu importe pourquoi cet autre appel a eu lieu : parce que vous avez lancé un second scénario, parce que vous avez ajouté un autre exécuteur, parce que vous avez « réutilisé le service » ailleurs. Le résultat est le même : le comportement dépend de l'ordre des appels, et c'est presque toujours une mauvaise nouvelle.
5. final et service sans état
Il y a un autre piège classique pour les débutants : « J'ai tout mis en final, donc j'ai de l'immuabilité, donc pas d'état ». Hélas, final fige la référence, pas le contenu interne de l'objet.
Cela revient particulièrement souvent avec les collections. Par exemple, vous avez voulu optimiser la génération d'un rapport et décidé de conserver un tampon ArrayList dans un champ : « pour ne pas créer une nouvelle liste à chaque appel ».
Exemple : référence final vers une collection mutable
package com.example.contextflow.application.reporting;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
// ❌ final « cloue » la référence, mais la liste elle-même est toujours mutable
private final List<String> buffer = new ArrayList<>();
public List<String> buildLines(List<String> input) {
// ❌ Nous mutons le buffer commun — il est commun à tous les appels de ce singleton
buffer.clear();
buffer.addAll(input);
// ❌ Nous renvoyons à l'extérieur une référence « vivante » aux internes du service
return buffer;
}
}
Du point de vue du compilateur Java, tout va bien. Du point de vue de la vie réelle, le service est devenu un conteneur de données partagé.
Le moment le plus désagréable : vous pouvez obtenir un bug même sans threads. Simplement parce que vous avez renvoyé à l'extérieur une référence vers la liste interne, et quelqu'un l'a modifiée :
List<String> lines = reportingService.buildLines(List.of("A", "B"));
lines.add("HACK"); // vous venez de modifier le tampon interne du service 😬
System.out.println(reportingService.buildLines(List.of("X")));
// peut afficher [X], ou [X, HACK] selon votre logique
Il y a deux bonnes solutions, toutes deux simples.
Si le tampon n'est pas nécessaire du tout, créez la liste à l'intérieur de la méthode et renvoyez une copie. Pour un projet d'apprentissage, c'est tout à fait normal.
public List<String> buildLines(List<String> input) {
// ✅ Variable locale : ne vit que dans le cadre de l'appel
List<String> lines = new ArrayList<>(input);
// ✅ Nous renvoyons à l'extérieur une vue/copie immuable
return List.copyOf(lines);
}
Si vous gardez délibérément un cache ou un tampon (cas rare à notre niveau), il doit être isolé et vous ne devez pas en donner la référence « vivante » à l'extérieur. Même alors, vous devrez réfléchir à qui et quand peut appeler la méthode, mais c'est déjà un niveau « vie d'adulte ». Pour l'instant, nous restons sur le principe : dans les services, le minimum d'état.
6. « État utile » : quand un singleton peut stocker des données
Pour ne pas tomber dans l'excès inverse (« aucun champ, rien que le vide et l'illumination »), il faut dire honnêtement : parfois, stocker un état dans un singleton est normal. Par exemple, si vous avez chargé un référentiel, des modèles, une configuration ou des valeurs pré-calculées une fois au démarrage, et que vous ne faites plus que les lire.
Dans le cours précédent (sur le cycle de vie), nous avons déjà vu des beans qui ont besoin d'effectuer une préparation à l'initialisation. Un exemple typique est le catalogue de modèles de notifications : il peut charger les modèles en mémoire au démarrage pour ne pas lire le fichier à chaque appel.
Il est important que cet état soit :
- soit immuable (Map.of(...), List.of(...)),
- soit « effectivement immuable » (construit une fois, jamais modifié ensuite),
- soit bien contrôlé et ne dépendant pas de « l'opération en cours ».
Mini-exemple d'état « sûr » : cache de référentiel
package com.example.contextflow.infrastructure.resources;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class NotificationTemplateCatalog {
// ✅ Le référentiel est créé une fois et ne change pas — c'est normal pour un singleton
private final Map<String, String> templates = Map.of(
"order-created", "Order {0} created",
"order-cancelled", "Order {0} cancelled"
);
public String template(String key) {
// Dans le code réel, on pourrait ajouter un traitement pour une clé absente
return templates.get(key);
}
}
Ici, il y a un état : templates. Mais il n'est ni « courant », ni « dernier », ni « pour la durée de l'opération ». C'est simplement un référentiel, identique pour tous les appels. Un tel singleton est généralement sûr dans son principe (nous ne discutons pas ici des subtilités comme le rechargement à chaud des modèles – c'est une autre histoire).
Une bonne règle pour ne pas se tromper : si le champ répond à la question « qu'ai-je fait lors du dernier appel ? » ou « que suis-je en train de faire dans l'appel en cours ? », alors c'est presque à coup sûr un mauvais champ pour un service singleton. Si le champ répond à la question « quelle est l'information générale immuable de l'application ? », alors c'est peut-être normal.
7. Refactoring pratique
Le plus agréable avec le sujet des services sans état, c'est qu'il améliore presque toujours le code immédiatement. Pas « un jour peut-être », mais dès aujourd'hui : moins de dépendances cachées, plus facile à tester, plus facile à lire, moins de surprises.
Techniquement, le refactoring se résume généralement à l'un des deux scénarios.
Premier scénario : vous supprimez le « champ courant » et commencez à passer les données explicitement via les paramètres des méthodes. C'est exactement ce que nous avons fait avec OrderCancellationService : currentOrderId a disparu, les paramètres sont apparus.
Deuxième scénario : vous comprenez que vous avez des données temporaires d'une seule opération, et qu'elles ont besoin d'un « conteneur » séparé. Dans les cas simples, ce sont simplement des variables locales. Dans les cas plus complexes, un objet séparé qui vit le temps d'une opération (et c'est justement le pont vers la prochaine leçon, où apparaîtra OrderProcessingSession).
Exemple : « découpage en méthodes privées » sans champ caché
Voici à quoi ressemble souvent « l'état dans un champ » dans le code réel : il n'apparaît pas par avidité, mais par désir de rendre le code plus beau.
Mauvais style (mais tentant) :
@Service
public class OrderPlacementService {
// ❌ « Commande en cours » conservée dans l'objet et circule entre les appels
private String currentOrderId;
public void placeOrder(String orderId) {
// ❌ Préparation de l'état au lieu d'un passage explicite de paramètres
this.currentOrderId = orderId;
// Ces méthodes liront implicitement currentOrderId
validate();
save();
System.out.println("placed " + currentOrderId); // placed ORD-1
}
private void validate() { /* uses currentOrderId */ }
private void save() { /* uses currentOrderId */ }
}
Bon style – le même code « joli », mais avec des paramètres explicites :
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
public void placeOrder(String orderId) {
// ✅ Les données de l'opération sont visibles directement dans les signatures des méthodes
validate(orderId);
save(orderId);
System.out.println("placed " + orderId); // placed ORD-1
}
private void validate(String orderId) { /* use orderId */ }
private void save(String orderId) { /* use orderId */ }
}
Oui, vous avez passé orderId deux fois. Mais vous avez acheté, avec ces deux paramètres, une chose importante : la prévisibilité. Désormais, chaque méthode est autonome : pour comprendre d'où viennent les données, il suffit de regarder les paramètres, pas de lire toute la classe comme un roman.
Exemple « tampon temporaire » → « variable locale »
Si vous avez essayé de « réutiliser une collection » quelque part, on peut presque toujours faire plus simple.
Avant (tampon mutable partagé) :
import java.util.ArrayList;
import java.util.List;
public List<String> format(List<String> input) {
// ❌ buffer — commun à tous les appels ; nous le mutons pendant l'opération
buffer.clear();
buffer.addAll(input);
// ✅ même si nous renvoyons une copie ici, la simple présence d'un buffer commun est une source de risques
return List.copyOf(buffer);
}
Après (tampon local) :
import java.util.ArrayList;
import java.util.List;
public List<String> format(List<String> input) {
// ✅ La collection locale « meurt » avec l'appel de la méthode
List<String> lines = new ArrayList<>(input);
// ✅ Nous renvoyons à l'extérieur un résultat immuable
return List.copyOf(lines);
}
Dans notre projet pédagogique, le gain est plus important que « l'optimisation » : nous produisons un code facile à comprendre.
Et voici un bon « test d'honnêteté » : si vous pouvez réécrire une méthode pour qu'elle fonctionne uniquement avec des paramètres et des variables locales, c'est presque toujours un signe de bonne conception de la couche service.
8. Erreurs typiques avec les singletons
Erreur n°1 : stocker un identifiant, un canal, une commande ou une tâche « courante » dans un champ d'un service singleton.
Ce code semble soigné car il évite les « paramètres superflus ». Mais en réalité, vous transformez le service en variable globale cachée : l'exactitude du fonctionnement dépend de l'ordre des appels et de qui d'autre utilise ce même singleton. Il vaut mieux passer les données via des paramètres ou conserver les valeurs temporaires dans des variables locales.
Erreur n°2 : croire que final signifie automatiquement « pas d'état ».
final protège la référence contre la réaffectation, mais ne rend pas l'objet immuable. private final List<String> buffer est toujours une liste mutable, avec seulement une référence « clouée ». Si vous mute la collection, vous créez quand même un état mutable partagé, surtout si vous renvoyez cette collection à l'extérieur.
Erreur n°3 : utiliser des collections dans les champs d'un singleton comme tampons de travail « pour la durée d'un appel ».
C'est une micro-optimisation fréquente qui n'est pas nécessaire dans les projets pédagogiques (ni dans la plupart des projets courants). En échange, vous obtenez des bugs étranges : « pourquoi des lignes de l'exécution précédente se retrouvent-elles dans le rapport ? », « pourquoi quelqu'un a-t-il ajouté un élément à ma liste ? ». Il est bien plus sûr et compréhensible de créer des collections temporaires à l'intérieur de la méthode.
Erreur n°4 : rendre le contrat d'une méthode implicite via des appels « préparatoires ».
Les services du type useChannel(...) → send(...), begin(orderId) → write(action) reposent sur un état caché : si le développeur oublie d'appeler la « préparation », le service se comporte bizarrement. Ce code semble souvent « orienté objet », mais en pratique ce n'est qu'une interface vers un état global. Dans la couche service, il est généralement préférable de rendre les méthodes autonomes.
Erreur n°5 : mélanger deux types d'« état » — les référentiels de l'application et les données d'une opération spécifique.
Un catalogue de modèles ou un référentiel préchargé peut vivre dans un singleton et être normal. Mais la « commande en cours » — non. Si vous vous surprenez à avoir un champ qui change à chaque appel d'une opération métier, c'est presque toujours un signal : vous avez caché des données temporaires là où elles ne devraient pas vivre.
GO TO FULL VERSION