ValidX Ships 8-Language Validation Errors with a Three-Tier Fallback That Never Crashes
ValidX Error Message Internationalization Complete Guide: 8 Languages, 9 Language Packs, and a Three-Level Fallback Mechanism
Introduction
Suppose your system is going global:
- A Japanese user submits an invalid phone number and gets a Chinese prompt: "手机号码格式不正确" (Invalid mobile phone number format);
- A German user enters a birthday in the wrong format and sees an error message that is a garbled mix of English and Chinese;
- Even more common: to create a separate set of error messages for the overseas version, developers fill Controllers with
if (lang == EN) return "..."— changing one message requires modifying three language branches.
These are all typical symptoms of poorly implemented "validation error message internationalization." A mature open-source validation library should have error messages that are ready-to-use out of the box and automatically switch based on the user's language — no translation needed from you, no language branch configuration required.
ValidX's multilingual support follows exactly this approach: it includes 9 language pack files covering 8 languages (Simplified Chinese, English, Japanese, Korean, French, German, Spanish, Russian; Chinese is carried by the default suffix-less pack and the _zh pack, two files), the annotation approach and the fluent API share a single message system across both paths, and it provides a three-level fallback to guarantee readable error messages in any language environment. This article dissects this mechanism layer by layer against the source code and provides ready-to-copy practical usage.
Note: All implementation details in this article have been verified one by one against the ValidX v1.2.0 source code (
MessageManager,ValidX,ValidationMessages_*.properties); example code and test cases are quoted from the project's test directory and can be reproduced independently.
1. Internationalization Mechanism Overview
1.1 Supported Languages
| Language | Resource File | Notes |
|---|---|---|
| Simplified Chinese | ValidationMessages_zh.properties |
Chinese pack |
| Simplified Chinese (default) | ValidationMessages.properties |
Suffix-less fallback file, content is Chinese |
| English | ValidationMessages_en.properties |
Fallback language (fallback target) |
| Japanese | ValidationMessages_ja.properties |
|
| Korean | ValidationMessages_ko.properties |
|
| French | ValidationMessages_fr.properties |
|
| German | ValidationMessages_de.properties |
|
| Spanish | ValidationMessages_es.properties |
|
| Russian | ValidationMessages_ru.properties |
Fun fact:
ValidationMessages.properties(without a language suffix), though named "default," actually contains Chinese; English is a separate_enpack. This means "uncovered language environments will eventually fall back to Chinese," and only after "fallback upon fallback upon fallback" do you reach English.
1.2 Two Consumption Paths, One Message System
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Annotation Validation │ │ Fluent API (ValidX.init()) │
│ (@Email, etc.) │ │ withLocale(locale) │
│ message()="{fully-qualified │ │ │
│ key}" │ │ │
└──────────────┬───────────────┘ └──────────────┬───────────────┘
│ │
▼ ▼
Hibernate Validator MessageManager
ResourceBundleMessageInterpolator .getMessage(key, locale)
(parses {key}, resolves by ├─ Language pack cache ConcurrentHashMap
thread Locale) ├─ UTF8Control forces UTF-8 reading
│ ├─ Three-level fallback: specified
│ │ language → English → key
└──────────┬───────────┼─ ThreadLocal thread-level language
│ │ switching
▼
ValidationMessages_*.properties (9 language packs)
- Annotation approach: Default messages are written as
{key}curly-brace placeholders, handed to the standard Bean ValidationMessageInterpolatorfor resolution and replacement; - Fluent approach: Directly calls the self-developed
MessageManager.getMessage(key, locale)to generate text in the corresponding language.
Both paths ultimately fetch messages from the same set of properties language packs, so there is no divergence where "annotations are Chinese but the fluent API becomes English."
1.3 Message Key Naming Convention
Message keys use fully qualified names, with the prefix being the package name, offering good readability and naturally avoiding conflicts:
| Category | Key Format | Example |
|---|---|---|
| Annotation messages | io.github.vipxieliang.validx.annotation.<name> |
...annotation.chinese.idcard |
| Validator messages | io.github.vipxieliang.validx.validator.<name> |
...validator.date.pattern.contains.time |
| General value messages | io.github.vipxieliang.validx.value.<name> |
...value.null |
2. Language Packs and Message Comparison
2.1 Chinese-English Comparison Examples
ValidationMessages_en.properties:
io.github.vipxieliang.validx.value.null=Value cannot be null
io.github.vipxieliang.validx.annotation.chinese.idcard=Invalid Chinese ID card number
io.github.vipxieliang.validx.annotation.chinese.phone=Invalid mobile phone number format
io.github.vipxieliang.validx.annotation.email=Invalid email address format
io.github.vipxieliang.validx.annotation.date.format=Invalid date format
ValidationMessages_zh.properties:
io.github.vipxieliang.validx.value.null=值不能为空
io.github.vipxieliang.validx.annotation.chinese.idcard=身份证号码不正确
io.github.vipxieliang.validx.annotation.chinese.phone=手机号码格式不正确
io.github.vipxieliang.validx.annotation.email=邮箱地址格式不正确
io.github.vipxieliang.validx.annotation.date.format=日期格式不正确
2.2 What Annotation Default Messages Look Like
The message() default value for each annotation is a {fully-qualified key}:
// ChineseIdCard.java
String message() default "{io.github.vipxieliang.validx.annotation.chinese.idcard}";
// Email.java
String message() default "{io.github.vipxieliang.validx.annotation.email}";
// Date.java
String message() default "{io.github.vipxieliang.validx.annotation.date.format}";
The keys in curly braces correspond one-to-one with the keys in the properties files. This is the source of "ready-to-use out of the box": just write @Email, and the error message is already available in 8 languages.
3. Internationalization via the Annotation Approach
3.1 Without Writing message, Automatically Follows the Language Environment
public class UserDTO {
@Email
private String email;
@ChineseIdCard
private String idCard;
}
When validation fails in a Chinese system environment, you get "邮箱地址格式不正确"; in an English system environment, you get "Invalid email address format." Same line of code, zero configuration, messages switch automatically.
3.2 Explicitly Specifying Language (Hibernate Validator Configuration)
Note: new ResourceBundleMessageInterpolator() only resolves {key} into specific text; the language is still determined by the thread / default Locale. Configuring it alone cannot "fix" the language. To fix the language, the correct approach is to set the default Locale in conjunction:
Locale.setDefault(Locale.ENGLISH); // Affects the entire JVM's default language (use with caution in production, remember to restore)
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator englishValidator = factory.getValidator();
Set<ConstraintViolation<UserDTO>> violations = englishValidator.validate(dto);
A more recommended approach: configure a fixed LocaleResolver in Spring, or use ValidX's fluent API withLocale() / thread-level MessageManager.setCurrentLocale() (see §4 below), which have a more controllable scope.
3.3 Spring Boot: Automatically Follows Accept-Language
In Spring Boot, validation is managed by LocalValidatorFactoryBean. Hibernate Validator's message interpolation reads the current thread's Locale, and Spring's LocaleResolver (default AcceptHeaderLocaleResolver) parses the request header Accept-Language to set the thread Locale. Therefore:
- A Chinese user's browser sends
Accept-Language: zh-CN→ error messages in Chinese; - A Japanese user sends
Accept-Language: ja→ error messages in Japanese; - No need to write any language judgment code in the Controller.
3.4 Local Override: Writing Your Own message
When you don't want the default message, hardcode or use a custom key; the override has the highest priority:
public class UserDTO {
// Hardcoded override
@Email(message = "邮箱格式不对,请检查")
private String email;
// Custom key, place in your own ValidationMessages.properties
@Email(message = "{myapp.msg.email}")
private String email2;
}
Standard Bean Validation annotations (
@NotBlank,@Size, etc.) also use this mechanism for their messages, with keys likejakarta.validation.constraints.NotBlank.message, etc. Hibernate Validator comes with English defaults.
4. Internationalization via the Fluent API
4.1 Method 1: Explicit Specification with withLocale()
import java.util.Locale;
// System default language
ValidX chain1 = ValidX.init().isEmail("invalid-email");
// Explicit Chinese
ValidX chain2 = ValidX.init()
.withLocale(Locale.SIMPLIFIED_CHINESE)
.isEmail("invalid-email");
// Explicit English
ValidX chain3 = ValidX.init()
.withLocale(Locale.ENGLISH)
.isEmail("invalid-email");
System.out.println(chain3.getErrorMessage()); // Invalid email address format
4.2 Method 2: Thread-Level Language Environment (Automatic Switching)
If you don't want to use withLocale every time, you can use MessageManager to set the current thread's language; all subsequent validations on that thread will automatically follow:
import io.github.vipxieliang.validx.i18n.MessageManager;
// Set current thread language to Chinese (affects all validations on this thread)
MessageManager.setCurrentLocale(Locale.SIMPLIFIED_CHINESE);
ValidX chain = ValidX.init().isEmail("invalid-email");
// chain.getErrorMessage() → 邮箱地址格式不正确
// Clean up after use to prevent language mixing due to thread pool reuse
MessageManager.clearCurrentLocale();
4.3 Locale Priority: Explicit > Thread-Level > System Default
The logic of ValidX.getLocale() in the source code:
private Locale getLocale() {
if (locale != null) return locale; // 1. Explicitly specified via withLocale
return MessageManager.getCurrentLocale(); // 2. Thread-level → 3. System default
}
| Priority | Setting Method | Scope |
|---|---|---|
| 1 | withLocale(Locale) |
Single validation chain |
| 2 | MessageManager.setCurrentLocale(Locale) |
All validations on the current thread |
| 3 | Locale.getDefault() |
Process-wide default |
4.4 Error Message Retrieval API
ValidX validator = ValidX.init()
.field("邮箱").isEmail("invalid-email")
.field("电话").isPhoneNumber("123");
validator.passed(); // false, whether all passed
validator.isValid(); // false, same as above
validator.getErrors(); // ["邮箱: Invalid email address format", ...], error list (copy)
validator.getErrorMessage(); // "邮箱: Invalid email address format, ...", comma-joined
5. MessageManager Core Mechanism Dissection
MessageManager is the hub of fluent API internationalization (src/main/java/io/github/vipxieliang/validx/i18n/MessageManager.java). Four key designs:
5.1 Three-Level Fallback: Specified Language → English → Key Itself
public static String getMessage(String key, Locale locale) {
try {
ResourceBundle bundle = BUNDLES.computeIfAbsent(locale,
l -> ResourceBundle.getBundle(BASE_NAME, l, UTF8_CONTROL));
return bundle.getString(key);
} catch (MissingResourceException e) {
try {
return DEFAULT_BUNDLE.getString(key); // Level 2: English fallback
} catch (MissingResourceException ex) {
return key; // Level 3: Return the key itself
}
}
}
| Level | Condition | Result |
|---|---|---|
| 1 | Specified language pack has the key | Message in the corresponding language |
| 2 | Specified language pack missing → English pack | English message |
| 3 | English pack also missing | Return the key itself (no exception thrown, no null pointer) |
This means: when a new validation rule is added but a language pack is missed, the worst case is that the user sees the key string, not a system crash.
5.2 UTF8Control: Forces UTF-8 Reading
Java's PropertyResourceBundle reads properties in ISO-8859-1 by default, causing Chinese to appear garbled. MessageManager's inner class UTF8Control overrides newBundle(), using InputStreamReader(stream, StandardCharsets.UTF_8) to force UTF-8 loading:
return new PropertyResourceBundle(new InputStreamReader(stream, StandardCharsets.UTF_8));
Therefore, language pack files can be saved as UTF-8 plain text for Chinese/Japanese/Korean (UTF8Control can read them directly), or can use Java properties' \uXXXX escape format (the current repository's 9 language pack files use this format) — UTF8Control can correctly load both formats.
5.3 Detail: Chinese Does Not Fall Back
UTF8Control also overrides getFallbackLocale(): returns null (no fallback) for the Chinese locale. This prevents the scenario where "in a Chinese system environment, if the zh pack is missing a key, it falls back to the default English pack" — ensuring that the Chinese environment always gets Chinese text (or the level-3 fallback key).
5.4 Caching: ConcurrentHashMap
private static final Map<Locale, ResourceBundle> BUNDLES = new ConcurrentHashMap<>();
// ...
BUNDLES.computeIfAbsent(locale, l -> ResourceBundle.getBundle(BASE_NAME, l, UTF8_CONTROL));
Each Locale's language pack is loaded only once; subsequent hits go directly to the cache. Multilingual validation incurs no repeated I/O overhead. DEFAULT_BUNDLE (English) is preloaded at class loading time, ensuring the fallback path is always available.
5.5 Dynamic Parameter Replacement
MessageManager is only responsible for fetching static text; messages with parameters are replaced by the caller after fetching the text, for example validateAge:
String message = MessageManager.getMessage("...annotation.age", locale);
message = message.replace("{min}", String.valueOf(minAge))
.replace("{max}", String.valueOf(maxAge));
6. Practical Scenarios
6.1 Web API: Return Errors Based on User Language
@RestController
public class RegisterController {
@PostMapping("/register")
public Result<Void> register(@Valid @RequestBody RegisterDTO dto) {
return Result.success();
}
}
Annotation approach: Spring Boot automatically switches based on Accept-Language, no code needed.
Fluent approach (e.g., when validating dynamic Map data): Read the user's language at the request entry point and bind it to the current thread:
@Component
public class RequestLocaleFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String lang = req.getHeader("Accept-Language");
if (lang != null && lang.startsWith("zh")) {
MessageManager.setCurrentLocale(Locale.SIMPLIFIED_CHINESE);
} else if (lang != null && lang.startsWith("ja")) {
MessageManager.setCurrentLocale(Locale.JAPANESE);
} else {
MessageManager.setCurrentLocale(Locale.ENGLISH);
}
try {
chain.doFilter(request, response);
} finally {
MessageManager.clearCurrentLocale(); // Clean up to prevent language mixing in thread pool
}
}
}
6.2 Pure Java Utility Class: No Spring Dependency
public class CertNoCheckUtil {
public static String checkCertNo(String certNo, Locale locale) {
ValidX validator = ValidX.init()
.withLocale(locale)
.field("证件号").isChineseIdCard(certNo);
return validator.isValid() ? "OK" : validator.getErrorMessage();
}
}
6.3 Frontend Receives Displayable Errors
// Backend returns structured errors, frontend displays directly
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<List<String>> handleValid(MethodArgumentNotValidException e) {
List<String> msgs = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage) // Already in the current language
.collect(Collectors.toList());
return Result.fail(400, msgs);
}
7. Test Assurance: i18n Behavior Is Locked Down
The project's test directory (src/test/java/.../i18n/) provides complete locking of internationalization behavior and serves as the verification basis for all conclusions in this article:
7.1 Language Pack Completeness Tests
DateValidatorI18nTest and DateTimeValidatorI18nTest assert: validator-level keys exist and are non-empty in all 8 language packs, and Chinese/English/Japanese messages are different from each other — ensuring each language pack is a "real translation," not just all falling back to English as filler.
7.2 Locale Priority Tests
AutoLocaleValidationChainTest covers three scenarios:
| Scenario | Setting | Result |
|---|---|---|
| No Locale set | — | Uses system default language |
| Thread-level set | setCurrentLocale(zh) |
Chinese message |
| Explicit overrides thread-level | setCurrentLocale(zh) + withLocale(en) |
English (explicit takes priority) |
8. Best Practice Checklist
- Zero configuration for annotation approach: Default
{key}messages already have 8 built-in languages; don't rush to hardcodemessage; - Use custom keys for local overrides: When custom text is needed, prefer
"{myapp.xxx}"placed in your ownValidationMessages.propertiesover hardcoding Chinese; - Use annotation validation in Spring Boot: Automatically follows
Accept-Language; don't write your own language branches; - Fluent API scenarios: Use
withLocale()for one-off specification; useMessageManager.setCurrentLocale()for thread-wide uniformity, and alwaysclearCurrentLocale()after use (especially in thread pool environments); - Don't miss keys when adding new language packs: The three-level fallback guarantees no crash, but missing keys will make users see key strings; before going live, use tests to iterate over all keys × all languages;
- Language pack files support both formats:
UTF8Controlsupports both UTF-8 plain text and\uXXXXescape formats (current repository files are in escape format); when making changes, stay consistent with the existing format; - Remember the priority:
withLocale> thread-level > system default; don't let explicit settings be overridden.
Summary
- ValidX includes 9 language pack files, 8 languages (Chinese default, English fallback + Japanese/Korean/French/German/Spanish/Russian); annotations and fluent API share a single message system;
- The annotation approach resolves via
{fully-qualified key}+ Bean Validation'sMessageInterpolator; under Spring Boot, it automatically followsAccept-Language; - The fluent API is implemented via
MessageManager, withwithLocale()for explicit specification and thread-levelsetCurrentLocale()for automatic switching; - The core mechanism has four parts: three-level fallback (specified language → English → key), UTF8Control (forces UTF-8), language pack caching (ConcurrentHashMap), Chinese no-fallback (prevents Chinese environment from getting English);
- The hallmark of well-done internationalization: users always see readable error messages in their own language, developers never need to write language branches.
ValidX is a Java validation library based on the Jakarta Bean Validation specification, featuring dual-mode annotation and fluent API, with 100+ built-in validation rules. Project URL:
github.com/vipxieliang/validx