switch on a language code, or a second copy of every controller. Spring already has the pieces to do it properly: a component that decides which language the current request wants, and a component that looks up a sentence by key in that language. What people trip over is not those two ideas. It is the details around them: a translation file that prints as garbage, an apostrophe that vanishes, Marathi digits where you expected Latin ones, a server whose own language setting quietly changes what your users see, and error responses that are half translated.
This article builds a small shop API that answers in English, Marathi and Hindi, and then goes through each of those details in the order you would meet them. Every code block is a file in a small project that was compiled and run, and every console block is quoted from a transcript that a test or a script wrote, so the claims below are ones the build would notice if they stopped being true. The project is the i18n module of a companion repository. There is no separate documentation folder: the deeper material sits in the collapsible “going deeper” sections beside the paragraph each one extends.
Versions. Spring Boot 4.1.1 and Spring Framework 7.0.9, on Java 25 (LTS, Temurin 25.0.4.1) and Maven 3.9. The application needs onlyspring-boot-starter-webmvcandspring-boot-starter-validation. The tests start it on a real port and talk to it over HTTP, so headers such asAccept-Languageare parsed by the embedded Tomcat, not simulated. To keep the transcripts the same on every machine the tests run with-Duser.language=en -Duser.country=US -Dfile.encoding=UTF-8.
A request arrives in one language and the answer has to leave in another
Internationalization in Spring is two small jobs done one after the other. First, a LocaleResolver looks at the request and decides whichLocale it wants: mr for Marathi, hi for Hindi, en for English. Second, a MessageSource takes a message key such as greeting and that locale and returns the sentence. The sentences live in ordinary text files, one per language, named messages.properties, messages_mr.properties and messages_hi.properties.
messages.properties on the classpath and a MessageSource for the basename messages exists; an Accept-Language based resolver is what Spring MVC uses when you do not configure one.
The English file is the fallback for every language, and the other two hold translations of the same keys. Here are the first two keys of each (messages.properties, messages_mr.properties, messages_hi.properties):
greeting=Hello, {0}!
cart.items=You have {0} items in your cart.
greeting=नमस्कार, {0}!
cart.items=तुमच्या कार्टमध्ये {0} वस्तू आहेत.
greeting=नमस्ते, {0}!
cart.items=आपकी कार्ट में {0} वस्तुएँ हैं।
The {0} is a placeholder for the first argument. The controller receives the request’s Locale as a plain method parameter (Spring MVC fills it in from whichever resolver the application has) and hands it to the MessageSource (ShopController.java):
/** The locale parameter is filled in by whichever LocaleResolver the application has. */
@GetMapping("/api/greeting")
Map<String, String> greeting(@RequestParam(defaultValue = "Asha") String name, Locale locale) {
return Map.of("locale", locale.toString(), "message", messages.getMessage("greeting", new Object[]{name}, locale));
}
Now call it with different Accept-Language headers. This is what came back (from 01-accept-language.txt):
Accept-Language: mr-IN -> status=200 locale=mr_IN message=नमस्कार, Asha!
Accept-Language: hi -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: mr;q=0.4, hi;q=0.9 -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: en-GB -> status=200 locale=en_GB message=Hello, Asha!
Accept-Language: fr -> status=200 locale=fr message=Hello, Asha!
Accept-Language: (no header) -> status=200 locale=en_US message=Hello, Asha!
Four things are worth reading off that transcript. mr-IN (Marathi as spoken in India) resolved to the locale mr_IN and still got the Marathi sentence, because there is no messages_mr_IN file and the lookup falls back to messages_mr. The quality values in mr;q=0.4, hi;q=0.9 were honoured: Hindi won, because the client said it prefers it. Neither French nor British English has a file, so both fall through to messages.properties and answer in English. And a request with no header at all got the locale en_US, which is a hint that something outside the request supplied it. The section on the hidden fallback below is about what.
Going deeper: the order in which files are tried
For a request in mr_IN the lookup tries the most specific file first and then loosens: messages_mr_IN, then messages_mr, then the base messages. That is the documented behaviour of ResourceBundle, which Spring’s default message source is built on, and the mr-IN row above is consistent with it. The row that is not obvious from that rule is the one where the file is missing altogether, and there the JVM’s own default locale takes part; the section on the hidden fallback measures it.
Spring Boot’s message source is configured by a handful of spring.messages.* properties. Their names, types and defaults, read straight out of the configuration metadata in the Spring Boot jar, are in 12-configuration-metadata.txt; none of them is set in this application, so everything above is Boot’s defaults.
Going deeper
- Spring Boot reference: internationalization
- Spring Framework reference: MessageSource
- Spring Framework reference: locale resolution in Spring MVC
- The scenario as a test: LocaleResolutionTests.java, method
acceptLanguageHeader
Write the translations once, and save the files as UTF-8
Marathi and Hindi are written in Devanagari, and in the files above the sentences are simply typed in Devanagari. That works because Spring Boot reads message files as UTF-8 by default. Older advice tells you to escape every non-Latin character as\u0928 because Java’s original properties format was ISO-8859-1; that advice is a reason people still write unreadable files, and it is not needed here.
The danger is the opposite one: a setting or a habit that makes Spring read the file with a different character set than the one the file was saved in. To see what that looks like, the test starts the application twice and asks for the Marathi greeting. The second run sets spring.messages.encoding=ISO-8859-1. Nothing else differs (from 07-properties-encoding.txt):
default (UTF-8): नमस्कार, Asha!
ISO-8859-1: नमसà¥à¤à¤¾à¤°, Asha!
The bytes of the file are the same in both runs; only the assumption about what they mean changed, so every three-byte Devanagari letter was read as three separate Latin characters. There is no exception and no warning. The application starts, the key is found, and the user sees garbage. Two things prevent it in practice: leave spring.messages.encoding alone, and set your editor and your build (project.build.sourceEncoding, the resource filtering encoding) to UTF-8.
The other everyday failure is a key that some language does not have. The Marathi file deliberately omits only.english.key, and nothing has the key no.such.key (from 06-missing-keys.txt):
only.english.key, locale mr -> This sentence has not been translated yet.
no.such.key, locale mr -> org.springframework.context.NoSuchMessageException: No message found under code 'no.such.key' for locale 'mr'.
A key that exists in the base file but not in the language file silently falls back to the base text, English here, so a half-finished translation ships as a mixture. A key that exists nowhere is different: MessageSource.getMessage throws NoSuchMessageException, and the exception message tells you the code and the locale that missed. The mixture is the one to watch for, because nothing fails.
Keep the files in step with a test, not with discipline. Missing translations never raise an error, so the only way to notice one is to look for it. A test that loads each language file and asserts that it has every key of the base file costs a few lines and turns “we forgot the Hindi one” from a bug report into a red build. This project does not include that test, so I make no claim about how it would look beyond the idea.
Going deeper: why the encoding test needed a cache flush
My first version of the encoding test printed correct Marathi in both runs, which looked like Spring ignoring the property. It was not: ResourceBundle caches the bundles it has loaded for the whole JVM, so the second application reused what the first one had already decoded. The committed test calls ResourceBundle.clearCache() between the runs (see MessagesTests.java, method wrongEncoding). In a real application this only matters if you change the encoding of a running JVM, which you will not; it matters in tests, and it is a good example of why a “the setting did nothing” result deserves a second look.
Spring Boot also has spring.messages.use-code-as-default-message, whose default in the metadata is false (transcript 12-configuration-metadata.txt). With the default, a missing key throws as shown above. I did not run it switched on, so I make no claim about what the output looks like.
Going deeper
- Spring Boot reference: internationalization (message source properties)
- Every
spring.messages.*andspring.web.locale*property with its type and default: 12-configuration-metadata.txt, written by capture-facts.sh - The scenarios as tests: MessagesTests.java, methods
wrongEncodingandmissingAndPartialTranslations
Two ways a correct translation prints wrong: apostrophes and digits
Both problems come from the same place. When a message has arguments, Spring does not just paste them in: it runs the text throughjava.text.MessageFormat, which has its own rules about characters and its own idea of what a number looks like in Marathi.
Start with the apostrophe. The base file has two versions of the same sentence, one with a doubled apostrophe and one with a single one (messages.properties):
apos.doubled=Order {0} can''t be shipped yet.
apos.single=Order {0} can't be shipped yet.
Here is each looked up with an argument and without one (from 04-messageformat-apostrophes.txt):
with an argument: doubled -> Order A-1 can't be shipped yet.
with an argument: single -> Order A-1 cant be shipped yet.
without arguments: doubled -> Order {0} can''t be shipped yet.
without arguments: single -> Order {0} can't be shipped yet.
With an argument, the doubled apostrophe is the correct one and the single one swallows a letter: can't became cant. In MessageFormat a single apostrophe starts a quoted section, so it disappears, and a doubled one means a literal apostrophe. Without arguments the rule flips: Spring hands back the raw text, so the doubled version prints its two apostrophes and the single one is fine. That is the trap. The same file can have a sentence that is right for one call and wrong for another, and the sentence that is right when it has an argument looks wrong when someone reads the file.
Spring Boot has a switch that applies MessageFormat everywhere, spring.messages.always-use-message-format, which the metadata shows defaulting to false. Here is the same set of lookups with it turned on (from 10-always-use-message-format.txt):
with an argument: doubled -> Order A-1 can't be shipped yet.
with an argument: single -> Order A-1 cant be shipped yet.
without arguments: doubled -> Order {0} can't be shipped yet.
without arguments: single -> Order {0} cant be shipped yet.
It makes the doubled apostrophe print correctly without arguments too, and it makes the single one lose its letter everywhere, so the rule becomes uniform: write '' in every message that could ever be formatted. That is the safe convention, and switching the property on makes the convention enforceable instead of dependent on which call site passes arguments. One more thing the last transcript shows: the placeholder {0} in the no-argument lines stayed as the text {0}, because there was nothing to put in it.
Now the digits. The cart message takes a number, and the controller passes it as a long. The three languages get the same number, and they do not agree on how to write it (from 05-devanagari-digits.txt):
Accept-Language: en -> You have 1,234,567 items in your cart.
Accept-Language: hi -> आपकी कार्ट में 1,234,567 वस्तुएँ हैं।
Accept-Language: mr -> तुमच्या कार्टमध्ये १,२३४,५६७ वस्तू आहेत.
mr defaults to them and the data for hi does not. Neither is a bug. Whether it is what you want depends on your audience, and many Marathi-speaking users read Latin digits without difficulty. If you want Latin digits there are two ways, both in the same transcript (05-devanagari-digits.txt):
--- fix 1: the client asks for Latin digits (Unicode extension -u-nu-latn) ---
Accept-Language: mr-IN-u-nu-latn -> locale=mr_IN_#u-nu-latn तुमच्या कार्टमध्ये 1,234,567 वस्तू आहेत.
--- fix 2: the server formats the number itself and passes text (GET /api/cart-latin) ---
Accept-Language: mr -> तुमच्या कार्टमध्ये 1,234,567 वस्तू आहेत.
The first fix is a request from the client: a Unicode extension in the language tag, -u-nu-latn (“numbering system: Latin”), which the JDK understands. The application resolved the locale as mr_IN_#u-nu-latn, still found the Marathi file, and printed Latin digits. The second fix is on the server: format the number yourself and pass the resulting text as the argument, so MessageFormat has no number left to localize (ShopController.java, method cartLatin):
/** The same sentence, but the number is formatted by us and handed over as text, so MessageFormat cannot localize its digits. */
@GetMapping("/api/cart-latin")
Map<String, String> cartLatin(@RequestParam long items, Locale locale) {
String formatted = NumberFormat.getIntegerInstance(Locale.ENGLISH).format(items);
return Map.of("locale", locale.toString(), "message", messages.getMessage("cart.items", new Object[]{formatted}, locale));
}
Choose the digits on purpose, per audience. The first fix depends on every client sending the extension; the second is under your control but bypasses the locale for that number, so you must pick the format yourself (the code above picks English grouping for every language). Neither is a default to accept without a decision.
Going deeper: the same rule shows up in error messages
Anything that goes through MessageFormat with a number argument behaves like the cart. The section on localized ProblemDetail shows it in a Marathi error detail: the id 7 in a not-found response comes out as ७, while the Hindi one keeps 7. Bean Validation messages use a different mechanism, and in the validation section the 18 in the Marathi age message is a Latin 18. Two mechanisms in one response, two different digit conventions.
I only measured a long argument. I did not test dates, currencies or percentages, so this article makes no claim about how those look in Marathi or Hindi. The MessageFormat Javadoc is the reference for the {0,number} and {0,date} forms.
Going deeper
- MessageFormat Javadoc: quoting and the {0,number} forms
- The scenarios as tests: MessagesTests.java, methods
apostrophesanddevanagariDigits
Let the user choose the language: Accept-Language, a cookie, or one fixed language
By default the browser chooses. It sends anAccept-Language header taken from its own settings, and Spring’s default resolver reads it, as transcript 01 showed. That is the right behaviour for an API and a reasonable start for a website. It is the wrong one when the user wants to override the browser: the reader whose laptop is set to English but who wants the shop in Marathi. For that you need the choice to be remembered, and a cookie is the usual place.
| Strategy | How the language is decided | How you set it up |
|---|---|---|
| Accept-Language (the default) | Each request carries its own header | Nothing |
| Cookie | The first choice is stored in a cookie and used from then on | A CookieLocaleResolver bean named localeResolver, plus a LocaleChangeInterceptor to read ?lang= |
| Fixed | One language for everyone, whatever the header says | spring.web.locale=hi and spring.web.locale-resolver=fixed |
?lang=mr and tells the resolver to change (LocaleConfig.java):
@Configuration(proxyBeanMethods = false)
@Profile("cookie")
static class Cookie implements WebMvcConfigurer {
@Bean
LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
resolver.setDefaultLocale(Locale.ENGLISH);
resolver.setCookieMaxAge(Duration.ofDays(30));
return resolver;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(changeInterceptor());
}
}
The helper changeInterceptor() in the same file creates the interceptor with the parameter name lang. Three requests show the behaviour (from 03-cookie-locale-resolver.txt, first section):
no cookie yet, Accept-Language: hi -> status=200 locale=en message=Hello, Asha!
?lang=mr, Accept-Language: hi -> status=200 locale=mr message=नमस्कार, Asha!
Set-Cookie: lang=mr; Path=/; Max-Age=2592000; SameSite=Lax
Cookie: lang=mr, Accept-Language: hi -> status=200 locale=mr message=नमस्कार, Asha!
CookieLocaleResolver is in charge, the header is no longer consulted at all; the resolver’s own default (English, set in the class above) applies until a cookie exists. The second row is the switch: ?lang=mr changed the locale for that request and the response set the cookie. The third shows the cookie winning over the header on later requests. If you want “follow the browser until the user chooses”, the resolver must be told to do that itself; this project does not, so I make no claim about how.
The cookie the server sent is (the test removes the Expires= timestamp, which changes on every run):
Set-Cookie: lang=mr; Path=/; Max-Age=2592000; SameSite=Lax
Thirty days is Max-Age=2592000 seconds, the value the configuration class set with setCookieMaxAge. The cookie is named lang because the resolver was constructed with that name, and it carries SameSite=Lax, which I did not configure.
Now the two ways to get this wrong. Both compile, both start, and both fail on the first ?lang= request. The first names the resolver bean anything other than localeResolver (LocaleConfig.java, profile cookiewrongname):
LocaleResolver cookieLocaleResolver() {
The second has the interceptor and no cookie resolver at all (profile interceptoronly). This is the transcript of both (from 03-cookie-locale-resolver.txt, second and third sections):
--- B. the same resolver under the bean name cookieLocaleResolver (profile 'cookiewrongname') ---
Accept-Language: hi -> status=200 locale=hi message=नमस्ते, Asha!
?lang=mr -> status=500 (Tomcat's own HTML error page)
bean 'cookieLocaleResolver' exists: true
bean 'localeResolver' is a: AcceptHeaderLocaleResolver
what the interceptor triggers: UnsupportedOperationException: Cannot change HTTP Accept-Language header - use a different locale resolution strategy
--- C. only the interceptor, Spring Boot's default resolver (profile 'interceptoronly') ---
?lang=mr -> status=500 (Tomcat's own HTML error page)
bean 'localeResolver' is a: AcceptHeaderLocaleResolver
what the interceptor triggers: UnsupportedOperationException: Cannot change HTTP Accept-Language header - use a different locale resolution strategy
The wrongly named bean exists (containsBean says so), but Spring MVC looks for a resolver by the exact name localeResolver, so what it actually uses is Spring Boot’s default, AcceptHeaderLocaleResolver. That resolver reads the header and cannot store anything, so when the interceptor asks it to change the locale it throws UnsupportedOperationException. The visible result is a 500. Notice also the first line of section B: without ?lang the application answered in Hindi, following the header, so a smoke test that never sends ?lang passes. Only the test that tries to change the language finds it.
The bean name is part of the contract. AThe third strategy is not a class but two properties, and it is the blunt one: everyone gets one language. Here it is with the header saying Marathi and French (from 11-spring-web-locale.txt):LocaleResolverbean called anything butlocaleResolveris ignored by Spring MVC, silently. The response to a?lang=request was Tomcat’s own HTML error page, not JSON, and I did not chase why the application’s error handling did not render it, so the exception text above is what the resolver throws when asked directly, the samesetLocalecall the interceptor makes.
--- spring.web.locale=hi (spring.web.locale-resolver left at accept-header) ---
Accept-Language: (no header) -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: mr -> status=200 locale=mr message=नमस्कार, Asha!
Accept-Language: fr -> status=200 locale=fr message=Hello, Asha!
--- spring.web.locale=hi and spring.web.locale-resolver=fixed ---
Accept-Language: (no header) -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: mr -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: fr -> status=200 locale=hi message=नमस्ते, Asha!
With only spring.web.locale=hi the property is just the default: the request without a header got Hindi, Marathi still got Marathi, and French got English (the fallback described in the next section, not Hindi). With spring.web.locale-resolver=fixed it is an order: whatever the header says, the locale is Hindi. A fixed resolver is a good fit for a single-language deployment, and useless for a multi-language one.
Going deeper: what else can be stored, and where
CookieLocaleResolver is one of the resolvers Spring MVC ships; the framework reference lists the others, including one that keeps the locale in the HTTP session. I only ran the header, cookie and fixed strategies here, and only those are claimed. A session-based resolver has the same setLocale contract as the cookie one, which is why the interceptor works with either, but I did not test it.
Because the tests use java.net.http against the real embedded server rather than MockMvc, the Accept-Language parsing and the Set-Cookie header in the transcripts are what a browser would see. The helper is Http.java; the scenarios are in LocaleResolutionTests.java.
Going deeper
- Spring Framework reference: locale resolution (header, cookie, session, fixed)
- Spring Boot reference: internationalization (spring.web.locale)
- The scenarios as tests: LocaleResolutionTests.java, methods
cookieLocaleResolverandspringWebLocaleProperty
The JVM’s own language is a hidden fallback
Go back to the last row of transcript 01: a request with noAccept-Language header was answered with the locale en_US. Nobody in the application chose that. It is the default locale of the Java process, which comes from the operating system unless something overrides it, and it takes part in message lookup in a way that has caught many teams out. Here is the scenario. Suppose the server’s own language is Hindi, perhaps because someone installed the operating system in Hindi. The test sets the JVM default to hi and asks for French and for English (from 02-fallback-to-system-locale.txt):
Locale.getDefault() while these ran: hi
--- spring.messages.fallback-to-system-locale left at its default ---
Accept-Language: fr -> status=200 locale=fr message=नमस्ते, Asha!
Accept-Language: en -> status=200 locale=en message=नमस्ते, Asha!
--- spring.messages.fallback-to-system-locale=false ---
Accept-Language: fr -> status=200 locale=fr message=Hello, Asha!
Accept-Language: en -> status=200 locale=en message=Hello, Asha!
messages_en file to find, and the fallback found messages_hi before it ever reached the base file. The second half is the fix: spring.messages.fallback-to-system-locale=false makes the base file the fallback, and both requests get English.
Set this to false unless you want the server’s language to leak. The property’s default istrue(transcript 12-configuration-metadata.txt). On a machine whose language you control it goes unnoticed; on the first deployment to a host or a container image with a different default it changes what your users read, with no error anywhere. I did not test creating amessages_en.propertiesas an alternative, so I do not claim that it would work the same way.
Going deeper: a Hindi default that leaked into another test
The same JVM-wide default is why the “no header” row of transcript 01 was, in one early run of the test suite, answered in Hindi: an earlier test in the same JVM had set the default to hi and the value had stuck for the later application. I did not trace which class holds it. The fix in the project is mechanical: the fallback scenario is its own test class, SystemLocaleFallbackTests.java, and surefire is configured with reuseForks=false so that each test class gets a fresh JVM (pom.xml). If your own tests change Locale.setDefault, do the same, or restore the previous value and expect surprises anyway.
The line Locale.getDefault() while these ran: hi at the top of the transcript is printed by the test so that the premise is visible in the output and not only in the source.
Going deeper
- Spring Boot reference: internationalization
- Spring Framework reference: MessageSource
- The scenario as a test: SystemLocaleFallbackTests.java
Validation messages in the user’s language
Bean Validation reports what is wrong with a request body, and by default it says so in English (or in whatever language the validator ships). To translate those messages you put a key in braces where the message text would go, and Spring Boot’s validator looks the key up in the same message files. The request class is (CustomerRequest.java):public record CustomerRequest(
@NotBlank(message = "{customer.name.required}") String name,
@Email(message = "{customer.email.invalid}") @NotBlank(message = "{customer.email.required}") String email,
@Min(value = 18, message = "{customer.age.min}") int age,
@NotNull String city) {
}
The first three fields name their message keys, and the fourth, city, deliberately does not. The keys are in the message files, and the Marathi ones read like this (messages_mr.properties):
customer.name.required=नाव आवश्यक आहे.
customer.email.required=ईमेल आवश्यक आहे.
customer.email.invalid=कृपया वैध ईमेल पत्ता द्या.
customer.age.min=तुमचे वय किमान {value} वर्षे असणे आवश्यक आहे.
Note {value} in the last line, with no number in it. That is a placeholder that Bean Validation fills from the annotation’s own attribute (@Min(value = 18 ...)), a different mechanism from the {0} that MessageFormat fills. The file has one more line at the end, for the city field that names no message (messages_mr.properties):
jakarta.validation.constraints.NotNull.message=रिक्त नसावे.
That is the validator’s own message key for @NotNull, and defining it in your file replaces the validator’s text for that constraint.
Send an invalid body, with all four fields wrong, in four languages. The body is {"name":"","email":"x","age":10} (the missing city is the fourth error). The handler that produces the response is in the next section; here is what came back (from 08-validation-messages.txt):
--- Accept-Language: en ---
status=400 Content-Type=application/problem+json
title: Validation failed
detail: The request has invalid fields.
error: age: You must be at least 18 years old.
error: city: must not be null
error: email: Enter a valid email address.
error: name: Name is required.
--- Accept-Language: mr ---
status=400 Content-Type=application/problem+json
title: पडताळणी अयशस्वी
detail: विनंतीतील काही फील्ड चुकीची आहेत.
error: age: तुमचे वय किमान 18 वर्षे असणे आवश्यक आहे.
error: city: रिक्त नसावे.
error: email: कृपया वैध ईमेल पत्ता द्या.
error: name: नाव आवश्यक आहे.
--- Accept-Language: hi ---
status=400 Content-Type=application/problem+json
title: सत्यापन विफल
detail: अनुरोध के कुछ फ़ील्ड अमान्य हैं।
error: age: आपकी आयु कम से कम 18 वर्ष होनी चाहिए।
error: city: खाली नहीं होना चाहिए।
error: email: कृपया सही ईमेल पता दर्ज करें।
error: name: नाम आवश्यक है।
--- Accept-Language: fr ---
status=400 Content-Type=application/problem+json
title: Validation failed
detail: The request has invalid fields.
error: age: You must be at least 18 years old.
error: city: ne doit pas être nul
error: email: Enter a valid email address.
error: name: Name is required.
Everything from the response body is translated in the Marathi and Hindi blocks: the title, the detail and every field message. The city line is the one to study. In English it is must not be null, the validator’s own text. In Marathi and Hindi it is the text of the NotNull.message key that this project defines. And for French, where the project defines nothing, it is ne doit pas être nul: the validator answered in French on its own, because it ships a French translation. Whatever languages the validator ships come for free; for any other you must define the key yourself, as this project does for Marathi and Hindi.
The French row is a warning as well as a nicety. The response for fr is a mixture: the title, detail and three field messages are English (the base file), and one field message is French. A language you have not translated does not fall back as one unit. Each piece falls back on its own, so a partly supported language produces sentences in two languages in one response. If you support fr, translate all of it; if you do not, decide what should happen, and test that.
Going deeper: why the error list is sorted, and where each message came from
The errors list in the response is sorted by the handler (next section shows the code). Bean Validation reports the violations in no guaranteed order, and a transcript that reordered itself between runs would make every later comparison noisy, so the handler sorts, which is also friendlier to clients.
The keys in braces are resolved by Spring Boot’s validator, which is wired to the application’s MessageSource; this is why the same messages_mr.properties file serves both the controller’s own sentences and the validation messages. The Hibernate Validator reference describes message interpolation and the {value}-style placeholders in detail.
Going deeper
- Hibernate Validator reference: message interpolation
- Spring Framework reference: validation configuration in Spring MVC
- The scenario as a test: ProblemDetailTests.java, method
validationMessages
Localized ProblemDetail: what Spring translates and what stays English
The last piece is the error response itself. Spring’sProblemDetail (the standard error body from RFC 9457, covered in the ProblemDetail article) has a title and a detail, and both are text a user may read. There are three kinds of exception in the wild, and they behave differently.
ErrorResponseException is a ready-made class) gets its title and detail looked up by Spring in the message source. An ordinary exception has no such contract, so somebody has to translate it, and that somebody is your handler. If the handler puts English text in the source, the response is English, whatever the request asked for.
Take the first kind. The exception carries the id as a message argument, and nothing else (OrderNotFoundException.java):
public class OrderNotFoundException extends ErrorResponseException {
public OrderNotFoundException(long id) {
super(HttpStatus.NOT_FOUND, ProblemDetail.forStatus(HttpStatus.NOT_FOUND), null, null, new Object[]{id});
}
}
Spring looks for two keys, built from the class name: problemDetail.title. and problemDetail. followed by the fully qualified class name. The Marathi file defines them (messages_mr.properties):
problemDetail.title.com.ankurm.i18n.web.OrderNotFoundException=ऑर्डर सापडली नाही
problemDetail.com.ankurm.i18n.web.OrderNotFoundException={0} क्रमांकाची ऑर्डर अस्तित्वात नाही.
For the second kind, the handler calls the message source by hand, and for the third it writes the English text in the source, the way most handlers are written (ApiExceptionHandler.java):
@ExceptionHandler(PaymentDeclinedException.class)
ProblemDetail paymentDeclined(PaymentDeclinedException ex) {
var locale = LocaleContextHolder.getLocale();
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.PAYMENT_REQUIRED,
getMessageSource().getMessage("payment.declined", new Object[]{ex.amount()}, locale));
problem.setTitle(getMessageSource().getMessage("payment.declined.title", null, locale));
return problem;
}
@ExceptionHandler(LegacyStateException.class)
ProblemDetail legacyState() {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The order is in a state that does not allow this.");
problem.setTitle("Conflict");
return problem;
}
Each is thrown by an endpoint, called in three languages (from 09-problemdetail-localized.txt):
--- an ErrorResponse (OrderNotFoundException) ---
Accept-Language: en
status=404 Content-Type=application/problem+json
title: Order not found
detail: No order with id 7 exists.
Accept-Language: mr
status=404 Content-Type=application/problem+json
title: ऑर्डर सापडली नाही
detail: ७ क्रमांकाची ऑर्डर अस्तित्वात नाही.
Accept-Language: hi
status=404 Content-Type=application/problem+json
title: ऑर्डर नहीं मिला
detail: क्रमांक 7 वाला ऑर्डर मौजूद नहीं है।
--- an ordinary exception, translated in the handler (PaymentDeclinedException) ---
Accept-Language: en
status=402 Content-Type=application/problem+json
title: Payment declined
detail: Payment of 499.00 was declined.
Accept-Language: mr
status=402 Content-Type=application/problem+json
title: पेमेंट नाकारले
detail: 499.00 चे पेमेंट नाकारले गेले.
Accept-Language: hi
status=402 Content-Type=application/problem+json
title: भुगतान अस्वीकृत
detail: 499.00 का भुगतान अस्वीकार कर दिया गया।
--- an ordinary exception, English in the handler (LegacyStateException) ---
Accept-Language: en
status=409 Content-Type=application/problem+json
title: Conflict
detail: The order is in a state that does not allow this.
Accept-Language: mr
status=409 Content-Type=application/problem+json
title: Conflict
detail: The order is in a state that does not allow this.
Accept-Language: hi
status=409 Content-Type=application/problem+json
title: Conflict
detail: The order is in a state that does not allow this.
The first two kinds behave the same from the caller’s side: the response is application/problem+json, and the title and detail are in the request’s language. The third is the one that stays English in every language, including the title, Conflict. Nothing warns you: the handler compiles, the test for the English path passes, and the Marathi user sees English. The only way to find these is to look at every handler for a string literal, or to test every exception in every language, which is what this transcript does.
Two smaller things in the first block. The id in the Marathi detail is ७, in Devanagari, while the Hindi one is 7. That is the digit behaviour from earlier, arriving through an exception argument: the id was a long, formatted by MessageFormat in the request’s locale. If you would rather have Latin digits in error messages, pass String.valueOf(id) instead of the number (I did not run that variant, but it is the same technique as the second digits fix). And the validation error from the previous section is also a Spring exception that is an ErrorResponse, so its title and detail are found in the message files under the class name in the same way. Here are the two keys for it, in the Hindi file, and the handler method that adds the field messages (ApiExceptionHandler.java):
problemDetail.title.org.springframework.web.bind.MethodArgumentNotValidException=सत्यापन विफल
problemDetail.org.springframework.web.bind.MethodArgumentNotValidException=अनुरोध के कुछ फ़ील्ड अमान्य हैं।
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail problem = ex.updateAndGetBody(getMessageSource(), LocaleContextHolder.getLocale());
problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.sorted()
.toList());
return handleExceptionInternal(ex, problem, headers, status, request);
}
The handler calls updateAndGetBody itself, with the message source and the request’s locale, because it wants to add the errors property after the translation has happened. That is a choice for this project’s response shape; I did not test what a handler that does not override the method returns, so I do not claim that the override is required for the translation.
Translating one exception is not the same as translating every exception. The third kind is the one that ships to production unnoticed. When you add a handler, ask: where does this text come from, and is that a message key?
Going deeper: choosing what to translate on the server
The server does not have to translate errors at all. A common alternative is to return a stable machine-readable code and let the client show its own translation. That keeps the language files in one place (the client), and it avoids all three problems above. This project translates on the server because the task was to show how, not because it is the better design in every case; the closing section says when I would not.
The ProblemDetail article on this site covers the response shape and the ResponseEntityExceptionHandler hierarchy this handler extends; the Spring reference page on error responses has the full list of exceptions that are ErrorResponses and the message-code convention that the keys above follow.
Going deeper
- Spring Framework reference: error responses and ProblemDetail (including message codes)
- RFC 9457: Problem Details for HTTP APIs
- Spring Boot 4 ProblemDetail (RFC 9457) and global exception handling
- The scenario as a test: ProblemDetailTests.java, method
problemDetailTitlesAndDetails
Should you translate on the server at all?
Every language is a second copy of every sentence, forever. Three files means three places to change when a message changes, and (as the missing-key section showed) nothing fails when one of them is forgotten. Translate on the server when the server’s text is what the user reads: server-rendered pages, emails, PDFs, or an API consumed directly by people. If your clients are apps or single-page applications that already have a translation system, return a stable error code and a plain English detail for logs, and let the client translate.
If you do translate on the server, three checks are worth writing down. Look at the digits your audience expects (transcript 05 and 09). Setspring.messages.fallback-to-system-locale=false(transcript 02). And test every exception handler in every language, because the one that is not translated is silent (transcript 09). I did not test dates, currencies, plural forms or right-to-left languages, so nothing above says how they behave.
Going deeper
- Run everything yourself: the module README has the quick-start, the profile table and an index of the twelve transcripts;
./scripts/run-all.shregenerates them
Further reading
- Companion repository for this article: asmhatre/spring-boot-demo, i18n module
- On this site: Spring Boot 4 ProblemDetail (RFC 9457) and Global Exception Handling, @ConfigurationProperties vs @Value in Spring Boot 4, Spring Boot 3 to 4 Migration Guide, Spring Boot 4 Interview Questions
- Spring Boot reference: internationalization
- Spring Framework reference: MessageSource
- Spring Framework reference: locale resolution in Spring MVC
- Spring Framework reference: error responses and ProblemDetail
- MessageFormat Javadoc
- RFC 9457: Problem Details for HTTP APIs
No Comments yet!