Backend internationalization (Spring Boot)¶
The Spring Boot backend uses standard Java Properties files for its translations. This approach is native to Spring and benefits from mature IDE tooling (syntax highlighting, validation, go-to-definition). Integration with the rest of the system goes through a dedicated I18nService service that encapsulates key loading and resolution.
Sources
backend/src/main/resources/messages_fr.properties, messages_en.properties, backend/src/main/java/com/perfshop/service/I18nService.java
Files¶
Two files at the root of src/main/resources/:
backend/src/main/resources/
├── messages_fr.properties (~35 KB, ~430 keys)
├── messages_en.properties (~34 KB, ~430 keys)
├── application.yml
├── db/migration-fr/
└── i18n/
├── enigmes/
└── logique/
Each file follows the classic Java Properties format:
# API error messages
api.error.product_not_found=Product not found
api.error.cart_empty=Your cart is empty
api.error.payment_invalid=Payment information is invalid
# Business chaos
chaos.business.a1.description=VAT 19.6% instead of 20%
chaos.business.a1.log_detail=Applied a 19.6% VAT on order {0}
# Licenses
license.error.no_license=No active license on this PerfShop instance
license.error.feature_denied=License required to access {0}
license.status.none_label=No license
Values can contain numbered placeholders in the format {0}, {1}, etc. These are the argument positions of Java's standard MessageFormat.
Key naming conventions¶
Keys follow a dot-separated hierarchy, in English, and use a consistent scheme:
| Prefix | Usage |
|---|---|
api.error.* |
REST endpoint error messages |
api.success.* |
Success messages |
chaos.business.aN.* |
Descriptions and logs of business anomalies A1 to A16 |
chaos.security.sN.* |
Descriptions and logs of OWASP flaws S1 to S12 |
chaos.functional.fN.* |
Descriptions of F1 to F4 exceptions |
chaos.scripting.* |
Chaos Scripting tokens |
chaos.infra.* |
Infrastructure chaos (CPU, memory, GC…) |
chaos.level* |
Level labels (Junior, Confirmed, Expert, Master) |
scenario.n1-01.* |
Weather scenarios (name, description) |
license.error.* |
License validation errors |
license.status.* |
License status labels |
admin.service.* |
AdminUserService messages (validation, exceptions) |
order.status.* |
Order status labels |
logique.* |
Catalog of the 25 pedagogical logic questions |
This normalization makes searching easier: grep -r "chaos.business" messages_fr.properties immediately finds all keys related to Business Chaos.
I18nService service¶
I18nService is a Spring bean that loads the dictionaries at startup and exposes resolution methods.
Loading¶
At startup, I18nService reads the PERFSHOP_LANG variable (via @Value or application.yml), determines the name of the matching messages_<lang>.properties file and loads it into memory. French is loaded first as the fallback dictionary:
@Service
public class I18nService {
private final Properties current; // active language
private final Properties fallback; // always fr
public I18nService(@Value("${perfshop.lang:fr}") String lang) {
this.fallback = loadProperties("messages_fr.properties");
this.current = lang.equals("fr")
? this.fallback
: loadProperties("messages_" + lang + ".properties");
}
// ...
}
If the requested language has no matching file, I18nService logs a warning and uses the French dictionary alone.
Exposed methods¶
| Method | Role |
|---|---|
t(String key) |
Translates a key with no argument, returns the key itself if absent |
t(String key, Object... args) |
Translates with MessageFormat substitution of {0}, {1}… |
Example usage from a controller or service:
return ResponseEntity.badRequest()
.body(Map.of("error", i18n.t("api.error.cart_empty")));
return ResponseEntity.status(422)
.body(Map.of("error", i18n.t("license.error.expired", info.expiresAt)));
throw new IllegalArgumentException(
i18n.t("admin.service.email_exists", email));
The result is a string already translated into the active language, ready to be returned to the client.
Lifecycle¶
sequenceDiagram
autonumber
participant Env as .env
participant Spring as Spring Boot
participant I18n as I18nService
participant Fs as Filesystem
Env->>Spring: PERFSHOP_LANG=fr
Spring->>I18n: @PostConstruct init
I18n->>Fs: load messages_fr.properties (fallback)
Fs-->>I18n: Properties fr (430 keys)
I18n->>Fs: load messages_fr.properties (current)
Note over I18n: Same file<br/>fallback = current
I18n-->>Spring: I18nService ready
Note over Spring: Runtime calls
Spring->>I18n: t("api.error.cart_empty")
I18n-->>Spring: "Your cart is empty"
In English mode:
sequenceDiagram
autonumber
participant Env as .env
participant Spring as Spring Boot
participant I18n as I18nService
participant Fs as Filesystem
Env->>Spring: PERFSHOP_LANG=en
Spring->>I18n: @PostConstruct init
I18n->>Fs: load messages_fr.properties (fallback)
Fs-->>I18n: Properties fr (430 keys)
I18n->>Fs: load messages_en.properties (current)
Fs-->>I18n: Properties en (430 keys)
I18n-->>Spring: I18nService ready
Spring->>I18n: t("api.error.cart_empty")
I18n-->>Spring: "Your cart is empty"
Spring->>I18n: t("new.key.not.yet.translated")
Note over I18n: Key missing from EN
I18n-->>Spring: (fallback FR) "Nouvelle clé..."
Integration with business exceptions¶
Several custom exceptions (CartItemNotFoundException, CartAccessDeniedException, etc.) receive an I18nService and build their message at the time of being thrown:
public class CartItemNotFoundException extends RuntimeException {
public CartItemNotFoundException(I18nService i18n, Long itemId) {
super(i18n.t("api.error.cart_item_not_found", itemId));
}
}
This approach guarantees that an exception message is always in the active language at the time it is thrown. The exception can then propagate without transformation up to the global @ExceptionHandler that sends it back to the client.
FR/EN symmetry¶
The two files must contain exactly the same keys. At the time of this writing, the symmetry is 430 keys in FR / 430 keys in EN.
To check the symmetry, a shell one-liner is enough:
diff <(grep -Eo '^[^#][^=]+' messages_fr.properties | sort) \
<(grep -Eo '^[^#][^=]+' messages_en.properties | sort)
This diff must be empty. Any output signals a drift to correct.
Using placeholders¶
Placeholders follow the standard MessageFormat syntax:
api.error.product_out_of_stock=Product {0} is no longer in stock
license.error.expired=License expired on {0}
admin.service.account_not_found=Admin account not found for id {0}
One thing to watch: MessageFormat treats {...} specially if the number is followed by a type ({0,number}, {1,date}). For a simple text replacement, leaving {0} as is is enough.
French apostrophes in values are a classic source of MessageFormat bugs — they are interpreted as quote delimiters and must be doubled ('') in the properties value if the string also contains placeholders. Without placeholders, no problem.
English key names, values in the target language¶
Convention: keys are always in English, regardless of the file. Only the values are translated:
# messages_fr.properties
api.error.cart_empty=Votre panier est vide
# messages_en.properties
api.error.cart_empty=Your cart is empty
# messages_es.properties (future)
api.error.cart_empty=Tu carrito está vacío
This convention eases maintenance and allows a non-French-speaking developer to understand the file structure without reading the values.
Adding a key¶
- Add the key in French in
messages_fr.properties - Add the same key in English in
messages_en.properties - Rebuild the backend (
mvn clean package) - Use the key in Java code via
i18n.t("my.new.key")
No other file to touch. Spring Boot automatically reloads the properties on restart.
Adding a new language¶
- Copy
messages_fr.propertiestomessages_<lang>.properties - Translate all the values (leave keys as they are)
- Deploy with
PERFSHOP_LANG=<lang>
No code change required. I18nService automatically loads the new file via its naming convention.
See also¶
- i18n overview
- Frontend internationalization
- Enigma internationalization — for pedagogical content