When building a REST API for a global audience, you often want to return error messages and responses in both English and Japanese. Spring Boot ships with built-in support for i18n (internationalization): by combining MessageSource and LocaleResolver, you can switch messages based on the Accept-Language header.
This article walks through the full implementation for a REST API, from creating messages.properties to configuring a LocaleResolver and localizing @Valid validation error messages. It assumes Spring Boot 3.x (Jakarta EE).
Overview of the i18n Implementation
Spring Boot’s i18n implementation revolves around two components.
- MessageSource: manages message text for each language
- LocaleResolver: resolves the locale (language) from the request
The LocaleResolver receives the request’s Accept-Language: ja header and sets the resolved locale in LocaleContextHolder. From there, MessageSource looks up LocaleContextHolder.getLocale() and returns the message that matches that locale.
Creating messages.properties
Place the property files under src/main/resources.
src/main/resources/
├── messages.properties # デフォルト(フォールバック)
├── messages_ja.properties # 日本語
└── messages_en.properties # 英語
messages.properties is the fallback used when the locale cannot be resolved.
# messages_ja.properties
user.name.required=ユーザー名は必須です
user.not.found=ユーザーが見つかりません
# messages_en.properties
user.name.required=User name is required
user.not.found=User not found
Specify the file location and encoding in application.properties. Note that basename must be given without the extension (messages, not messages.properties). If you forget the encoding, Japanese text will be garbled, so always set it.
spring.messages.basename=messages
spring.messages.encoding=UTF-8
spring.messages.use-code-as-default-message=false
basename also accepts multiple comma-separated values. If you want to keep validation messages separate from general-purpose messages, write something like messages,validation.
When You Need a MessageSource Bean Definition
The auto-configuration is enabled by the application.properties settings alone. If you want fine-grained control over cache duration, define a ReloadableResourceBundleMessageSource Bean.
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource =
new ReloadableResourceBundleMessageSource();
messageSource.setBasenames("classpath:messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setCacheSeconds(0); // 開発中は即時リロード。本番は-1(永続キャッシュ)
return messageSource;
}
setCacheSeconds(-1) is the default value and means cache forever (files are never reloaded). If you want file changes reflected immediately during development, use setCacheSeconds(0).
If you define your own Bean with @Bean, Spring Boot’s auto-configuration (MessageSourceAutoConfiguration) is disabled. The spring.messages.* settings in application.properties will no longer be read, so specify setBasenames and setDefaultEncoding directly in code.
Configuring the LocaleResolver
For a REST API, AcceptHeaderLocaleResolver is the best fit because it keeps things stateless. CookieResolver and SessionResolver store the locale on the client or server side, which suits Web MVC but brings unnecessary state management into an API.
@Configuration
public class WebConfig {
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.JAPANESE);
resolver.setSupportedLocales(List.of(Locale.JAPANESE, Locale.ENGLISH));
return resolver;
}
}
setDefaultLocale specifies the fallback language for requests that have no Accept-Language header. Be careful not to forget this Bean registration, or Spring MVC will not resolve the Accept-Language header correctly.
Retrieving Messages with MessageSource
Locale Fallback Behavior for messages_*.properties
When the Accept-Language header is ja-JP, MessageSource searches for files in the following order.
messages_ja_JP.properties(language + country)messages_ja.properties(language only)messages.properties(default)
In other words, if you provide a language-code-only file (messages_ja.properties) to absorb regional differences, you can return the same Japanese message for Accept-Language variations such as ja-JP / ja / ja-Hira. If a language not allowed by supportedLocales arrives, it falls back to defaultLocale.
Embedding Dynamic Values with Placeholders
To embed parameters in a message, use placeholders such as {0} and {1}, and pass the values as an array in the second argument of getMessage.
# messages_ja.properties
user.not.found.with.id=ID={0} のユーザーが見つかりません
String message = messageSource.getMessage(
"user.not.found.with.id",
new Object[]{ userId },
LocaleContextHolder.getLocale()
);
Validation messages can also reference attribute values such as {min} / {max}. If you write @Size(min=3, max=20, message="{user.name.length}"), you can define the message in messages_ja.properties as user.name.length=ユーザー名は{min}文字以上{max}文字以内です.
To retrieve a message in a Service class, get the locale with LocaleContextHolder.getLocale() and pass it to MessageSource.
@Service
@RequiredArgsConstructor
public class UserService {
private final MessageSource messageSource;
public User findUser(Long id) {
return userRepository.findById(id).orElseThrow(() -> {
String message = messageSource.getMessage(
"user.not.found", null, LocaleContextHolder.getLocale()
);
return new UserNotFoundException(message);
});
}
}
If the message code does not exist, a NoSuchMessageException is thrown. If you want to return a default value instead, pass a string as the fourth argument of getMessage(code, args, defaultMessage, locale).
Localizing @Valid Validation Error Messages
If you are using spring-boot-starter-validation, ValidationAutoConfiguration automatically injects MessageSource into LocalValidatorFactoryBean. This lets you manage @Valid validation error messages in messages.properties. All you need to do is specify the key in {...} format in the annotation’s message attribute.
public class UserRequest {
@NotBlank(message = "{user.name.required}")
private String name;
}
Note that if you define your own custom ValidatorFactory Bean, this automatic integration is disabled. For how to define custom validation rules with localized message keys, see How to Create Custom Validation Annotations in Spring Boot, which covers the topic in detail.
Returning Localized Error Responses with @RestControllerAdvice
To convert a MethodArgumentNotValidException into a localized error response, handle it in a @RestControllerAdvice.
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final MessageSource messageSource;
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationError(
MethodArgumentNotValidException ex) {
Locale locale = LocaleContextHolder.getLocale();
List<String> messages = ex.getBindingResult().getFieldErrors().stream()
.map(error -> messageSource.getMessage(error, locale))
.collect(Collectors.toList());
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", messages));
}
}
Using the messageSource.getMessage(FieldError, Locale) overload resolves the message codes held by the FieldError in order. For designing an exception handler that holds up in production (adding trace IDs, extending ProblemDetail), also see Implementing a Production-Ready GlobalExceptionHandler in Spring Boot.
Testing Responses per Accept-Language with MockMvc
You can verify the language-switching behavior of localized error responses using header("Accept-Language", "...") in MockMvc.
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerI18nTest {
@Autowired MockMvc mockMvc;
@Test
void returnsJapaneseMessageWhenAcceptLanguageIsJa() throws Exception {
mockMvc.perform(get("/api/users/999").header("Accept-Language", "ja"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("ユーザーが見つかりません"));
}
@Test
void returnsEnglishMessageWhenAcceptLanguageIsEn() throws Exception {
mockMvc.perform(get("/api/users/999").header("Accept-Language", "en"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("User not found"));
}
}
Because LocaleContextHolder is ThreadLocal-based, the locale from a previous test can linger in parallel test runs. Calling LocaleContextHolder.resetLocaleContext() in @AfterEach keeps things safe.
Caveats for Native Images (GraalVM)
When running on a GraalVM Native Image with Spring Boot 3.x, messages*.properties must be included in the build as resources. The base name specified in spring.messages.basename is registered as a resource automatically by Spring Boot’s AOT hints, but if you use a custom path, explicitly register it with resources().registerPattern("messages*.properties") in RuntimeHints.
Verifying with curl
# 日本語
curl -H "Accept-Language: ja" http://localhost:8080/api/users/999
# 英語
curl -H "Accept-Language: en" http://localhost:8080/api/users/999
# ヘッダーなし(デフォルトロケール=日本語が使われる)
curl http://localhost:8080/api/users/999
// Accept-Language: ja
{ "code": "NOT_FOUND", "message": "ユーザーが見つかりません" }
// Accept-Language: en
{ "code": "NOT_FOUND", "message": "User not found" }
Summary
Prepare messages.properties, register an AcceptHeaderLocaleResolver Bean, and retrieve messages through MessageSource. That is all it takes to add clean multilingual support to a REST API. Integrating it into an existing project is not hard either, so give it a try when you need to go global.
Related Articles to Read Next
- How to Create Custom Validation Annotations in Spring Boot. Messages for custom annotations can also be localized via
messages.properties. - Implementing a Production-Ready GlobalExceptionHandler in Spring Boot. An implementation example that extends localized error responses into ProblemDetail.
- How to Implement Google Login (OAuth2) in Spring Boot. A setup worth considering alongside this one as the authentication foundation for a global-facing API.