When building REST APIs for a global audience, you often want to serve error messages and responses in both English and Japanese. Spring Boot has built-in support for i18n (internationalization), and by combining MessageSource and LocaleResolver, you can switch messages based on the Accept-Language header.
This article walks through everything from creating messages.properties files to configuring LocaleResolver, and localizing @Valid validation error messages — a complete implementation guide for REST APIs. It assumes Spring Boot 3.x (Jakarta EE).
Overview of i18n Implementation
Spring Boot’s i18n implementation centers on two components:
- MessageSource: Manages message text for each language
- LocaleResolver: Resolves the locale (language) from the request
The LocaleResolver receives the Accept-Language: ja header from the request and sets the resolved locale into LocaleContextHolder. Then MessageSource references LocaleContextHolder.getLocale() and returns messages matching that locale.
Creating messages.properties
Place property files under src/main/resources.
src/main/resources/
├── messages.properties # Default (fallback)
├── messages_ja.properties # Japanese
└── messages_en.properties # English
messages.properties is the fallback used when no locale can 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
In application.properties, specify the file location and encoding. The basename should be specified without the extension (it’s messages, not messages.properties). Be sure to set the encoding — forgetting it will result in garbled Japanese text.
spring.messages.basename=messages
spring.messages.encoding=UTF-8
spring.messages.use-code-as-default-message=false
basename can also specify multiple values comma-separated. If you want to separate validation and general messages, write something like messages,validation.
When You Need a MessageSource Bean Definition
Auto-configuration is enabled just by setting application.properties. If you need 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); // Immediate reload during development. Use -1 (permanent cache) in production
return messageSource;
}
setCacheSeconds(-1) is the default value and means permanent cache (no file reload). If you want file changes reflected immediately during development, use setCacheSeconds(0).
When you define your own @Bean, Spring Boot’s auto-configuration (MessageSourceAutoConfiguration) is disabled. The spring.messages.* settings in application.properties are no longer referenced, so specify setBasenames and setDefaultEncoding directly in code.
Configuring LocaleResolver
For REST APIs, AcceptHeaderLocaleResolver is ideal because it keeps things stateless. CookieResolver and SessionResolver maintain locale state on the client or server, which suits Web MVC but introduces unnecessary state management for APIs.
@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 without an Accept-Language header. Without this bean registration, Spring MVC won’t correctly resolve the Accept-Language header, so be careful.
Retrieving Messages with MessageSource
Locale Fallback Behavior of messages_*.properties
When the Accept-Language header is ja-JP, MessageSource searches files in the following order:
messages_ja_JP.properties(language + country)messages_ja.properties(language only)messages.properties(default)
In other words, by providing a language-only file (messages_ja.properties) to absorb regional differences, you can return the same Japanese message regardless of Accept-Language variations like ja-JP / ja / ja-Hira. If a language not allowed in supportedLocales is received, it falls back to defaultLocale.
Embedding Dynamic Values with Placeholders
To embed parameters in messages, use placeholders like {0}, {1}, and pass 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()
);
You can also reference attribute values like {min} / {max} in validation messages. If you write @Size(min=3, max=20, message="{user.name.length}"), you can define user.name.length=ユーザー名は{min}文字以上{max}文字以内です in messages_ja.properties.
To retrieve messages 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 doesn’t exist, a NoSuchMessageException is thrown. If you want to return a default value, pass a string as the fourth argument of getMessage(code, args, defaultMessage, locale).
Internationalizing @Valid Validation Error Messages
If you’re using spring-boot-starter-validation, ValidationAutoConfiguration automatically injects MessageSource into LocalValidatorFactoryBean. This means @Valid validation error messages can be managed through messages.properties. Just specify the key in {...} format in the annotation’s message attribute.
public class UserRequest {
@NotBlank(message = "{user.name.required}")
private String name;
}
Note that this automatic integration is disabled if you define your own custom ValidatorFactory bean. For details on defining custom validation rules with i18n-capable message keys, see How to Create Custom Validation Annotations in Spring Boot.
Returning i18n Error Responses with @RestControllerAdvice
To convert MethodArgumentNotValidException into an internationalized error response, handle it with @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, the message codes held by the FieldError are resolved in order. For designing exception handlers ready for production (adding traceID, extending ProblemDetail), also see Implementing Spring Boot’s GlobalExceptionHandler for Production.
Testing Responses by Accept-Language with MockMvc
You can verify the language-switching behavior of internationalized error responses using MockMvc’s header("Accept-Language", "...").
@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"));
}
}
Since LocaleContextHolder is ThreadLocal-based, the locale from a previous test may persist in parallel test runs. Calling LocaleContextHolder.resetLocaleContext() in @AfterEach is a safe practice.
Notes for Native Image (GraalVM)
When running on Spring Boot 3.x GraalVM Native Image, messages*.properties files need to be included in the build as resources. Base names specified by spring.messages.basename are automatically registered as resources by Spring Boot’s AOT hints, but if you use a custom path, explicitly register it with RuntimeHints using resources().registerPattern("messages*.properties").
Verifying with curl
# Japanese
curl -H "Accept-Language: ja" http://localhost:8080/api/users/999
# English
curl -H "Accept-Language: en" http://localhost:8080/api/users/999
# No header (default locale = Japanese is used)
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
By preparing messages.properties, registering an AcceptHeaderLocaleResolver bean, and retrieving messages via MessageSource, you can simply build i18n support for your REST API. It’s not hard to integrate into existing projects 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 — Custom annotation messages can also be internationalized with
messages.properties. - Implementing Spring Boot’s GlobalExceptionHandler for Production — Implementation example for extending internationalized error responses into ProblemDetail.
- How to Implement Google Login (OAuth2) in Spring Boot — A configuration worth considering as an authentication foundation for global APIs.