1. Importancia de la estructura y i18n
Cuando los bundles ya están en src/main/resources, messageSource está conectado al contenedor y la regla de selección de locale está separada, el problema ya no está en el propio getMessage(...). El problema se vuelve muy terrenal: dónde debe vivir todo esto para que el proyecto no se convierta en una caja de cables — las claves en un lugar, el locale en otro, los configs en un tercero, y el mensaje en el idioma incorrecto aparece ya en tiempo de ejecución.
Precisamente aquí comienza el dolor familiar: en el proyecto hay diez mensajes diferentes, tres servicios que los solicitan y un colega que accidentalmente escribirá la clave "order.createdd" (dos d) — y pasará media hora mirando NoSuchMessageException como si fuera un acertijo de la Esfinge.
El problema no es que MessageSource sea complejo. El problema es que los textos son datos, y a los datos les gusta propagarse. Si no se acuerda dónde viven los bundles, dónde están las claves, quién tiene derecho a llamar a MessageSource, y dónde termina el modelo de dominio y comienza el «texto para el usuario», obtendrá un artefacto de proyecto típico: «todo parece funcionar, pero da miedo tocarlo».
Nuestro objetivo aquí es hacer que, según la estructura de ContextFlow, se puedan responder tres preguntas sin adivinanzas: dónde cambiar el texto; dónde agregar una nueva clave; adónde mirar si el mensaje se muestra en el idioma incorrecto.
Límites de las capas: domain e i18n
Ahora viene una idea importante que es fácil pasar por alto, porque suena «como obvia». Pero es precisamente la que suele ahorrarle dolor al proyecto. El dominio (domain) se trata de significado: pedido, cliente, estado, importe. La localización se trata de presentación: cómo le hablamos de esto al usuario, en consola, en carta, en informe. Si se mezclan estas dos cosas, empezará a colar Spring en las clases más «puras» — y el dominio se convertirá en «dominio más un apéndice de infraestructura».
En ContextFlow mantenemos la disciplina: domain no debe depender de MessageSource, no debe almacenar claves ni debe decidir por sí mismo en qué idioma hablar. Si se ha creado un pedido — es un hecho. El texto «Pedido creado» es tarea de la capa de aplicación, que muestra y comunica.
Es útil tener en mente una tabla simple (no como un «canon», sino como una pista para resolver casos dudosos):
| Capa | Qué vive aquí | Qué NO vive aquí |
|---|---|---|
| domain | Order, Customer, OrderStatus, reglas de cálculo, invariantes | MessageSource, claves "order.created", selección de Locale, lectura de .properties |
| application | servicios de casos de uso, escenarios, formación de texto para notificaciones/consola/informes | lectura de bajo nivel de archivos, rutas absolutas, trabajo con Resource como archivo |
| infrastructure | implementaciones concretas: archivo de auditoría, notificaciones de consola, carga de plantillas | decisiones de negocio «cuándo cancelar un pedido», invariantes del dominio |
| config | wiring: @Configuration, @Bean, @Import, @ComponentScan | lógica de negocio (nada de «si el pedido es caro — cancelarlo») |
Esta tabla es nuestro «puesto fronterizo». Y a continuación, distribuiremos los bundles/keys/config de forma que caigan naturalmente en sus capas.
2. Message bundles en src/main/resources
Cuando recién comienzas, quieres poner los archivos «junto a la clase», como si fueran sus recursos personales. A veces incluso se ve ordenado… hasta la primera compilación jar, donde de repente te das cuenta de que los recursos son parte del classpath, no del sistema de archivos «como en el explorador». Por eso elegimos una regla aburrida pero confiable: todos los message bundles están en src/main/resources.
Para ContextFlow la estructura de recursos gradualmente se vuelve así: templates/ quedan para grandes plantillas de texto, junto a ellos están los properties de la aplicación y los bundles de mensajes.
src/main/resources/
├── contextflow.properties
├── contextflow-demo.properties
├── contextflow-test.properties
├── messages.properties
├── messages_ru.properties
├── messages_en.properties
└── templates/
├── notifications/
│ ├── order-created.txt
│ └── order-cancelled.txt
└── reports/
└── daily-report-header.txt
Fíjate en una importante «ancla mental»: los bundles no son «archivos en el disco», son recursos de la aplicación que terminarán en el classpath. Cuando Gradle compila el proyecto, estos archivos aparecen en build/resources/main y luego se empaquetan en un jar. Por eso ResourceBundleMessageSource los busca por basename en el classpath, no por ruta absoluta.
Para la base messages nos bastan tres archivos. Ejemplo de contenido (muy corto para que se vea la idea):
# src/main/resources/messages.properties
# Bundle base (por defecto), a menudo es conveniente mantenerlo en inglés
order.created=Order {0} created
order.cancelled=Order {0} cancelled
# src/main/resources/messages_ru.properties
# Localización rusa
order.created=Pedido {0} creado
order.cancelled=Pedido {0} cancelado
# src/main/resources/messages_en.properties
# Localización inglesa explícita (puede coincidir con la base)
order.created=Order {0} created
order.cancelled=Order {0} cancelled
Sí, el archivo en inglés puede coincidir con el base. En un proyecto de aprendizaje es normal: la capa base a menudo se escribe en inglés y las localizaciones se agregan de forma puntual. Lo principal es que entiendas dónde está la «fuente de la verdad» para el texto y por qué no debe estar oculta dentro de una clase Java.
3. Claves de mensajes y constantes
Las claves ya las hemos definido. Aquí importa otra cosa: no deben estar como cadenas sueltas por el proyecto. Dentro de una semana estarás depurando un error porque en un lugar aparece "order.created" y en otro "order.create" (no es la misma palabra), y todo compila perfectamente porque una cadena es una cadena, el compilador no la juzga.
Por eso introducimos una regla simple que se amortiza casi de inmediato: las claves viven en un solo lugar como constantes. No es «belleza por la belleza», es la manera mínima de obtener autocompletado en el IDE y reducir la cantidad de errores tipográficos.
En ContextFlow es conveniente crear una clase de constantes, por ejemplo así:
package com.example.contextflow.application.i18n;
/**
* Claves de mensajes para i18n.
* Las almacenamos centralizadamente para que el IDE las sugiera y para reducir los errores tipográficos.
*/
public final class MessageKeys {
// Mensaje sobre creación de pedido
public static final String ORDER_CREATED = "order.created";
// Mensaje sobre cancelación de pedido
public static final String ORDER_CANCELLED = "order.cancelled";
private MessageKeys() {
// Clase utilitaria: no se crean instancias
}
}
¿Por qué el paquete application.i18n y no domain? Porque las claves son necesarias donde formamos los textos de usuario. El dominio no necesita claves, vive de hechos, no de frases.
Sin embargo, es importante no caer en el otro extremo: no convertir MessageKeys en un «vertedero de todo». Si las claves se vuelven muchas, es mejor crear varias clases pequeñas (por ejemplo, OrderMessageKeys, ReportMessageKeys) en el mismo paquete. Mientras las claves sean pocas, bastan un par de constantes claras, no un mini-framework.
Otro detalle práctico: trata de que las claves sean cortas y con significado. "order.created" es una buena clave. "order.created.message.for.console.output.v2" es una clave que claramente ya ha pasado por el dolor y trata de contarlo. Pero la clave no debe ser un diario.
4. Uso de MessageSource en application
Cuando los bundles y las claves ya están en su lugar, la siguiente cuestión no es cómo volver a llamar a getMessage(...), sino quién debería hacerlo. Si dejas que MessageSource vague por todo el proyecto, rápidamente aparecerá en NotificationSender, en AuditService y, Dios no lo quiera, en Order.
En ContextFlow es conveniente tener un servicio estrecho que responda solo por obtener el texto: un pequeño OrderTextService. Vive en application porque es parte de la orquestación y presentación, no del dominio.
package com.example.contextflow.application.i18n;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
/**
* Servicio que se encarga de obtener los textos localizados relacionados con el pedido.
* Importante: los objetos de dominio no arrastran Spring aquí; al contrario, la capa de aplicación construye por sí misma la presentación.
*/
@Service
public class OrderTextService {
// Spring MessageSource — dependencia de infraestructura, pero vive en la capa de aplicación como herramienta de presentación
private final MessageSource messages;
public OrderTextService(MessageSource messages) {
this.messages = messages;
}
}
Y aquí un método por separado, para no convertir el ejemplo en un «tostón»:
import java.util.Locale;
public String created(String orderId, Locale locale) {
// Tomamos el texto por clave + sustituimos los parámetros (orderId)
return messages.getMessage(
MessageKeys.ORDER_CREATED,
new Object[]{orderId}, // parámetros para {0}, {1}, ...
"Order created", // fallback, si no se encuentra la clave
locale // locale seleccionado
);
}
Aquí importa una sola cosa: OrderTextService es un application-bean normal. Lo recoge CoreConfig mediante scan, y él mismo depende solo de MessageSource, no de ApplicationContext.
Con el locale pasa lo mismo: la regla de selección no debe flotar por los servicios, por eso es conveniente moverla a LocaleSelector. Desde el punto de vista de la estructura del proyecto, también es parte de la capa de aplicación: la regla de selección de locale pertenece al comportamiento del escenario, no al dominio ni a la configuración.
Versión mínima:
package com.example.contextflow.application.i18n;
import java.util.Locale;
/**
* Encapsula la regla de selección de locale: tomamos el preferido, y si no está, usamos el predeterminado.
*/
public class LocaleSelector {
private final Locale defaultLocale;
public LocaleSelector(Locale defaultLocale) {
this.defaultLocale = defaultLocale;
}
public Locale choose(Locale preferredLocale) {
// Regla simple: si el usuario no ha seleccionado nada, no fallamos, usamos el predeterminado
return preferredLocale != null ? preferredLocale : defaultLocale;
}
}
LocalizationConfig crea un bean para esta clase y suministra a la capa de aplicación la regla de selección de locale ya lista. Así el código del escenario no tiene que recordar cada vez qué hacer con null, sino que simplemente utiliza el mismo acuerdo.
Una disciplina importante: el modelo de dominio (Customer) puede almacenar preferredLocale como datos, pero la decisión de «qué locale usar realmente» la tomamos en la capa de aplicación. Esto parece un poco pesado, pero luego no buscarás por el proyecto 17 lugares donde cada uno «a su manera» decide qué hacer con la locale null.
5. Clases Config y módulos
Después de haber agregado properties, resources, reglas de locale y messages, AppConfig muy rápidamente empieza a parecerse a un cajón de cocina donde metes de todo: cucharas, pilas, y por alguna razón un tornillo de 13. Sí, puedes vivir así. Pero luego alguien abrirá ese cajón — y te temerá como persona.
Por lo tanto, formamos para ContextFlow una capa de configuración separada: com.example.contextflow.config, dentro de la cual la configuración se distribuye por módulos. Esto concuerda con la estructura general del proyecto, donde config.* es el lugar donde la aplicación se ensambla como un juego de construcción, no donde vive la lógica de negocio.
Por ejemplo, hoy nos resulta lógico tener estos módulos:
• config.core — @ComponentScan base para application e infrastructure;
• config.localization — MessageConfig y LocalizationConfig, es decir, el wiring para messageSource, Locale predeterminado y LocaleSelector;
• config.AppConfig — la clase «ensambladora» superior que lo importa todo.
Observa: no estamos obligados a dividir infinitamente. Hay que dividir cuando mejora la legibilidad, no cuando nos pica la comezón de «dividir en 100 archivos».
6. Ensamblaje mediante @Import
Cuando los roles de los módulos están separados, la configuración raíz se lee de un vistazo: el scan vive en un lugar, los mensajes y la regla de locale en otro, el ensamblaje en un tercero.
Ejemplo de CoreConfig — solo scanning:
package com.example.contextflow.config.core;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@ComponentScan(basePackages = {
"com.example.contextflow.application",
"com.example.contextflow.infrastructure"
})
public class CoreConfig { }
application.i18n ya está dentro de application, por lo que OrderTextService se levanta mediante scan. Y el paquete config no entra aquí: lo conectamos explícitamente.
Ejemplo de MessageConfig — solo MessageSource:
package com.example.contextflow.config.localization;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
@Configuration(proxyBeanMethods = false)
public class MessageConfig {
@Bean
MessageSource messageSource() {
// ResourceBundleMessageSource busca archivos por basename en el classpath:
// messages.properties, messages_ru.properties, messages_en.properties, etc.
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
source.setBasename("messages");
source.setDefaultEncoding("UTF-8"); // Importante para que los caracteres rusos se lean correctamente
return source;
}
}
El módulo LocalizationConfig queda junto a él: en él vive el wiring para defaultLocale y LocaleSelector, para que la regla de selección de locale no flote por los servicios.
Ejemplo del AppConfig superior, que lo ensambla todo:
package com.example.contextflow.config;
import com.example.contextflow.config.core.CoreConfig;
import com.example.contextflow.config.localization.LocalizationConfig;
import com.example.contextflow.config.localization.MessageConfig;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false)
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // Listamos explícitamente los módulos que componen la aplicación
public class AppConfig { }
Así AppConfig vuelve a ser un mapa de ensamblaje corto, no un lugar donde metimos todo «por si acaso».
7. Desde el locale hasta el texto listo
Cuando todas las piezas están en su sitio, es útil ver no el árbol de archivos, sino el flujo en tiempo de ejecución.
- El escenario o servicio de aplicación trae el preferredLocale del cliente. Si no está, entra en juego el valor de contextflow.locale.default.
- LocaleSelector selecciona el Locale final, para que la regla de fallback sea única para toda la aplicación.
- OrderPlacementService u otro application-bean invoca a OrderTextService. El propio OrderTextService ya ha sido levantado por el contenedor mediante CoreConfig, porque es un @Service normal en application.i18n.
- OrderTextService solicita a messageSource el mensaje por MessageKeys.ORDER_CREATED y pasa argumentos como orderId.
- El bean messageSource busca el valor adecuado en messages_<locale>.properties, si es necesario retrocede a messages.properties, y la cadena lista se va — a la consola, auditoría u otro adaptador de salida.
Para eso sirve toda esta distribución por paquetes: cada pieza tiene un solo rol, y la cadena se lee de arriba abajo sin adivinar quién tomó el locale y por qué el texto está de repente en otro idioma.
8. Estructura final de ContextFlow
Si nos fijamos específicamente en la parte de i18n y configuración modular de ContextFlow, hay que buscar aquí:
src/main/java/com/example/contextflow/
├── domain/
│ └── model/
│ ├── Order.java
│ └── Customer.java
├── application/
│ ├── service/
│ │ └── OrderPlacementService.java
│ └── i18n/
│ ├── MessageKeys.java
│ ├── OrderTextService.java
│ └── LocaleSelector.java
├── infrastructure/
│ ├── notification/
│ │ └── ConsoleNotificationSender.java
│ ├── audit/
│ │ └── ConsoleAuditWriter.java
│ └── resources/
│ └── NotificationTemplateCatalog.java
└── config/
├── AppConfig.java
├── core/
│ └── CoreConfig.java
└── localization/
├── MessageConfig.java
└── LocalizationConfig.java
Y la parte de recursos que participa específicamente en la localización y selección del locale predeterminado:
src/main/resources/
├── contextflow.properties
├── messages.properties
├── messages_ru.properties
└── messages_en.properties
Para que la imagen esté aún más «en la cabeza», aquí un mini diagrama de interacción:
flowchart LR A[application.service
OrderPlacementService] --> L[application.i18n
LocaleSelector] A --> B[application.i18n
OrderTextService] L --> B B --> C["MessageSource
(bean name: messageSource)"] C --> D[resources
messages*.properties]
Aquí se ve la simple separación: OrderTextService y LocaleSelector viven en la capa de aplicación, messageSource y el wiring de locale — en los módulos de configuración, los bundles — en resources, y las plantillas grandes del tema anterior quedan en templates/ y no se mudan a messages*.properties.
9. Errores típicos al trabajar con i18n
Error n.º 1: las claves se dispersan por el proyecto como «cadenas mágicas».
Las primeras veces escribir "order.created" directamente en getMessage(...) parece normal. Luego aparece "order.create" y "order.created " (con un espacio), y de repente no estás desarrollando, estás haciendo arqueología. Se soluciona de forma aburrida: una clase de constantes MessageKeys en application y la disciplina de «usar solo esa».
Error n.º 2: MessageSource se cuela en el modelo de dominio.
A veces uno quiere hacer en Order un método toLocalizedString(MessageSource, Locale). Parece cómodo, pero es un camino rápido para que el dominio empiece a depender de Spring y las pruebas del dominio se conviertan en levantar el contexto. El dominio debe almacenar hechos, no formas «bonitas de decirlos».
Error n.º 3: los bundles se colocan «en cualquier lado» y el basename no coincide con la ruta real.
Si pones el archivo en src/main/resources/i18n/messages.properties y haces setBasename("messages"), Spring honestamente no encontrará nada. En el proyecto de aprendizaje mantenemos los bundles en la raíz de los recursos, para que el basename sea lo más simple posible: "messages". Cuando sea necesario complicarlo, lo complicaremos conscientemente, no por casualidad.
Error n.º 4: un AppConfig gigante o un escaneo raíz por todo com.example.contextflow.
Mientras el proyecto es pequeño, es tolerable. Cuando se agregan resources, reglas de locale y MessageSource, ese escaneo empieza a arrastrar accidentalmente clases config, y AppConfig a mezclar lo que no se debe. Como resultado, el wiring se vuelve opaco y ya no entiendes qué llegó mediante @Import y qué mediante escaneo. Se soluciona con un escaneo puntual para application / infrastructure y @Import explícito para los módulos de configuración.
Error n.º 5: las claves se hacen «con texto dentro», por ejemplo order created successfully.
Una clave no es una frase, una clave es un código de significado. Debe ser estable, corta y no depender del idioma. Si la clave ya contiene texto en inglés, no estás localizando el sistema, estás jugando a «pon otro texto en inglés». Mejor order.created, y la frase real en los bundles.
GO TO FULL VERSION