1. Importance de la structure et de l’i18n
Lorsque les bundles se trouvent déjà dans src/main/resources, messageSource est connecté au conteneur, et la règle de sélection de la locale est séparée, le problème n’est plus getMessage(...). Le problème devient très concret : où tout cela doit-il vivre pour que le projet ne se transforme pas en une boîte avec des fils — les clés à un endroit, la locale à un autre, les confs à un troisième, et le message dans la mauvaise langue apparaît déjà à l’exécution.
C’est précisément à ce stade que commence la douleur familière : dans le projet, dix messages différents, trois services qui les demandent, et un collègue qui, par erreur, écrira la clé "order.createdd" (deux d) — et vous passerez une demi-heure à regarder NoSuchMessageException comme une énigme du Sphinx.
Le problème n’est pas que MessageSource soit complexe. Le problème est que les textes sont des données, et les données aiment se disperser. Si l’on ne se met pas d’accord sur l’endroit où vivent les bundles, où se trouvent les clés, qui a le droit d’appeler MessageSource et où s’arrête le modèle de domaine et commence le « texte pour l’utilisateur », vous obtiendrez un artefact typique de projet : « tout semble fonctionner, mais il fait peur d’y toucher ».
Notre objectif ici est de faire en sorte que, par la structure de ContextFlow, on puisse répondre à trois questions sans avoir à deviner : où changer le texte ; où ajouter une nouvelle clé ; où regarder si le message s’affiche dans la mauvaise langue.
Limites des couches : domain et i18n
Voici une idée importante qu’il est facile de laisser passer parce qu’elle semble « évidente ». Mais c’est elle qui, en règle générale, épargne de la douleur au projet. Le domaine (domain) concerne le sens : commande, client, statut, montant. La localisation concerne la représentation : comment nous en parlons à l’utilisateur, dans la console, dans une lettre, dans un rapport. Si vous mélangez ces deux choses, vous commencerez à faire passer Spring dans les classes les plus « pures » — et le domaine se transformera en « domaine plus un petit bout d’infrastructure ».
Dans ContextFlow, nous maintenons une discipline : domain ne doit pas dépendre de MessageSource, ne doit pas stocker de clés et ne doit pas décider lui-même dans quelle langue parler. Si une commande est créée, c’est un fait. Le texte « Commande créée » est déjà une tâche de la couche application, qui affiche et communique.
Il est pratique de garder en tête un tableau simple (non pas comme un « canon », mais comme une aide pour résoudre les cas litigieux) :
| Couche | Ce qui vit ici | Ce qui NE vit pas ici |
|---|---|---|
| domain | Order, Customer, OrderStatus, règles de calcul, invariants | MessageSource, clés "order.created", choix du Locale, lecture des .properties |
| application | services use-case, scénarios, génération de texte pour notifications/console/rapports | lecture bas niveau de fichiers, chemins absolus, travail avec Resource comme un fichier |
| infrastructure | implémentations concrètes : audit fichier, notifications console, chargement de templates | décisions métier « quand annuler une commande », invariants domaine |
| config | wiring : @Configuration, @Bean, @Import, @ComponentScan | logique métier (pas de « si la commande est chère — annuler ») |
Ce tableau est notre « poste frontière ». Et nous allons répartir les bundles/keys/config de manière à ce qu’ils tombent naturellement dans leurs couches.
2. Message bundles dans src/main/resources
Quand vous débutez, vous avez envie de mettre les fichiers « à côté de la classe », comme si c’étaient ses ressources personnelles. Parfois cela semble même soigné… jusqu’à la première compilation de jar, où vous réalisez soudainement que les ressources font partie du classpath, et non du système de fichiers « comme dans l’explorateur ». C’est pourquoi nous choisissons une règle ennuyeuse mais fiable : tous les message bundles se trouvent dans src/main/resources.
Pour ContextFlow, la structure des ressources devient progressivement celle-ci : templates/ reste pour les grands templates textuels, les properties de l’application et les bundles de messages sont placés à côté.
src/main/resources/
├── contextflow.properties
├── contextflow-demo.properties
├── contextflow-test.properties
├── messages.properties
├── messages_fr.properties
├── messages_en.properties
└── templates/
├── notifications/
│ ├── order-created.txt
│ └── order-cancelled.txt
└── reports/
└── daily-report-header.txt
Notez l’important « point d’ancrage mental » : les bundles ne sont pas des « fichiers quelconques sur le disque », ce sont des ressources de l’application, qui se retrouveront dans le classpath. Quand Gradle assemble le projet, ces fichiers aboutissent dans build/resources/main, puis sont emballés dans le jar. C’est pourquoi ResourceBundleMessageSource les recherche par basename dans le classpath, et non par chemin absolu.
Pour la base messages, trois fichiers suffisent. Exemple de contenu (très court pour que l’idée soit visible) :
# src/main/resources/messages.properties
# Bundle de base (par défaut), souvent il est pratique de le mettre en anglais
order.created=Order {0} created
order.cancelled=Order {0} cancelled
# src/main/resources/messages_fr.properties
# Fichier de localisation française
order.created=Commande {0} créée
order.cancelled=Commande {0} annulée
# src/main/resources/messages_en.properties
# Localisation anglaise explicite (peut coïncider avec la base)
order.created=Order {0} created
order.cancelled=Order {0} cancelled
Oui, le fichier anglais peut coïncider avec la base. Dans un projet pédagogique, c’est normal : la couche de base est souvent écrite en anglais, et les localisations sont ajoutées ponctuellement. L’important est de comprendre où se trouve la source de vérité pour le texte et pourquoi elle ne doit pas être cachée à l’intérieur d’une classe Java.
3. Clés de messages et constantes
Nous avons déjà introduit les clés elles-mêmes. L’important est autre chose : elles n’ont pas leur place sous forme de chaînes éparpillées dans tout le projet. Au bout d’une semaine, vous attrapez un bug parce qu’à un endroit c’est "order.created", et ailleurs "order.create" (pas le bon mot), et tout cela compile très bien, car une chaîne est une chaîne, le compilateur n’est pas juge.
C’est pourquoi nous introduisons une règle simple, qui est rentabilisée presque immédiatement : les clés vivent en un seul endroit sous forme de constantes. Ce n’est pas de « la beauté pour la beauté », c’est le moyen minimal d’obtenir l’autocomplétion dans l’IDE et de réduire le nombre de fautes de frappe.
Dans ContextFlow, il est pratique de créer une classe de constantes, par exemple :
package com.example.contextflow.application.i18n;
/**
* Clés de messages pour i18n.
* Stockées de manière centralisée pour que l’IDE les suggère et pour réduire les fautes de frappe.
*/
public final class MessageKeys {
// Message pour la création de commande
public static final String ORDER_CREATED = "order.created";
// Message pour l’annulation de commande
public static final String ORDER_CANCELLED = "order.cancelled";
private MessageKeys() {
// Classe utilitaire : nous ne créons pas d’instances
}
}
Pourquoi le package application.i18n et non domain ? Parce que les clés sont nécessaires là où nous formons les textes pour l’utilisateur. Le domaine n’a pas besoin de clés, il vit de faits, pas de phrases.
Attention toutefois à ne pas tomber dans l’autre extrême : ne pas transformer MessageKeys en « décharge de tout et n’importe quoi ». S’il y a beaucoup de clés, mieux vaut faire plusieurs petites classes (par exemple OrderMessageKeys, ReportMessageKeys) dans le même package. Tant qu’il y a peu de clés, quelques constantes compréhensibles suffisent, pas tout un mini-framework.
Autre nuance pratique : essayez de garder les clés courtes et porteuses de sens. "order.created" est une bonne clé. "order.created.message.for.console.output.v2" est une clé qui, visiblement, a vécu une douleur et tente de la raconter. Mais une clé ne doit pas être un journal intime.
4. Utilisation de MessageSource dans application
Une fois que les bundles et les clés sont en place, la question suivante n’est pas de savoir comment appeler encore une fois getMessage(...), mais qui doit s’en occuper. Si vous laissez MessageSource se promener dans tout le projet, il se retrouvera rapidement dans NotificationSender, puis dans AuditService, et, à force, dans Order.
Dans ContextFlow, il est pratique d’avoir un service étroit qui ne s’occupe que de l’obtention du texte : un petit OrderTextService. Il vit dans application parce qu’il fait partie de l’orchestration et de la représentation, et non du domaine.
package com.example.contextflow.application.i18n;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
/**
* Service responsable de l’obtention de textes localisés liés à la commande.
* Important : les objets du domaine ne tirent pas Spring ici, au contraire — la couche application construit elle-même la représentation.
*/
@Service
public class OrderTextService {
// Spring MessageSource — une dépendance infrastructurelle, mais vit dans la couche application comme outil de représentation
private final MessageSource messages;
public OrderTextService(MessageSource messages) {
this.messages = messages;
}
}
Et voici une méthode — séparément, pour ne pas transformer l’exemple en « drap » :
import java.util.Locale;
public String created(String orderId, Locale locale) {
// On prend le texte par la clé + on insère les paramètres (orderId)
return messages.getMessage(
MessageKeys.ORDER_CREATED,
new Object[]{orderId}, // paramètres pour {0}, {1}, ...
"Commande créée", // fallback si la clé n’est pas trouvée
locale // locale choisie
);
}
Ici, une seule chose est importante : OrderTextService est un bean d’application ordinaire. Il est pris en charge par CoreConfig via le scan, et il ne dépend que de MessageSource, pas de ApplicationContext.
Même histoire avec la locale : la règle de sélection ne doit pas flotter entre les services, donc il est pratique de la sortir dans un LocaleSelector. Du point de vue de la structure du projet, cela fait aussi partie de la couche application : la règle de sélection de la locale concerne le comportement du scénario, pas le domaine ni la configuration.
Version minimale :
package com.example.contextflow.application.i18n;
import java.util.Locale;
/**
* Encapsule la règle de sélection de la locale : on prend la préférée,
* et si elle n’existe pas, on utilise la valeur par défaut.
*/
public class LocaleSelector {
private final Locale defaultLocale;
public LocaleSelector(Locale defaultLocale) {
this.defaultLocale = defaultLocale;
}
public Locale choose(Locale preferredLocale) {
// Règle simple : si l’utilisateur n’a rien choisi, on ne plante pas, on utilise la locale par défaut
return preferredLocale != null ? preferredLocale : defaultLocale;
}
}
LocalizationConfig crée un bean pour cette classe et fournit à la couche application une règle de sélection de locale prête. Ainsi, le code du scénario ne se souvient pas à chaque fois de ce qu’il faut faire avec null, mais utilise simplement la même convention.
Discipline importante : le modèle de domaine (Customer) peut stocker preferredLocale comme donnée, mais la décision « quelle locale utiliser réellement », nous la prenons dans la couche application. Cela semble un peu ennuyeux, mais vous ne chercherez pas plus tard dans tout le projet 17 endroits où chacun décide « à sa manière » quoi faire avec une locale null.
5. Classes de configuration et modules
Après avoir ajouté les properties, resources, les règles de locale et les messages, AppConfig commence très vite à ressembler à un tiroir de cuisine où vous fourrez tout : cuillères, piles, et pour une raison quelconque un boulon de 13. Oui, vous pouvez vivre comme ça. Mais ensuite quelqu’un ouvrira ce tiroir et aura peur de vous en tant que personne.
C’est pourquoi nous créons pour ContextFlow une couche de configuration séparée : com.example.contextflow.config, à l’intérieur de laquelle la configuration est répartie en modules. Cela s’accorde avec la structure générale cible du projet, où config.* est le lieu où l’application est assemblée comme un jeu de construction, et non l’endroit où vit la logique métier.
Par exemple, il nous semble logique d’avoir aujourd’hui les modules suivants :
• config.core — @ComponentScan de base pour application et infrastructure ;
• config.localization — MessageConfig et LocalizationConfig, c’est-à-dire le câblage pour messageSource, la Locale par défaut et LocaleSelector ;
• config.AppConfig — la classe d’assemblage supérieure qui importe tout.
Notez que nous ne sommes pas obligés de diviser à l’infini. Il faut diviser quand cela améliore la lisibilité, pas quand nous avons une démangeaison de « tout diviser en 100 fichiers ».
6. Assemblage via @Import
Quand les rôles des modules sont séparés, le config racine se lit d’un seul coup d’œil : le scan vit dans un endroit, les messages et la règle de locale dans un autre, l’assemblage dans un troisième.
Exemple de CoreConfig — uniquement le scanning :
package com.example.contextflow.config.core;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@ComponentScan(basePackages = {
"com.example.contextflow.application",
"com.example.contextflow.infrastructure"
})
public class CoreConfig { }
application.i18n fait déjà partie de application, donc OrderTextService est pris en charge par le scan. Et le package config n’y est pas inclus : nous le connectons explicitement.
Exemple de MessageConfig — uniquement MessageSource :
package com.example.contextflow.config.localization;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
@Configuration(proxyBeanMethods = false)
public class MessageConfig {
@Bean
MessageSource messageSource() {
// ResourceBundleMessageSource recherche les fichiers par basename dans le classpath :
// messages.properties, messages_fr.properties, messages_en.properties, etc.
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
source.setBasename("messages");
source.setDefaultEncoding("UTF-8"); // Important pour que les caractères russes soient lus correctement
return source;
}
}
Le module LocalizationConfig reste à côté : il contient le câblage pour defaultLocale et LocaleSelector, pour que la règle de sélection de la locale ne flotte pas entre les services.
Exemple de l’AppConfig supérieur, qui assemble tout :
package com.example.contextflow.config;
import com.example.contextflow.config.core.CoreConfig;
import com.example.contextflow.config.localization.LocalizationConfig;
import com.example.contextflow.config.localization.MessageConfig;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false)
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // Nous énumérons explicitement les modules qui composent l’application
public class AppConfig { }
Ainsi, AppConfig redevient une courte carte d’assemblage, et non un endroit où nous avons mis « au cas où » tout ce qui nous passait par la tête.
7. De la locale au texte prêt
Quand tous les morceaux sont en place, il est utile de voir non pas l’arborescence des fichiers, mais le flux d’exécution lui-même.
- Le scénario ou le service d’application apporte le preferredLocale du client. S’il n’y en a pas, la valeur de contextflow.locale.default entre en jeu.
- LocaleSelector choisit la Locale finale, afin que la règle de fallback soit unique pour toute l’application.
- OrderPlacementService ou un autre bean d’application appelle OrderTextService. OrderTextService est déjà monté par le conteneur via CoreConfig, car c’est un @Service classique dans application.i18n.
- OrderTextService demande à messageSource le message pour MessageKeys.ORDER_CREATED et transmet des arguments comme orderId.
- Le bean messageSource cherche la valeur appropriée dans messages_<locale>.properties, si nécessaire il se rabat sur messages.properties, et la chaîne prête part ensuite — vers la console, l’audit ou un autre adaptateur de sortie.
Voilà pourquoi toute cette répartition en paquets est nécessaire : chaque pièce a un seul rôle, et la chaîne se lit de haut en bas sans avoir à deviner qui a pris la locale où et pourquoi le texte est soudainement dans une autre langue.
8. Structure finale de ContextFlow
Si l’on regarde spécifiquement l’i18n et le fragment de configuration modulaire de ContextFlow, il faut chercher ici :
src/main/java/com/example/contextflow/
├── domain/
│ └── model/
│ ├── Order.java
│ └── Customer.java
├── application/
│ ├── service/
│ │ └── OrderPlacementService.java
│ └── i18n/
│ ├── MessageKeys.java
│ ├── OrderTextService.java
│ └── LocaleSelector.java
├── infrastructure/
│ ├── notification/
│ │ └── ConsoleNotificationSender.java
│ ├── audit/
│ │ └── ConsoleAuditWriter.java
│ └── resources/
│ └── NotificationTemplateCatalog.java
└── config/
├── AppConfig.java
├── core/
│ └── CoreConfig.java
└── localization/
├── MessageConfig.java
└── LocalizationConfig.java
Et la partie ressources qui participe spécifiquement à la localisation et au choix de la locale par défaut :
src/main/resources/
├── contextflow.properties
├── messages.properties
├── messages_fr.properties
└── messages_en.properties
Pour que le tableau soit encore plus « dans la tête », voici un mini-schéma d’interaction :
flowchart LR A[application.service
OrderPlacementService] --> L[application.i18n
LocaleSelector] A --> B[application.i18n
OrderTextService] L --> B B --> C["MessageSource
(bean name: messageSource)"] C --> D[resources
messages*.properties]
Ici, on voit une séparation simple : OrderTextService et LocaleSelector vivent dans la couche application, messageSource et le câblage de la locale dans les modules de config, les bundles dans resources, et les grands templates de la leçon précédente restent dans templates/ et ne migrent pas dans messages*.properties.
9. Erreurs typiques lors du travail avec i18n
Erreur n°1 : les clés se dispersent dans le projet comme des « chaînes magiques ».
Les premières fois, écrire "order.created" directement dans getMessage(...) semble normal. Ensuite apparaît "order.create" et "order.created " (avec un espace), et soudain vous ne faites plus du développement, mais de l’archéologie. On guérit cela de manière ennuyeuse : une classe de constantes MessageKeys dans application et la discipline « nous n’utilisons que celle-ci ».
Erreur n°2 : MessageSource est traîné dans le modèle du domaine.
Parfois, on veut créer sur Order une méthode toLocalizedString(MessageSource, Locale). Cela semble pratique, mais c’est le chemin rapide pour que le domaine devienne dépendant de Spring, et que le test du domaine devienne un chargement de contexte. Le domaine doit stocker des faits, pas des façons de « les exprimer joliment ».
Erreur n°3 : les bundles sont placés « n’importe où », et le basename ne correspond pas au chemin réel.
Si vous mettez le fichier dans src/main/resources/i18n/messages.properties, mais que setBasename("messages"), alors Spring ne trouvera honnêtement rien. Dans le projet d’apprentissage, nous gardons les bundles à la racine des ressources pour que le basename soit le plus simple possible : "messages". Quand il faudra complexifier — nous complexifierons de manière consciente, et non par hasard.
Erreur n°4 : un AppConfig géant ou un root-scan sur tout com.example.contextflow.
Tant que le projet est petit, c’est tolérable. Quand on ajoute des resources, des règles de locale et MessageSource, un tel scan commence à tirer accidentellement des classes de config, et AppConfig mélange ce qui ne doit pas l’être. En conséquence, le câblage devient opaque, et vous ne comprenez plus ce qui est venu via @Import et ce qui est venu via le scan. On guérit cela avec un scan ciblé pour application / infrastructure et un @Import explicite pour les modules de config.
Erreur n°5 : les clés sont faites « avec du texte à l’intérieur », par exemple order created successfully.
La clé n’est pas une phrase, la clé est un code de sens. Elle doit être stable, courte et ne pas dépendre de la langue. Si la clé contient déjà du texte anglais, vous ne localisez pas le système, vous jouez à « remplace par un autre texte anglais ». Mieux vaut order.created, et la phrase réelle dans les bundles.
GO TO FULL VERSION