Skip to content

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

  1. Add the key in French in messages_fr.properties
  2. Add the same key in English in messages_en.properties
  3. Rebuild the backend (mvn clean package)
  4. 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

  1. Copy messages_fr.properties to messages_<lang>.properties
  2. Translate all the values (leave keys as they are)
  3. Deploy with PERFSHOP_LANG=<lang>

No code change required. I18nService automatically loads the new file via its naming convention.

See also