跪拜 Guibai
← Back to the summary

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:

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 _en pack. 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)

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:

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 like jakarta.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

  1. Zero configuration for annotation approach: Default {key} messages already have 8 built-in languages; don't rush to hardcode message;
  2. Use custom keys for local overrides: When custom text is needed, prefer "{myapp.xxx}" placed in your own ValidationMessages.properties over hardcoding Chinese;
  3. Use annotation validation in Spring Boot: Automatically follows Accept-Language; don't write your own language branches;
  4. Fluent API scenarios: Use withLocale() for one-off specification; use MessageManager.setCurrentLocale() for thread-wide uniformity, and always clearCurrentLocale() after use (especially in thread pool environments);
  5. 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;
  6. Language pack files support both formats: UTF8Control supports both UTF-8 plain text and \uXXXX escape formats (current repository files are in escape format); when making changes, stay consistent with the existing format;
  7. Remember the priority: withLocale > thread-level > system default; don't let explicit settings be overridden.

Summary

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