DEV Community

Àlámú
Àlámú

Posted on • Originally published at Medium

A Guide to Localizing and Internationalizing Your Java App with Localizer

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.

Facebook API response for Sending a Friend Request<br>

Other Networking sites

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.

Localizer

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>
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Message Sources Configuration

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());
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode
# Responses
# Mfa
mfa.status=Mfa status retrieved successfully
resend.mfa.verification.code=Verification code sent successfully
start.setup.mfa=Mfa setup in progress
Enter fullscreen mode Exit fullscreen mode
# 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}.
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

}
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

In a service, it might look something like this;

if (ProfileStatus.isDisabled(profileStatus)) {
  throw new DisabledAccountException();
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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};
  }
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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

  1. Publish a Java library to Maven Central - A step-by-step guide (2024)
  2. Publishing to the Maven Central Repository
  3. How to Publish a Java Library to Maven Central
  4. How to upload your Android Library to Maven Central | Central Portal in 2024
  5. Publish an Android library on Maven Central
  6. Maven Central Repository - Sonatype

Top comments (0)