Spring Boot Internationalization (i18n): Locale Resolution & Message Sources
Learn Spring Boot i18n with locale resolution strategies, message sources, and production incident stories.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17+
- ✓Spring Boot 3.2.x project (Maven or Gradle)
- ✓Basic understanding of Spring MVC or WebFlux
• Implement i18n by defining message properties files per locale (e.g., messages_en.properties, messages_fr.properties) and configuring a MessageSource bean. • Use AcceptHeaderLocaleResolver or SessionLocaleResolver to resolve locale from HTTP requests. • Access localized messages via MessageSource.getMessage() or Spring's #{} expression in Thymeleaf. • For production, always validate locale codes and handle fallback to default locale. • Avoid hardcoding locale resolution; use LocaleContextHolder for thread-safe access.
Imagine a restaurant menu that changes language based on who walks in. If a French speaker enters, the menu switches to French; if a German speaker, it switches to German. Spring Boot i18n does the same for your web app: it detects the user's language (from browser settings, session, or URL) and serves the right translation of your text, without you rewriting the whole page.
Internationalization (i18n) is a critical feature for any SaaS application that serves a global user base. Without it, you're forcing every user to speak the same language, which is a fast track to churn in non-English markets. Spring Boot provides first-class support for i18n through its MessageSource abstraction and locale resolution mechanisms. In this article, we'll walk through the complete setup: from defining message properties files to configuring locale resolvers, and we'll cover the pitfalls that hit us in production at a payment-processing company. We'll use Spring Boot 3.2.x (with Java 17+) and focus on practical, testable patterns. You'll learn how to resolve locales from HTTP headers, sessions, cookies, and even custom strategies. We'll also dive into message formatting with placeholders, pluralization, and fallback handling. By the end, you'll be able to internationalize your REST APIs and web views with confidence. This isn't just a 'hello world' tutorial — we're going to cover real-world scenarios like thread safety in async processing and caching message sources for performance.
Setting Up Message Sources
The foundation of i18n in Spring Boot is the MessageSource interface. By default, Spring Boot auto-configures a ResourceBundleMessageSource that reads properties files from the classpath. You define a base name (e.g., 'messages') and then create files like messages_en.properties, messages_fr.properties, and messages_de.properties. Each file contains key-value pairs for your localized strings. For example, in messages_en.properties: greeting=Hello, {0}! and in messages_fr.properties: greeting=Bonjour, {0}!. The {0} is a placeholder that you can replace at runtime with MessageSource. The default base name is 'messages', but you can change it via spring.messages.basename in application.properties. You can also specify multiple basenames separated by commas. In production, we used a single basename 'i18n/messages' to keep things organized. The message files should be placed in src/main/resources/i18n/. Make sure to use ISO 639-1 language codes (e.g., 'en', 'fr', 'de') and optionally country codes (e.g., 'en_US', 'fr_CA'). The fallback mechanism will go from the most specific to the least specific locale, and finally to the default locale's file (without suffix). If a key is missing, Spring throws a NoSuchMessageException, so you should always provide a default message or use the 'defaultMessage' parameter.
What the Official Docs Won't Tell You
The official Spring documentation covers the basics of MessageSource and locale resolvers, but it skips several production realities. First, the default AcceptHeaderLocaleResolver uses the 'Accept-Language' header from the HTTP request. This works fine for browsers, but what about API clients that don't send this header? You'll get the default locale (usually English) and no error. In a payment-processing system, this caused us to send English error messages to a French mobile app because the app didn't set the header. Second, the docs don't emphasize thread safety. If you use SessionLocaleResolver, the locale is stored in the HTTP session, which is fine for synchronous requests. But in reactive stacks (WebFlux) or async processing, you need to manually propagate the locale using LocaleContextHolder. Third, message source caching is glossed over. By default, ResourceBundleMessageSource caches forever. In production, if you update a properties file, you need to restart the app or use ReloadableResourceBundleMessageSource with a cache timeout. We learned this the hard way when a hotfix for a typo in a French message didn't take effect for hours. Finally, the docs don't warn about missing keys. If a key is missing for a requested locale, Spring falls back to the default locale silently. This can mask bugs where translations are incomplete. In our case, a missing key for a critical error message in German caused the app to show English, and users complained. The fix: implement a custom MessageSource that logs warnings for missing keys.
Locale Resolution Strategies
Spring Boot provides several locale resolvers out of the box. The most common are AcceptHeaderLocaleResolver (uses the 'Accept-Language' HTTP header), SessionLocaleResolver (stores locale in the user's HTTP session), CookieLocaleResolver (stores locale in a cookie), and FixedLocaleResolver (always uses a fixed locale). For REST APIs, AcceptHeaderLocaleResolver is the safest choice because it doesn't require session state. For web applications with user preferences, SessionLocaleResolver or CookieLocaleResolver allow users to switch languages persistently. You can also implement a custom LocaleResolver by implementing the LocaleResolver interface. For example, a URL-based resolver that reads a language parameter from the request path (e.g., /en/orders, /fr/orders). In a SaaS billing system, we used a hybrid approach: AcceptHeaderLocaleResolver as default, but allowed authenticated users to override their locale via a profile setting stored in the database. This required a custom resolver that first checks the database, then falls back to the header. The resolution order matters: Spring's LocaleContextHolder holds the resolved locale, and you can access it anywhere in the request thread. For async tasks, you must manually set the locale context using LocaleContextHolder.setLocale(), otherwise the default locale is used.
Using MessageSource in Controllers and Services
Once your MessageSource and locale resolver are configured, you can inject MessageSource into your beans and call getMessage(). The method takes a code (the key in properties file), an array of arguments (for placeholders), and a Locale. The Locale can be obtained from LocaleContextHolder.getLocale(), which is automatically populated by Spring's DispatcherServlet based on the resolved locale. In a controller, you can also use @RequestHeader to get the Accept-Language header directly, but that's more verbose. For Thymeleaf templates, you can use the #{} syntax: #{greeting} will automatically resolve using the current locale. For REST APIs returning JSON, you typically embed the localized message in the response body. For example, in an error response, you might include a localized message field. In a payment-processing system, we used a base error class that accepted a message key and arguments, and the error handler would call MessageSource to get the localized string before sending the response. This ensured that all error messages were consistent and localized. Remember to handle the case where the message key doesn't exist — always provide a default message string to avoid NoSuchMessageException.
LocaleContextHolder.getLocale() to get the current locale. Always provide a default message for robustness.Internationalizing Validation Messages
Spring Boot's validation framework (JSR-380 Bean Validation) supports i18n out of the box. You can define custom validation messages in your properties files using the key format: {javax.validation.constraints.NotNull.message} (for Hibernate Validator). Alternatively, you can override these keys in your own messages.properties file. For example, in messages_en.properties: NotNull.orderId=Order ID must not be null. Then in your DTO, you annotate the field with @NotNull(message = "{NotNull.orderId}"). Spring will resolve the message key using the current locale. This is powerful because you get localized error messages with minimal code. However, a common pitfall is forgetting to add the curly braces around the key. Without them, Spring treats the string as a literal message, not a key. Also, ensure that your validation messages are in the same basename as your other messages. By default, Spring Boot's auto-configuration for validation uses a separate message source, but you can configure it to use your custom MessageSource by setting spring.messages.basename and ensuring the validator is configured to use it. In production, we created a custom Validator bean that used our LoggingMessageSource to catch missing validation message keys early.
Handling Locale in Async and Reactive Contexts
In asynchronous processing (e.g., @Async methods, CompletableFuture), the locale context is not automatically propagated. The LocaleContextHolder uses a ThreadLocal, so if you spawn a new thread, it won't have the original request's locale. The fix is to capture the locale before the async call and pass it explicitly. For @Async methods, you can use a custom TaskExecutor that copies the LocaleContext from the parent thread. Alternatively, you can use Spring's RequestContextFilter or a custom interceptor to set the locale on the new thread. In reactive stacks (WebFlux), the locale is typically resolved via the ServerWebExchange, and you need to use a reactive LocaleContextHolder equivalent. Spring WebFlux provides LocaleContextHolder with a reactive variant that uses Reactor's Context. You can access it via ReactiveLocaleContextHolder. In a real-time analytics platform, we had a service that processed events asynchronously and sent localized emails. We captured the locale in the event payload and passed it to the email service, which then used it to resolve the email template. This avoided thread-local issues entirely.
LocaleContextHolder.setLocale() in the async thread and reset it afterward.Testing Internationalization
Testing i18n requires verifying that messages are resolved correctly for different locales. You can use Spring's MockMvc to simulate requests with different Accept-Language headers. For example, mockMvc.perform(get("/api/orders/1").header("Accept-Language", "fr")) and then assert that the response contains the French translation. For unit tests of services, you can mock the MessageSource or use a test configuration with a simple properties file. A common mistake is to only test with the default locale. You should test at least two non-default locales to ensure that the fallback mechanism works correctly. In our payment system, we had a suite of i18n tests that ran for every locale we supported (10+). They checked that all keys resolved to non-null strings and that placeholders were replaced correctly. We also tested edge cases like invalid locale codes (e.g., 'xx') and missing keys. For integration tests, we used TestRestTemplate with custom headers. The key is to automate this testing because manual verification is tedious and error-prone.
Performance Considerations and Caching
Message source resolution can become a performance bottleneck if not configured correctly. Each call to getMessage() potentially involves reading a properties file and parsing it. In high-throughput systems, this can add up. Spring's ReloadableResourceBundleMessageSource caches the loaded properties files for a configurable period (cacheSeconds). The default is infinite caching for ResourceBundleMessageSource, which is fine for production as long as you don't change properties files without restarting. If you need to update translations without restarting, use ReloadableResourceBundleMessageSource with a short cache timeout (e.g., 300 seconds). However, be aware that this adds file system I/O on every cache miss. In a SaaS billing system handling thousands of requests per second, we used a two-tier caching strategy: an in-memory cache (Caffeine) that stored resolved messages for the most common locales, with a TTL of 10 minutes. The MessageSource acted as the second tier. This reduced the load on the file system by 90%. Also, consider that locale resolution itself is cheap (just reading a header or session attribute), so the bottleneck is usually message resolution, not locale resolution. Another optimization is to pre-load all messages at startup into a Map<Locale, Map<String, String>> and serve from there. This avoids file I/O entirely at the cost of memory. For microservices, this is often acceptable because the number of messages is small.
The Case of the Missing French Translations
- Always test locale resolution with actual browser headers, not just unit tests.
- Use a tool like i18n-ally to validate that all message keys exist in every locale file.
- Implement a fallback that logs warnings when a key is missing, so you catch gaps early.
LocaleContextHolder.setLocale() calls in async code.curl -H "Accept-Language: fr" http://localhost:8080/api/testCheck LocaleContextHolder.getLocale() in your controller (add a debug log).| File | Command / Code | Purpose |
|---|---|---|
| MessagesConfig.java | @Configuration | Setting Up Message Sources |
| CustomMessageSource.java | public class LoggingMessageSource extends ReloadableResourceBundleMessageSource ... | What the Official Docs Won't Tell You |
| CustomLocaleResolver.java | public class DatabaseLocaleResolver implements LocaleResolver { | Locale Resolution Strategies |
| OrderController.java | @RestController | Using MessageSource in Controllers and Services |
| OrderRequest.java | public class OrderRequest { | Internationalizing Validation Messages |
| AsyncService.java | @Service | Handling Locale in Async and Reactive Contexts |
| I18nControllerTest.java | @SpringBootTest | Testing Internationalization |
| CachingMessageSource.java | @Component | Performance Considerations and Caching |
Key takeaways
Interview Questions on This Topic
Explain how LocaleContextHolder works and when you would need to use it manually.
LocaleContextHolder.setLocale() to set it and resetLocaleContext() to clean up.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't