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:

  1. messages_ja_JP.properties (language + country)
  2. messages_ja.properties (language only)
  3. 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.