This package extends the AbstractMessageSource and provides the MessageSource interface.
Important
Breaking change (0.9.0): nextCatalog() has been removed from the CatalogInterface.
The functionality itself has not changed.
- Dependency
- Packages that use the catalog as a base package
- CatalogMessageSource Configuration
- Message formatting
- Resource classes
- Javadoc
- License
<dependency>
<groupId>io.github.alaugks</groupId>
<artifactId>spring-messagesource-catalog</artifactId>
<version>0.9.1</version>
</dependency>implementation group: 'io.github.alaugks', name: 'spring-messagesource-catalog', version: '0.9.1'
- spring-messagesource-xliff: Xliff MessageSource for Spring
- spring-messagesource-json: JSON MessageSource for Spring
- spring-messagesource-db-example: Example custom Spring MessageSource from database
| Method | Default | Description |
|---|---|---|
builder(CatalogInterface catalogSource, Locale defaultLocale) |
— | Entry point.catalogSource is the initial source.defaultLocale is the locale to fall back to when a code cannot be resolved for the requested locale. |
builder(List<TransUnitInterface> transUnits, Locale defaultLocale) |
— | Entry point (alternative).transUnits are used as the initial source, wrapped in a TransUnitsCatalog.defaultLocale is the locale to fall back to when a code cannot be resolved for the requested locale. |
addSource(CatalogInterface source) |
— | Appends another source. Sources are aggregated additively at build(); their lazy resolveTransUnit lookups are consulted in the order they were added. |
addSource(List<TransUnitInterface> transUnits) |
— | Convenience overload of addSource that wraps the trans units in a TransUnitsCatalog. |
defaultDomain(String defaultDomain) |
messages |
The default domain. Codes stored under this domain are also accessible without the domain prefix; codes under any other domain require the <domain>.<code> prefix. |
enableICU4j() |
disabled | Format messages with ICU4J's com.ibm.icu.text.MessageFormat instead of the default java.text.MessageFormat, adding named arguments and ICU plural/select patterns. See Message formatting for details and examples. |
parentMessageSource(MessageSource parentMessageSource) |
— | Sets a parent MessageSource to delegate to. When a code cannot be resolved in the catalog, the lookup falls back to the parent source. |
If the String domain argument is not set, the default is the messages domain.
TransUnit(Locale locale, String code, String value);
TransUnit(Locale locale, String code, String value, String domain);import io.github.alaugks.spring.messagesource.catalog.CatalogMessageSourceBuilder;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnit;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnitInterface;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MessageConfig {
private final List<TransUnitInterface> transUnits = new ArrayList<>() {{
// en
add(new TransUnit(Locale.forLanguageTag("en"), "headline", "Headline"));
add(new TransUnit(Locale.forLanguageTag("en"), "postcode", "Postcode"));
add(new TransUnit(Locale.forLanguageTag("en"), "headline", "Payment", "payment"));
add(new TransUnit(Locale.forLanguageTag("en"), "expiry_date", "Expiry date", "payment"));
// en-US
add(new TransUnit(Locale.forLanguageTag("en-US"), "postcode", "Zip code"));
add(new TransUnit(Locale.forLanguageTag("en-US"), "expiry_date", "Expiration date", "payment"));
// de
add(new TransUnit(Locale.forLanguageTag("de"), "headline", "Überschrift"));
add(new TransUnit(Locale.forLanguageTag("de"), "postcode", "Postleitzahl"));
add(new TransUnit(Locale.forLanguageTag("de"), "headline", "Zahlung", "payment"));
add(new TransUnit(Locale.forLanguageTag("de"), "expiry_date", "Ablaufdatum", "payment"));
}};
@Bean
public MessageSource messageSource() {
return CatalogMessageSourceBuilder
.builder(this.transUnits, Locale.forLanguageTag("en"))
.build();
}
}The behaviour of resolving the target value based on the code is equivalent to the ResourceBundleMessageSource or ReloadableResourceBundleMessageSource.
| code | en | en-US | de | jp*** |
|---|---|---|---|---|
| headline* messages.headline |
Headline | Headline** | Überschrift | Headline |
| postcode* messages.postcode |
Postcode | Zip code | Postleitzahl | Postcode |
| payment.headline | Payment | Payment** | Zahlung | Payment |
| payment.expiry_date | Expiry date | Expiration date | Ablaufdatum | Expiry date |
*Default domain is
messages.**Example of a fallback from Language_Region (
en-US) to Language (en). Theiddoes not exist inen-US, so it tries to select the translation with localeen.***There is no translation for Japanese (
jp). The default locale transUnits (en) are selected.
A custom source typically extends AbstractCatalog, which provides no-op defaults for the two data methods. A source then chooses one of two patterns:
- Eager — override
getTransUnits(). The list is read once at construction time and merged into the catalog map. - Lazy — override
resolveTransUnit(code, locale)to return aTransUnitInterface. Called only when the catalog map has no entry for the requested key. The returned trans unit is cached in the map (using itsdomain), so subsequent lookups for the same key hit the in-memory map.
When the catalog map cannot resolve a key, the lazy path consults the configured sources in the order they were added. For each source:
- The source inspects the incoming
code(andlocale). - If it can answer, it returns a
TransUnit, the lookup stops, and the result is cached in the in-memory catalog map. - If it cannot answer, it returns
nulland the next source is tried. - If no source claims the request, the message ends up unresolved.
A common opt-out strategy is to gate on the requested domain — a source that owns "glossary" returns null for anything that doesn't start with "glossary.". The LazyCatalog example below shows that pattern.
The three examples below illustrate the patterns. They are then combined in Combining multiple sources.
The trans units are passed in via the constructor.
import io.github.alaugks.spring.messagesource.catalog.catalog.AbstractCatalog;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnitInterface;
import java.util.List;
public class MyStaticCatalog extends AbstractCatalog {
private final List<TransUnitInterface> transUnits;
public MyStaticCatalog(List<TransUnitInterface> transUnits) {
this.transUnits = transUnits;
}
@Override
public List<TransUnitInterface> getTransUnits() {
return this.transUnits;
}
}The trans units are loaded from a database table at construction time and exposed via getTransUnits().
import io.github.alaugks.spring.messagesource.catalog.catalog.AbstractCatalog;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnit;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnitInterface;
import java.util.ArrayList;
import java.util.List;
public class GlossaryDbCatalog extends AbstractCatalog {
private static final String DOMAIN = "glossary";
private final GlossaryRepository glossaryRepository;
public GlossaryDbCatalog(GlossaryRepository glossaryRepository) {
this.glossaryRepository = glossaryRepository;
}
@Override
public List<TransUnitInterface> getTransUnits() {
List<TransUnitInterface> transUnits = new ArrayList<>();
this.glossaryRepository.findAll().forEach(row -> transUnits.add(
new TransUnit(row.getLocale(), row.getCode(), row.getValue(), DOMAIN)
));
return transUnits;
}
}The trans units are not loaded up front. resolveTransUnit(...) is called only on a map miss; the resolved value is then cached in the catalog so that subsequent lookups for the same key hit the in-memory map.
Useful when the underlying source is large enough that eager loading is impractical (e.g. a glossary table with hundreds of thousands of rows, or an external API).
The code argument is passed through as-is from the caller, so it may be given without a domain prefix (e.g. "headline") or with one (e.g. "lazyglossary.headline"). A source that owns a specific domain checks the prefix and strips it before looking up its backend; the returned TransUnit then carries the code without the prefix and the source's own domain, so the cache entry lives at "<domain>.<code>".
import io.github.alaugks.spring.messagesource.catalog.catalog.AbstractCatalog;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnit;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnitInterface;
import java.util.Locale;
public class LazyCatalog extends AbstractCatalog {
private static final String DOMAIN = "lazyglossary";
private static final String PREFIX = DOMAIN + ".";
private final LazyCatalogRepository lazyCatalogRepository;
public LazyCatalog(LazyCatalogRepository lazyCatalogRepository) {
this.lazyCatalogRepository = lazyCatalogRepository;
}
@Override
public TransUnitInterface resolveTransUnit(String code, Locale locale) {
// code may be "<code>" (default domain) or "<domain>.<code>".
// This source owns only DOMAIN; for anything it cannot answer it
// returns null, and the builder consults the next source.
if (code.startsWith(PREFIX)) {
String localCode = code.substring(PREFIX.length());
String value = this.lazyCatalogRepository.findByCodeAndLocale(localCode, locale);
if (value != null) {
return new TransUnit(locale, localCode, value, DOMAIN);
}
}
return null;
}
}Several sources can be combined directly on the CatalogMessageSourceBuilder. The example below combines the three custom catalogs above.
Sources are added in order with addSource(...). On a map miss, the builder consults the sources in this order and the first non-null resolveTransUnit result wins; a source that cannot answer simply returns null. Eager sources, by contrast, are aggregated up front into the catalog map, where the first source wins on key conflicts (putIfAbsent semantics).
import io.github.alaugks.spring.messagesource.catalog.CatalogMessageSourceBuilder;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnit;
import io.github.alaugks.spring.messagesource.catalog.records.TransUnitInterface;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MessageConfig {
private final GlossaryRepository glossaryRepository;
private final LazyCatalogRepository lazyCatalogRepository;
public MessageConfig(
GlossaryRepository glossaryRepository,
LazyCatalogRepository lazyCatalogRepository
) {
this.glossaryRepository = glossaryRepository;
this.lazyCatalogRepository = lazyCatalogRepository;
}
@Bean
public MessageSource messageSource() {
List<TransUnitInterface> staticTransUnits = new ArrayList<>() {{
add(new TransUnit(Locale.forLanguageTag("en"), "headline", "Headline"));
add(new TransUnit(Locale.forLanguageTag("de"), "headline", "Überschrift"));
}};
return CatalogMessageSourceBuilder
.builder(new MyStaticCatalog(staticTransUnits), Locale.forLanguageTag("en"))
.addSource(new GlossaryDbCatalog(this.glossaryRepository))
.addSource(new LazyCatalog(this.lazyCatalogRepository))
.build();
}
}A resolved value is formatted before it is returned, applying the arguments passed to getMessage(...) to
the message pattern. Two formatters are available: the default java.text.MessageFormat and, once
enableICU4j() is set, com.ibm.icu.text.MessageFormat.
Important
Named arguments and ICU plural/select patterns (e.g. {count, plural, …}) cannot be resolved by the default java.text.MessageFormat and fail at getMessage() time. To use them you must enable ICU4J via enableICU4j().
ICU4J is the com.ibm.icu:icu4j dependency, which is shipped transitively with this package — no extra dependency is required. Its com.ibm.icu.text.MessageFormat is a syntax superset of java.text.MessageFormat, so existing numeric-index patterns keep working.
Note that the two are not fully output-compatible: ICU4J uses Unicode CLDR locale data, so the formatted result for a given locale can differ from the JDK's — for example the decimal and grouping separators in numbers (. vs ,). Verify locale-sensitive output after enabling ICU4J.
Without enableICU4j(), values are formatted with java.text.MessageFormat —
the same formatter Spring's ResourceBundleMessageSource uses. It only understands numeric argument
indices ({0}, {1}, …), passed positionally as an Object[]. Numbers are formatted locale-aware
(grouping separators differ per locale).
new TransUnit(Locale.forLanguageTag("en"), "files", "There are {0,number,integer} files.");
new TransUnit(Locale.forLanguageTag("de"), "files", "Es gibt {0,number,integer} Dateien.");
messageSource.getMessage(
"files",
new Object[] { 10000 },
Locale.forLanguageTag("de")
);Result: Es gibt 10.000 Dateien.
java.text.MessageFormat has no plural keyword, but its choice sub-format covers the common plural
case by mapping numeric ranges to variants. A limit followed by # matches values from that number
up, < matches values strictly greater; each |-separated case is itself a pattern, so reference the
number again as {0,number,integer} to insert it.
new TransUnit(
Locale.forLanguageTag("en"),
"file_deleted",
"{0,choice,0#You deleted no files.|1#You deleted one file.|1<You deleted {0,number,integer} files.}"
);
new TransUnit(
Locale.forLanguageTag("de"),
"file_deleted",
"{0,choice,0#Sie haben keine Dateien gelöscht.|1#Sie haben eine Datei gelöscht.|1<Sie haben {0,number,integer} Dateien gelöscht.}"
);
messageSource.getMessage(
"file_deleted",
new Object[] { 1000 },
Locale.forLanguageTag("de")
);Result: Sie haben 1.000 Dateien gelöscht.
java.text.MessageFormat has no select construct: it can only branch on numbers via choice, not
on arbitrary string values, so value-based choices such as grammatical gender cannot be expressed. For
string-based select (and CLDR plural categories like few/many), enable
ICU4J.
Enable ICU4J on the builder to format with ICU4J's MessageFormat:
@Bean
public MessageSource messageSource() {
return CatalogMessageSourceBuilder
.builder(this.transUnits, Locale.forLanguageTag("en"))
.enableICU4j() // required for named arguments and plural/select
.build();
}With ICU4J enabled, patterns can use named arguments and the ICU plural/select constructs. Named
arguments are passed as a single Map (not as positional {0} / {1} arguments); the catalog detects a lone
Map argument and formats the pattern with it.
A plural switch selects a variant based on a number. Each case is either an exact number — matched as
=N — or a CLDR plural keyword (zero, one, two, few, many, other) that the locale's plural
rules select from the number. The number itself is inserted into a case by referencing the argument name,
{count}.
Which keywords a language uses, and how each number maps to one, is defined per language in the Unicode CLDR Language Plural Rules.
new TransUnit(
Locale.forLanguageTag("en"),
"file_deleted",
"{count, plural, =0 {You deleted no files.} =1 {You deleted one file.} other {You deleted {count} files.}}"
);
new TransUnit(
Locale.forLanguageTag("de"),
"file_deleted",
"{count, plural, =0 {Sie haben keine Dateien gelöscht.} =1 {Sie haben eine Datei gelöscht.} other {Sie haben {count} Dateien gelöscht.}}"
);
messageSource.getMessage(
"file_deleted",
new Object[] { Map.of("count", 1000) },
Locale.forLanguageTag("de")
);Result: Sie haben 1.000 Dateien gelöscht.
A select switch picks the case whose value matches the argument. Use it for any value-based choice such as
grammatical gender; a final other case acts as the fallback.
The apostrophe is a quoting metacharacter in ICU MessageFormat. A literal apostrophe must be written as two
single quotes (''), e.g. Wie geht es ihr? resolves to Wie geht's ihr?.
new TransUnit(
Locale.forLanguageTag("en"),
"greeting",
"{recipient_gender, select, feminine {How is she?} masculine {How is he?} other {How are they?}}"
);
new TransUnit(
Locale.forLanguageTag("de"),
"greeting",
"{recipient_gender, select, feminine {Wie geht es ihr?} masculine {Wie geht es ihm?} other {Wie geht es ihnen?}}"
);
messageSource.getMessage(
"greeting",
new Object[] { Map.of("recipient_gender", "feminine") },
Locale.forLanguageTag("de")
);Result: Wie geht's ihr?
The resources package loads translation files from the classpath or filesystem so a format-specific parser
can turn them into TransUnits. It is the shared file-loading stage used by the sibling packages
spring-messagesource-xliff and
spring-messagesource-json. Applications that consume
the catalog through one of those packages do not call these classes directly, but a custom file-based source
can reuse them.
The stages are: a LocationPattern describes where to look, ResourcesLoader resolves and reads the
matching files, ResourcesFileNameParser derives the domain and locale from each file name, and the result
is a list of TranslationFile records (domain + locale + raw bytes).
Holds one or more Spring resource location patterns
(e.g. classpath:/translations/*) that tell ResourcesLoader where to look. Accepts either a single pattern
or a list; duplicate patterns are eliminated.
new LocationPattern("classpath:/translations/*");
new LocationPattern(List.of(
"classpath:/translations/*",
"classpath:/translations_extra/*"
));Resolves every configured pattern, keeps the resources whose file extension is in the allow-list and
whose file name parses, and reads each one into a TranslationFile. File names that do not match the
naming convention are skipped. Resource resolution or read errors are wrapped in a
CatalogMessageSourceRuntimeException.
ResourcesLoader loader = new ResourcesLoader(
Locale.forLanguageTag("en"), // default locale for files without a locale part
new LocationPattern("classpath:/translations/*"),
List.of("ext") // accepted file extensions (without leading dot)
);
List<TranslationFile> files = loader.getTranslationFiles();ResourcesFileNameParser derives the domain and locale from each file name. Matching is case-insensitive
and the file extension is ignored — .ext below stands for any extension, the convention does not depend on
the file type. The domain and the locale part can be separated by _, - or .; language and region are
separated by _ or -. When a file name carries no locale part, the default locale passed to
ResourcesLoader is used.
| File name | Domain | Locale |
|---|---|---|
messages.ext |
messages |
default |
messages_de.ext |
messages |
de |
messages.de.ext |
messages |
de |
messages_en-US.ext |
messages |
en_US |
payment_de.ext |
payment |
de |
File names that do not match this pattern are ignored by ResourcesLoader.
Filename— the parsed parts of a file name (domain,language,region).hasLocale()reports whether a language part was present andlocale()builds theLocalefrom the parts.TranslationFile— a loaded file:domain,localeand the rawcontentbytes. The byte content is compared by value inequals/hashCode.
Build the Javadoc locally:
./mvnw javadoc:javadoc
The generated HTML is written to target/reports/apidocs/index.html.
Licensed under the Apache License, Version 2.0.