DEV Community

Solon Framework
Solon Framework

Posted on

Solon I18n: Three Resolvers, Zero Boilerplate

Solon takes a different approach. The entire i18n module is built around three resolvers, one annotation, and a utility class — no XML, no interceptor registration, no boilerplate configuration.

Let me walk through how it works.

The Resource Convention

Place your message files under resources/i18n/:

resources/i18n/messages.properties           # default (e.g. Chinese)
resources/i18n/messages_en_US.properties     # US English
resources/i18n/messages_ja_JP.properties     # Japanese
Enter fullscreen mode Exit fullscreen mode

File format is standard Java properties:

# messages_en_US.properties
login.title=Sign In
login.welcome=Welcome, {0}!
app.name=Solon Application
Enter fullscreen mode Exit fullscreen mode

That's it. No bean declaration, no location configuration. Solon discovers them automatically.

Three Locale Resolvers, One Switch

Solon ships three built-in locale resolvers. The default is header-based, but you can swap it with a one-liner.

1. Header Resolver (Default)

Reads the Content-Language header, falling back to Accept-Language:

# app.yml — no config needed, works out of the box
Enter fullscreen mode Exit fullscreen mode

To customize the header name:

@Configuration
public class I18nConfig {
    @Bean
    public LocaleResolver localeInit() {
        LocaleResolverHeader resolver = new LocaleResolverHeader();
        resolver.setHeaderName("lang");  // now reads "lang" header
        return resolver;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Cookie Resolver

Reads locale from a cookie named SOLON.LOCALE by default:

@Configuration
public class I18nConfig {
    @Bean
    public LocaleResolver localeInit() {
        return new LocaleResolverCookie();
    }
}
Enter fullscreen mode Exit fullscreen mode

Custom cookie name:

resolver.setCookieName("lang");
Enter fullscreen mode Exit fullscreen mode

3. Session Resolver

Reads locale from a session attribute:

@Configuration
public class I18nConfig {
    @Bean
    public LocaleResolver localeInit() {
        return new LocaleResolverSession();
    }
}
Enter fullscreen mode Exit fullscreen mode

Custom attribute name:

resolver.setAttrName("lang");
Enter fullscreen mode Exit fullscreen mode

4. Custom Resolver

If none of the three fit your use case, implement LocaleResolver directly:

@Component
public class QueryParamResolver implements LocaleResolver {
    @Override
    public Locale getLocale(Context ctx) {
        String lang = ctx.param("lang");
        if (lang != null) {
            return LocaleUtil.toLocale(lang);
        }
        return Locale.getDefault();
    }

    @Override
    public void setLocale(Context ctx, Locale locale) {
        ctx.setLocale(locale);
    }
}
Enter fullscreen mode Exit fullscreen mode

Three Ways to Read Messages

1. I18nUtil — Quick Access

Static methods for one-off lookups anywhere:

// From current request context (locale resolved automatically)
String title = I18nUtil.getMessage("login.title");

// With a specific Locale
String welcome = I18nUtil.getMessage(locale, "login.welcome", "Alice");

// With a Context object
I18nUtil.getMessage(ctx, "app.name");
Enter fullscreen mode Exit fullscreen mode

2. I18nService — Named Bundle Access

When you need messages from a specific bundle:

I18nService loginI18n = new I18nService("i18n.login");

@Mapping("/greet")
public String greet(Locale locale) {
    return loginI18n.get(locale, "login.welcome", "Alice");
}
Enter fullscreen mode Exit fullscreen mode

3. I18nBundle — Programmatic Bundle

For advanced usage, get the raw bundle:

I18nBundle bundle = I18nUtil.getBundle("i18n.messages", locale);
String value = bundle.get("login.title");
String formatted = bundle.getAndFormat("login.welcome", "Alice");
Enter fullscreen mode Exit fullscreen mode

@I18n — Template Integration

Add @I18n to your controller and every template gets an i18n variable:

@I18n("i18n.login")
@Controller
public class LoginController {
    @Mapping("/login")
    public ModelAndView login() {
        return new ModelAndView("login.ftl");
    }
}
Enter fullscreen mode Exit fullscreen mode

Without specifying a bundle name, it defaults to i18n.messages.

Template Syntax Examples

Freemarker (login.ftl):

<h1>${i18n["login.title"]}</h1>
<p>${i18n.getAndFormat("login.welcome", session.user.name)}</p>
Enter fullscreen mode Exit fullscreen mode

Thymeleaf:

<h1 th:text="${i18n.get('login.title')}">Sign In</h1>
<p th:text="${i18n.getAndFormat('login.welcome', 'Alice')}">Welcome!</p>
Enter fullscreen mode Exit fullscreen mode

Beetl:

<h1>${i18n["login.title"]}</h1>
<p>${@i18n.getAndFormat("login.welcome", "Alice")}</p>
Enter fullscreen mode Exit fullscreen mode

Enjoy:

<h1>#(i18n.get("login.title"))</h1>
<p>#(i18n.getAndFormat("login.welcome", "Alice"))</p>
Enter fullscreen mode Exit fullscreen mode

Extending for Distributed Environments

For teams that manage translations in a config center or CMS, implement I18nBundleFactory:

@Component
public class RemoteBundleFactory implements I18nBundleFactory {
    @Override
    public I18nBundle create(String bundleName, Locale locale) {
        // Fetch from remote config center
        Props props = getRemoteProps(bundleName, locale);
        return new I18nBundleImpl(props, locale);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it:

I18nUtil.setBundleFactory(new RemoteBundleFactory());
Enter fullscreen mode Exit fullscreen mode

Solon's built-in I18nBundleFactoryLocal reads from resources/i18n/; the factory approach lets you swap in Redis, Nacos, or any other source without changing your application code.

Minimal Dependency

The solon-i18n module has zero transitive dependencies beyond solon-core. If you're using solon-web, it's already included.

<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-i18n</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

The Trade-offs

What you don't get:

  • No MessageSource-style hierarchy (Solon uses flat bundles per name)
  • No automatic reload in dev mode (restart to pick up changes, or implement a custom I18nBundleFactory with hot-reload)
  • No built-in language switcher endpoint (it's intentionally left to the resolver — switching via query param requires a custom LocaleResolver)

What you do get:

  • i18n working in under 60 seconds
  • Three production-ready resolver strategies
  • First-class template integration across 5 engines
  • Extensible factory pattern for distributed config
  • Zero XML, zero interceptors, zero ceremony

Summary

Solon's i18n model follows the framework's philosophy: convention over configuration, but swap when you need to. One dependency, one annotation for templates, one utility for programmatic access, and three drop-in resolvers cover the vast majority of real-world scenarios.

If you've been wrestling with MessageSource configurations, give this a try — it's surprisingly refreshing.

Top comments (0)