My mother taught me that language is the bridge between people. Our tools ship that bridge half-built and find out in production.

So I made a missing translation a compile error. Not a runtime MissingResourceException. Not a ticket. A build that does not pass.

This one is personal. The serialization work was a fight with physics. The publishing plugin was a fight with a four-year-old bug. This is a tribute.

Barbara Hidalgo-Toledo

Barbara Hidalgo-Toledo loved languages. She ran a successful language institute, Language Encounters Center, in Santiago Chile between 1986 and 1989. She taught professional English to business people and conversational English to students and kids. She grew her institute to a handful of employees, while raising me and caring for her mother, who was dying of breast cancer. When grandma died, her desire to return to the United States and finish her degree in Broadcast Journalism overrode what some may have considered the "common sense" to stay and develop her business. She closed LEC as we left, but never forgot it. Language, she told me, was the key to expanding our minds.

Now, I revive Language Encounters Center to apply her passion and lessons in silico. The common practices of l10n/i18n in software engineering is abysmal. As an afterthought, at best, completely forgotten or actively deprioritized at worst. It's deeply ironic because software is one of the most mixed types of work in the world, in any professional software engineering team, it's fairly common to work with someone who speaks Arabic, Moldovan, Turkish, German, Russian, Chinese, Spanish, French, etc...

Despite all this representation actually doing the work, software tends to be strikingly un-international. Once, I found myself in the Amazon rainforest trying to teach an Indigenous tour guide and host how to list his property on Booking.com, only to find half the translations broken. Once I gave a Spanish-speaking partner an Android phone for her birthday. Screen two of the setup (after selecting Latin American Spanish as the primary language) was in English.

It's shameful. It is implicitly colonial. When we ignore the importance of the languages of our heritage, we say "only English matters, because only money matters. My ancestors, my grandmother, my mother, they can all go fuck themselves." That was the desire of Ronald Reagan's "Melting Pot." Many of us were melted. Is that who we want to be?

There's a certain level of laziness that's inherent to this problem. It's the same level of laziness I saw in the southern coast of Andalucia, Spain when old British people leave the island for a holiday, get drunk and yell at waiters in English, demanding to be understood because they have more money than the average population in the region. That's the attitude we take software when we treat localization (l10n) and internationalization (i18n) so shabbily.

In honor of Barbara Hidalgo-Toledo, Visionary Software Solutions takes a different approach. We hold this Truth to be Self-Evident: the place where languages meet is where you decide whether the person on the other side matters enough to get the details right. What follows is the engineering — but the engineering is the least important part of the argument. a half-delivered message is not a smaller kindness, it is a broken promise. I do not accept that. Here is the code that does not accept it either.

The problem: we ship the broken promise on purpose

Twenty-five years into a career and the canonical way to internationalize a Java program is still this:

// java.util.ResourceBundle — the 1990s called, they want their API back
ResourceBundle bundle = ResourceBundle.getBundle("messages", new Locale("en", "US"));
String message = bundle.getString("welcome.message");  // runtime exception if missing

Every failure mode here is discovered by a user, in the language we did not bother to finish, at the moment we can least afford it:

  • String keys → a typo is a runtime crash, not a red squiggle.
  • String language tags → new Locale("en", "US") has no more type safety than a comment.
  • Missing translations are found in production, by the exact person we failed to translate for.
  • Properties files drift away from the code they describe.
  • There is no way to make completeness a build requirement, so it never is.

We have collectively decided that MissingResourceException in front of a Spanish-speaking customer is a tolerable cost of doing business. It is an odious practice. It is entirely optional.

The solution: move the failure to the only place it is cheap

A missing translation is cheapest to fix at the instant you write the code that needs it. Most expensive at the instant a user hits it. LEC moves the entire failure class from the second instant to the first.

// Messages are type-safe annotations, co-located with the code that uses them
@Message(language = IANA.ENGLISH, value = "Welcome to the Free Software Blog")
@Message(language = IANA.SPANISH, value = "Bienvenido al Blog de Software Libre")
@Message(language = IANA.FRENCH,  value = "Bienvenue sur le blog des logiciels libres")
public @interface Welcome {}

// The module declares the languages it promises to support
@SupportedLanguages({IANA.ENGLISH, IANA.SPANISH, IANA.FRENCH})
module my.application {
    requires software.visionary.lec;
}

// Use it with the confidence the compiler just earned you
String message = LanguageEncountersCenter.INSTANCE.translate(Welcome.class, locale);

Missing the Spanish translation? Compilation fails. Typo in a language tag? It will not compile. Declared IANA.FRENCH as supported but did not provide French? The build stops. The broken promise becomes structurally impossible to deploy.

How LEC compares to what exists

Capability ResourceBundle Spring MessageSource ICU4J L10nMessages LEC
Type-safe language tags StringStringStringString IANA enum
Translation validation RuntimeRuntimeRuntimeKeys only Full, compile-time
Missing-translation detection RuntimeRuntimeRuntimeKeys only Compile-time
Validation overhead RuntimeRuntimeRuntimeRuntime Zero
Standards basis ISO codesPartialCLDR / BCP 47Properties RFC 5646, direct
Module-system integration NoneNoneNoneNone First-class JPMS

LEC is the Rust of i18n libraries — it moves a whole category of defect from "discovered in production" to "cannot be expressed," and it does so at zero runtime cost because all of it happens at SOURCE retention during annotation processing.

That table is the short version. The long version is a systematic 2025 survey of ten Java i18n/l10n solutions — ResourceBundle, Spring MessageSource, ICU4J, Jakarta EE, L10nMessages, Propify, JLibs, gettext-commons, Apache Commons i18n, Tolgee — scored on relevance, authority, depth, and impact, with a gap analysis showing exactly where every one of them leaves the door open: Java i18n/l10n Landscape Analysis, 2025.

Architecture: four pieces, each its own repository

Monorepos tempt bad dependency hygiene — they can force modules that are not changing to re-version alongside ones that are. So LEC is atomized: every module below is an independently versioned repo under the Language Encounters Center organization, consuming its upstreams as published Maven coordinates, not source-folded subprojects.

1 — The IANA registry (an external dependency)

An exhaustive, type-safe enum generated directly from the IANA Language Subtag Registry. Not a curated subset someone will forget to update — the actual registry, parsed from the authoritative file.

// java.util.Locale — weak typing, runtime construction
Locale locale = new Locale("en", "US");   // strings → runtime errors
locale.getLanguage();                      // returns the String "en"

// LEC IANA — strong typing, compile-time checked
IANA language = IANA.ENGLISH;              // an enum constant
language.name();                           // returns "ENGLISH"

Consumed as published artifacts from the standalone iana-subtag-registry organization:

  • software.visionary.iana.registry:annotation — the @LanguageTag annotation.
  • software.visionary.iana.registry:processor — the annotation processor that reads the IANA registry and generates the exhaustive IANA enum.

2 — languages-encountered: your team's subset, type-safe

The full registry has 8,000+ subtags. Your team will probably never support Klingon. Hand-maintaining a List<String> of the ones you do support throws away type safety; carrying the full enum carries languages you will never use. So generate a custom enum containing exactly your supported languages, at compile time, from a single module-level declaration.

@LanguagesEncountered({IANA.ENGLISH, IANA.SPANISH, IANA.FRENCH, IANA.GERMAN})
module com.example.myapp {
    requires software.visionary.lec.languages.encountered;
}

// Generated at compile time — only the four you declared
public enum TeamLanguages { ENGLISH, SPANISH, FRENCH, GERMAN; }

Type-safe instead of ad-hoc strings, enum performance (switch, ordinal), a smaller footprint than the full enum, and a module-info.java that documents exactly which languages the team has committed to.

3 — lec: the core API

The heart of the system: the @Message annotation and runtime resolution.

@Message(language = IANA.ENGLISH, value = "Welcome, {0}!")
@Message(language = IANA.SPANISH, value = "¡Bienvenido, {0}!")
public @interface UserWelcome {}

String message = LanguageEncountersCenter.INSTANCE.translate(
    UserWelcome.class, Locale.forLanguageTag("es"));

4 — enforce-supported-languages: the crown jewel

The annotation processor that turns the whole promise into a build gate. @SupportedLanguages declares what a module owes; the processor proves every @Message pays it.

error: Missing translations in module software.visionary.strider:
  software.visionary.strider.CannotReadFile: missing IANA.SPANISH

Module-level rather than package-level, so it aligns with JPMS boundaries. It uses the Phyrexia Newt abstraction over javax.lang.model so the processor code is legible instead of the usual mirror-API swamp. It dogfoods LEC — the processor's own error messages are @Message annotations, validated by the same machinery they belong to — and it ships with a full BDD test suite.

Getting started

Java 17+ (for JPMS), Gradle 8+ or Maven 3.9+.

Gradle, with the version catalog

# gradle/libs.versions.toml
[versions]
lec = "2026.05.10"

[libraries]
lec = { module = "software.visionary:lec", version.ref = "lec" }
enforce-supported-languages = { module = "software.visionary.lec:enforce-supported-languages", version.ref = "lec" }
// build.gradle.kts
dependencies {
    api(libs.lec)
    annotationProcessor(libs.enforce.supported.languages)
}

Maven

<dependency>
  <groupId>software.visionary</groupId>
  <artifactId>lec</artifactId>
  <version>2026.05.10</version>
</dependency>

<!-- maven-compiler-plugin -->
<annotationProcessorPaths>
  <path>
    <groupId>software.visionary.lec</groupId>
    <artifactId>enforce-supported-languages</artifactId>
    <version>2026.05.10</version>
  </path>
</annotationProcessorPaths>

Living documentation: the two demos prove it both ways

A claim like "missing translations cannot be deployed" is worth nothing unless you can watch it fail on demand. Both demos are full standalone repositories — not toy snippets — that exist to be run.

demo-blog-happy — every promise kept

A free-software blog with complete English, Spanish, and French translations. It builds, it runs, it prints all three. demo-blog-happy.

demo-products-unhappy — a promise broken on purpose

A product catalog deliberately missing translations. It is engineered not to build, and the failure is the feature:

error: Missing translations in module software.visionary.lec.demo.products:
  software.visionary.lec.demo.products.AddedToCart: missing IANA.FRENCH, IANA.SPANISH
1 error

BUILD FAILED

That exact error is the entire thesis of this project, reproduced end to end through real published Maven coordinates. demo-products-unhappy.

The journey: a rabbit hole that ate a whole book

To generate the IANA enum from the authoritative source, I had to parse the authoritative source. The IANA Language Subtag Registry is not JSON, not XML, not CSV. It is distributed in the Record Jar format — a data-file metaformat catalogued by Eric S. Raymond in The Art of Unix Programming. I went to RFC 5646 §3.1.1 to learn the registry format, hit Record Jar, went to TAOUP to learn Record Jar — and stayed for the whole book.

I could have hacked a one-off parser into LEC and moved on. I do not build that way. A format is a reusable thing; it gets its own library, tested, published, and reusable by anyone — including future me. Two fell out of the rabbit hole.

cookie-jar — the simpler sibling

Right next to the registry's format in TAOUP sits the Cookie Jar format: independent records separated by %%, the format Unix fortune has used to serve a random epigram since approximately forever. It is the simpler of the two — flat records, no field structure — and it round-trips text → Cookies, Cookies → text. The worked example parses a scrape of one of my favorite pages on the internet, Gabe Robins' good quotations, into a jar you can draw from.

record-jar — the format the registry actually uses

Its richer sibling — and the one the IANA registry actually uses — is Record Jar: a clean-room implementation of ESR's format, %%-separated records of newline-separated key: value fields, with continuation lines. It does the same round trip — text → Records, Records → text — plus the less obvious immutable algebra: a Record plus extra fields is a new Record; a Record minus filtered fields is a new Record. Nothing mutates.

Its sharpest edge is a mixin that lets a plain java.lang.Record serialize itself to Record Jar with no reflection at the call site, guarded by a recursive bounded type parameter so only actual records can implement it:

public interface Recorded<R extends Record & Recorded<R>>
        extends Traversable.TraversableRecord<R>

record Song(String title, String artist) implements Recorded<Song> {}   // compiles
class  NotARecord implements Recorded<NotARecord> {}                     // does NOT compile

R extends Record can only ever be satisfied by a real record declaration; & Recorded<R> closes the loop so the compiler itself refuses non-records. Extending TraversableRecord<R> means any record that opts in becomes reflection-free iterable over its components for free. The same compile-time-or-nothing philosophy as LEC, one layer down.

One of its nicer features is that it gives you java.lang.Record round-trip serialization practically effortlessly. Her favorite song — Imagine, by John Lennon — written to Record Jar, then rebuilt by the static factory method:

record Song(String title, String artist) implements Recorded<Song> {
    public static Song create(Record r) {            // the STATIC FACTORY METHOD
        return new Song(r.fields().get(new NonEmptyString("title")).get(),
                        r.fields().get(new NonEmptyString("artist")).get());
    }
}

Song imagine    = new Song("Imagine", "John Lennon");
Record asRecord = imagine.toRecord();               // record → Record Jar
Song restored   = Recorded.newInstance(Song.class, asRecord);

The serialized form:

title: Imagine
artist: John Lennon
%%

Neither library was the goal. Both exist because the goal was done right. Doing it right meant reading the spec — all of it — and leaving behind something reusable instead of a private hack. That's what I do.

Roadmap

Step 1: other languages. The idea is not Java-specific. It is easy to port, and it will be. Kotlin, TypeScript, Dart, and Rust are the initial targets. We're open to others if the community expresses sufficient interest.

Step 2: Languwiz. LEC treats language as a type-safe, standards-anchored tag. Languwiz goes further: a strongly typed domain model of natural language itself — phonology, morphology, syntax; writing systems and scripts; grammar; the family trees and relationships between languages. Imagine natural language grammatical forms and syntax available programmatically. Could we save AIs context and processing by better modeling in code?

Why this matters

Internationalization has been an afterthought for decades. We accept that missing translations slip to production. We tolerate runtime exceptions in front of the very people we claimed to be including. We live with string APIs that the compiler cannot help us with. We call that normal.

Modern type systems and compile-time validation can dissolve that entire category of failure. LEC is the proof. It is small, fast, and free. I built it because the idea would not leave me alone; I open-sourced it because the people most failed by missing translations are exactly the people who never see the bug tracker. If you want software that is simple, correct, and humane from someone who builds it that way every day, this is what that looks like. I am available at nico@visionary.software to build the next one with you, or to license these for your commercial work.

A half-delivered message is not a smaller kindness. It is a broken promise. I will keep building things that refuse to ship it.