Hi, it's been a little while 🙂. This guide is about Java and I'll show you how to localize your app using Localizer.
Localization and i18n should be simple, which is why I created Localizer.
I could've finished this earlier. I relax on weekends enjoying rock bands like Aerosmith, and 'Dream On' happens to be my favorite.
I've always wanted to contribute to Open Source Software (OSS), and I am happy that I am making contributions (two closed, but not merged, in RxJs and Angular) to projects I use and that make me productive.
I opened a Pull Request (PR) for a OneOf constraint in Hibernate Validator.
It all started when I created an issue in the Jakarta EE repository about addition a @ValidEnum constraint for enum validation.
I'll tell you what - Open Source instantly impacted me. Knowing that my code would be used by other developers pushed me to improve and deliver with quality. What seemed some complex code has now become simpler, and I'm happy that the community will benefit from it in the future.
How the Idea for Localizer Came About
In my free time, I'm developing a social networking app and I realized that I need to make the app i18n-compatible.
I decided to create an API. I then open sites like Facebook and others to observe how their API responses are localized, and it was done in a fashion similar to the way I do it.
In my opinion, handling localized responses from the server or backend would be easy to change and maintain going forward because the API can be consumed by web, mobile, and desktop devices, etc. Single sources of truth are awesome.
So I am open-sourcing Localizer.
Localizer is a Java library that simplifies internationalization for your Java applications, allowing them to support multiple languages with ease.
It allows seamless resolution of localized messages usingLocalizer instances or beans, supporting multiple locales for general, response, and error messages. It integrates with Spring, and other frameworks, such as Vaadin, Quarkus, and Jakarta.
So let's get on with how to localize your application's APIs and backend responses.
The code examples are going to be based on Spring Boot.
Setting Up Localizer
Step 1: How to download & install dependency
To add Localizer, include the following dependency in your pom.xml
file.
<dependency>
<groupId>com.fleencorp.i18n</groupId>
<artifactId>localizer</artifactId>
<version>2.0.6</version>
</dependency>
If you're using Gradle, include the following in your build.gradle
file:
implementation 'com.fleencorp.i18n:localizer:2.0.6'
Step 2: How to Configure the Message Source and Localizer
Now, let's create a @Configuration
class to define the message source:
You can create it in a package of your choice, for example, com.fleencorp.travelstar
. I will name mine MesssageSourceConfiguration
@Configuration
public class MessageSourceConfiguration {}
You can specify the paths to your message source files directly in your configuration class or externalize them to the application.properties file. For example:
spring.messages.encoding=UTF-8
spring.messages.message.base-name=classpath:i18n/messages
spring.messages.error.base-name=classpath:i18n/errors/messages
Note: The /messages
part is not a directory or folder but rather a filename pattern that allows Spring to find the message source files and their locale-specific ones like messages_en_US.properties, messages_de.properties, messages_fr.properties, and so on. It looks something like this:
Step 3: How to populate the Configuration class
@Configuration
public class MessageSourceConfiguration {
@Value("${spring.messages.encoding}")
private String messageSourceEncoding;
@Value("${spring.messages.message.base-name}")
private String messageBaseName;
@Value("${spring.messages.error.base-name}")
private String errorMessageBaseName;
private ReloadableResourceBundleMessageSource baseMessageSource() {
final ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setCacheSeconds(60);
messageSource.setDefaultLocale(Locale.US);
messageSource.setAlwaysUseMessageFormat(true);
messageSource.setUseCodeAsDefaultMessage(false);
messageSource.setFallbackToSystemLocale(false);
messageSource.setDefaultEncoding(messageSourceEncoding);
return messageSource;
}
private MessageSource messageSource() {
final ReloadableResourceBundleMessageSource messageSource = baseMessageSource();
messageSource.setBasenames(messageBaseName);
return messageSource;
}
private MessageSource errorMessageSource() {
final ReloadableResourceBundleMessageSource messageSource = baseMessageSource();
messageSource.setBasenames(errorMessageBaseName);
return messageSource;
}
@Bean
public Localizer localizer() {
return new LocalizerAdapter(messageSource());
}
@Bean
public ErrorLocalizer errorLocalizer() {
return new ErrorLocalizerAdapter(errorMessageSource());
}
}
Questions:
Question 1: What can I change?
You can configure ReloadableResourceBundleMessageSource
according to your preference if you wish. You can also replace it with a message source bundle like ResourceBundleMessageSource
.
Question 2: What are some simple and effective naming patterns for message keys?
You can do it in a way that aligns with the existing standard you are working with. In my case, I name message keys based on the response or exception class name without the suffix. I also group response and error messages alphabetically and based on their similarities. For example:
public class MfaStatusResponse {}
public class ResendMfaVerificationCodeResponse {}
public class StartSetupMfaResponse {}
public class AlreadySignedUpException {}
public class DisabledAccountException {}
public class UsernameNotFoundException {}
# Responses
# Mfa
mfa.status=Mfa status retrieved successfully
resend.mfa.verification.code=Verification code sent successfully
start.setup.mfa=Mfa setup in progress
# Errors
# Auth
already.signed.up=This profile is already signed up and has completed the registration process.
disabled.account=This account has been disabled.
username.not.found=The username or password is invalid. ID: {0}.
Question 3: Why do we need two MessageSource?
To make maintainability and change easier. Having separate message sources will make your life easier as your application localization need grows.
I even have three message sources, as shown in the screenshot above: one for the usual API responses, one for the error responses and another for general use, like notification message keys and other simple classes like the below:
public class MfaTypeInfo {
private MfaType mfaType; // MfaType is an enum that has message codes
private String mfaTypeText;
}
public class ProfileStatusInfo {
private ProfileStatus status; // ProfileStatus is an enum that has message codes
private String profileStatusText;
}
# Mfa Type
mfa.type.phone=Phone
mfa.type.email=Email
mfa.type.authenticator=Authenticator
mfa.type.none=None
# Profile Status
profile.status.active=Active
profile.status.banned=Banned
The above is all the configurations or settings you need. It's that simple.
While working on Localizer, I also explored Hexagonal Architecture when my lead talked about it. The approach solidified my opinion in separating concerns - making localization simple and easily integrated into the business logic.
Another benefit is that it makes testing easier and it reduces the tight coupling between your service classes and other dependencies. I also went the extra mile by replacing external service classes in the app core logic with their equivalent contracts and interfaces.
The concept is beneficial. I like it, so I decided to refactor anything I consider to be a potential technical debt in the future.
To maintain a clean application core and business logic, localization details will be moved to DTOs, responses, and other simple model classes.
I won't be satisfied if the ApiResponse, which is intended solely for i18n and localization, is scattered throughout the application's core implementations.
How to Configure a DTO for a Localized Response
To configure a DTO for localized responses, you can extend the ApiResponse
class. For example:
public class RetrieveCountryResponse extends ApiResponse {
@Override
public String getMessageCode() {
return "retrieve.country";
}
}
It's as simple as extending the ApiResponse class.
The message would look like this:
# messages_fr.properties
# Country
retrieve.country=Country retrieved successfully
# messages_fr.properties
# Country
retrieve.country=Pays récupéré avec succès
In the code I write, it looks like this:
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class RetrieveCountryResponse extends ApiResponse {
@JsonProperty("country")
private CountryResponse country;
@Override
public String getMessageCode() {
return "retrieve.country";
}
public static RetrieveCountryResponse of(final CountryResponse country) {
return new RetrieveCountryResponse(country);
}
}
Here's how to localize RetrieveCountryResponse before returning the HTTP response to the requester or consumer of the API.
The service that returns the response looks like this
public interface CountryService {
RetrieveCountryResponse getCountry(Long countryId);
}
The implementation looks like
@Service
public class CountryServiceImpl implements CountryService {
private final CountryRepository countryRepository;
private final Localizer localizer;
public CountryServiceImpl(
final CountryRepository countryRepository,
final Localizer localizer) {
this.countryRepository = countryRepository;
this.localizer = localizer;
}
@Override
public RetrieveCountryResponse getCountry(final Long countryId) {
final Country country = countryRepository.findById(countryId)
.orElseThrow(CountryNotFoundException.of(countryId));
final CountryResponse countryResponse = CountryMapper.toCountryResponse(country);
final RetrieveCountryResponse retrieveCountryResponse = RetrieveCountryResponse.of(countryResponse);
return localizer.of(retrieveCountryResponse);
}
}
The cool thing about the pattern above is that it promotes loose coupling, which I'm happy with. There are no external details in your core logic, other than the localizer. In the controller, it would look like this:
@GetMapping(value = "/detail/{countryId}")
public RetrieveCountryResponse getCountry(
@PathVariable(name = "countryId") final Long countryId) {
return countryService.getCountry(countryId);
}
Your response would look like this:
{
"message": "Country retrieved successfully",
"country": {
"id": 60,
"title": "Finland",
"code": "FIN",
"timezone": "Europe/Helsinki",
"created_on": "2024-10-03T21:52:14",
"updated_on": "2024-10-03T21:52:14"
}
}
Don't mind me 🙂, I like snake_case for my fields, but I transform them to camelCase on the frontend.
Other examples of localized response include;
@AllArgsConstructor
public class BlockUserStatusResponse extends ApiResponse {
private BlockStatus blockStatus;
@Override
public String getMessageCode() {
return BlockStatus.isBlocked(blockStatus)
? "block.user.status.blocked"
: "block.user.status.unblocked";
}
public static BlockUserStatusResponse of(final BlockStatus blockStatus) {
return new BlockUserStatusResponse(blockStatus);
}
}
The messages would look like
# messages.properties
block.user.status.blocked=You blocked this user
block.user.status.unblocked=Unblock this user
# messages_fr.properties
block.user.status.blocked=Vous avez bloqué cet utilisateur
block.user.status.unblocked=Débloquer cet utilisateur
Your API response can contain parameters too like
public class ContactRequestResponse extends ApiResponse {
private String requesterUsername;
@Override
public String getMessageCode() {
return "contact.request";
}
@Override
public Object[] getParams() {
new Object[] { requesterUsername };
}
}
The messages might look like
# messages.properties
contact.request={0} wants to be your friend
# messages_fr.properties
contact.request={0} veut être votre ami
Parameters are handled based on their index or position in the array. {0} for the first element, {1} for the second element, {2} for the third element and so on
That's all about localizing your API responses!
How to localize your Error response
public class DisabledAccountException extends ApiException {
@Override
public String getMessageCode() {
return "disabled.account";
}
}
In a service, it might look something like this;
if (ProfileStatus.isDisabled(profileStatus)) {
throw new DisabledAccountException();
}
You will create an exception handler and use RestControllerAdvise
if the response is REST API-based like this
@RestControllerAdvice
public class GlobalExceptionHandler {
private final ErrorLocalizer localizer;
public GlobalExceptionHandler(final ErrorLocalizer localizer) {
this.localizer = localizer;
}
@ExceptionHandler(value = {
DisabledAccountException.class,
})
@ResponseStatus(value = BAD_REQUEST)
public ErrorResponse handleBadRequest(final ApiException e) {
return localizer.withStatus(e, Response.Status.BAD_REQUEST);
}
}
The Response
class is part of the Java Jakarta RESTful Web Services library.
ErrorResponse is a simple and awesome utility.
You don't necessarily have to return ErrorResponse if you prefer to create your own. For example:
@ExceptionHandler(value = {
DisabledAccountException.class,
})
@ResponseStatus(value = BAD_REQUEST)
public YourOwnErrorResponse handleBadRequest(final ApiException ex) {
final String message = localizer.getMessage(ex.getMessageCode());
return new YourOwnErrorResponse(message);
}
And that's it for localizing error responses!
Let's also explore some advanced use of localizer
Here's an example of a SignInResponse
I created:
public class SignInResponse extends ApiResponse {
// ... Other fields not included
private String accessToken;
private String refreshToken;
private AuthenticationStatus authenticationStatus;
private MfaTypeInfo mfaTypeInfo;
@Override
public String getMessageCode() {
return "sign.in";
}
@JsonIgnore
public String getPreVerificationMessageCode() {
return "sign.in.pre.verification";
}
@JsonIgnore
public String getMfaAuthenticatorMessageCode() {
return "sign.in.mfa.authenticator";
}
@JsonIgnore
public String getMfaEmailOrPhoneMessageCode() {
return MfaType.isEmail(getMfaType())
? "sign.in.mfa.email"
: "sign.in.mfa.phone";
}
@JsonIgnore
public MfaType getMfaType() {
return nonNull(mfaTypeInfo)
? mfaTypeInfo.getMfaType()
: null;
}
@JsonIgnore
public String getMfaMessageCode() {
return MfaType.isAuthenticator(getMfaType())
? getMfaAuthenticatorMessageCode()
: getMfaEmailOrPhoneMessageCode();
}
@Override
public Object[] getParams() {
final String verificationType = MfaType.isPhone(getMfaType()) ? phoneNumber.toString() : emailAddress.toString();
return new Object[]{verificationType};
}
}
The message.properties file looks like
# messages.properties
sign.in=Sign-in successful
sign.in.pre.verification=Enter the code sent to email to complete the sign up
sign.in.mfa.authenticator=Use an authenticator code to complete the process
sign.in.mfa.email=Code has been sent to your email {0}
sign.in.mfa.phone=Code has been sent to your phone {0}
# messages_fr.properties
sign.in=Connexion réussie
sign.in.pre.verification=Entrez le code envoyé à votre email pour compléter l'inscription
sign.in.mfa.authenticator=Utilisez un code d'authentificateur pour compléter le processus
sign.in.mfa.email=Un code a été envoyé à votre email {0}
sign.in.mfa.phone=Un code a été envoyé à votre téléphone {0}
In the AuthenticationServiceImpl, It looks like this and depending on the current status of the user's profile, the message will be different.
public SignInResponse signIn(final SignInDto signInDto) {
final String emailAddress = signInDto.getEmailAddress();
final String password = signInDto.getPassword();
final Authentication authentication = authenticateCredentials(emailAddress, password);
final AuthorizedUser user = (AuthorizedUser) authentication.getPrincipal();
validateProfileIsNotDisabledOrBanned(user.getProfileStatus());
final SignInResponse signInResponse = createDefaultSignInResponse(user);
if (isProfileInactiveAndUserYetToBeVerified(user)) {
return processSignInForProfileYetToBeVerified(signInResponse, user);
}
if (isMfaEnabledAndMfaTypeSet(user)) {
return processSignInForProfileWithMfaEnabled(signInResponse, user);
}
return processSignInForProfileThatIsVerified(signInResponse, user, authentication);
}
protected SignInResponse processSignInForProfileYetToBeVerified(final SignInResponse signInResponse, final AuthorizedUser user) {
handleProfileYetToBeVerified(signInResponse, user);
return localizer.of(signInResponse, signInResponse.getPreVerificationMessageCode());
}
protected SignInResponse processSignInForProfileWithMfaEnabled(final SignInResponse signInResponse, final AuthorizedUser user) {
handleProfileWithMfaEnabled(signInResponse, user);
return localizer.of(signInResponse, signInResponse.getMfaMessageCode());
}
protected SignInResponse processSignInForProfileThatIsVerified(final SignInResponse signInResponse, final AuthorizedUser user, final Authentication authentication) {
handleProfileThatIsVerified(signInResponse, user, authentication);
return localizer.of(signInResponse);
}
I hope you found this helpful & Good luck 🤞.
The following resources also helped me in getting my library published on Maven Central. I want to say Thank you.
References
- Publish a Java library to Maven Central - A step-by-step guide (2024)
- Publishing to the Maven Central Repository
- How to Publish a Java Library to Maven Central
- How to upload your Android Library to Maven Central | Central Portal in 2024
- Publish an Android library on Maven Central
- Maven Central Repository - Sonatype
Top comments (0)