Spring Modulith: módulos con fronteras verificadas en un monolito Spring Boot

Un equipo arranca un proyecto Spring Boot con la mejor intención: paquetes por funcionalidad, un servicio por dominio, nada de atajos. Dos años después, OrderService importa el repositorio de productos «porque era más rápido», el módulo de inventario llama a pedidos y pedidos a inventario, y nadie se atreve a mover una clase sin lanzar la batería completa de tests. Nadie decidió que el monolito acabara así. Simplemente, nada lo impedía.
Spring Modulith ataca exactamente ese punto: no te da una arquitectura nueva, te permite comprobar en el build la que ya querías tener.
Este es el primer artículo de una serie sobre Spring Modulith construida sobre un proyecto real. Todo el código, las salidas y el diagrama que aparecen corresponden a un estado concreto del repositorio Datiacode-Tech/spring-modulith-shop: el tag blog-1-modulos. Puedes clonarlo y reproducir cada paso.
Spring Modulith en 30 segundos
- Un módulo es, por defecto, cada subpaquete directo del paquete donde está tu clase @SpringBootApplication. No hay descriptores ni plugins.
- La API de un módulo son los tipos públicos de la raíz de su paquete. Lo que cuelga de sus subpaquetes es interno.
- @NamedInterface publica a propósito un subpaquete concreto, ampliando esa API.
- ApplicationModules.verify() convierte esas reglas en un test: falla si un módulo usa tipos internos de otro o si hay ciclos entre módulos.
- Documenter genera diagramas C4 y fichas por módulo a partir del mismo modelo que se verifica.
- Todo vive en un solo despliegue: sigues teniendo un monolito, pero con fronteras que se pueden comprobar.
En este artículo
El problema: el acoplamiento no se decide, se acumula
En Java, una clase public se puede importar desde cualquier paquete del classpath. El compilador no distingue entre «esta clase es la API de pedidos» y «esta clase es un detalle de implementación que nadie debería tocar». Esa distinción vive en la cabeza del equipo, en una página de la wiki o en un comentario de la code review.
Y lo que solo vive en la cabeza del equipo se erosiona: por plazos, por gente nueva, por un «lo arreglo luego» que nunca llega. El resultado es el monolito en el que todo depende de todo.
La respuesta habitual ha sido saltar a microservicios: si los módulos están en procesos distintos, no pueden importarse clases. Funciona, pero el aislamiento se paga con red, latencia, despliegues múltiples y N proyectos que mantener.
Qué es Spring Modulith y qué no es
Spring Modulith es un proyecto de Spring para diseñar y verificar aplicaciones Spring Boot como monolitos modulares: un único despliegue dividido en módulos con APIs explícitas y dependencias verificables.
No es un framework de microservicios, ni OSGi, ni un sistema de plugins, ni un mecanismo de aislamiento de procesos, ni un modelo de despliegue nuevo. Tus módulos siguen siendo paquetes Java dentro de la misma JVM, con los mismos beans de Spring. Lo que añade Spring Modulith es:
- Un modelo de tu aplicación: qué módulos hay, qué expone cada uno y de quién depende.
- Verificación de ese modelo en un test.
- Documentación generada desde ese modelo.
- Comunicación por eventos fiable entre módulos, tema del siguiente artículo de la serie.
Spring Modulith vs microservicios: dos decisiones distintas
Solemos mezclar dos decisiones: cómo organizas el dominio y cómo lo despliegas. Spring Modulith actúa sobre la primera y deja intacta la segunda.
| Aspecto | Monolito clásico | Monolito modular (Spring Modulith) | Microservicios |
|---|---|---|---|
| Despliegues | 1 | 1 | N |
| Fronteras entre dominios | Por convención | Verificadas en el build | Impuestas por la red |
| Llamada entre módulos | Método | Método o evento | HTTP o mensajería |
| Transacciones | Locales | Locales | Locales por servicio; coordinación distribuida o sagas |
| Coste operativo | Bajo | Bajo | Alto |
| Extraer un módulo a servicio | Doloroso | Más sencillo si las fronteras están bien definidas | Ya está hecho |
Modularidad y distribución son decisiones diferentes. Primero se ponen fronteras sólidas dentro del monolito; si un módulo crece tanto que justifica un despliegue propio, ya tiene trazada la frontera, que es la parte difícil de sacarlo. Spring Modulith no sustituye a los microservicios: hace que la decisión de distribuir se tome cuando de verdad hace falta.
El proyecto de ejemplo: Spring Modulith Shop
Para que todo sea concreto, el repositorio implementa una tienda mínima. En el tag blog-1-modulos tiene tres módulos de negocio y uno transversal:
catalog: qué se vende y a qué precio.
inventory: cuántas unidades hay en el almacén.
order: los pedidos. Para crear uno necesita el nombre y el precio de cada producto, así que consulta a catalog.
shared: configuración transversal, como un Clock y el manejador global de errores.
No hay frontend ni pagos: solo lo justo. La serie avanza por tags del mismo repositorio: módulos y fronteras (este artículo), eventos y fiabilidad, externalización con Kafka y, por último, Spring AI como un módulo más.
Qué es un módulo en Spring Modulith
Lo primero que sorprende de Spring Modulith es lo poco exótico que es. Este es el árbol real del proyecto en el tag blog-1-modulos:
Ver en texto · Estructura de paquetes en el tag blog-1-modulos
com.datiacode.shop
├── ShopApplication.java ← @SpringBootApplication
├── catalog
│ ├── CatalogService.java ← API pública
│ ├── model ← interfaz con nombre catalog::model
│ │ ├── ProductInfo.java
│ │ └── package-info.java ← @NamedInterface("model")
│ └── internal
│ ├── DefaultCatalogService.java
│ ├── Product.java
│ └── ProductRepository.java
├── inventory
│ ├── InventoryService.java ← API pública
│ └── internal
│ ├── DefaultInventoryService.java
│ ├── StockItem.java
│ └── StockItemRepository.java
├── order
│ ├── OrderService.java ← API pública
│ ├── NewOrder.java, OrderDetails.java, OrderCompleted.java
│ ├── UnknownProductException.java, OrderNotFoundException.java
│ └── internal
│ ├── DefaultOrderService.java
│ ├── OrderController.java ← controlador REST
│ ├── Order.java, OrderRepository.java, OrderLine.java, OrderStatus.java
│ └── CreateOrderRequest.java, OrderResponse.java, OrderExceptionHandler.java
└── shared
├── config
│ └── ClockConfig.java
└── web
└── GlobalExceptionHandler.javaLa regla por defecto admite variantes que describe la documentación de fundamentos: considerar módulos solo los paquetes anotados con @ApplicationModule, añadir paquetes raíz con @Modulithic o declarar módulos anidados. Este proyecto usa la detección por defecto. Y un detalle del árbol: el controlador REST vive en order.internal. La API de un módulo no es su API REST: es lo que otros módulos pueden usar desde Java.
API pública frente a internal
La API modular define qué tipos Java puede usar otro módulo. Por defecto, Spring Modulith considera API de un módulo los tipos públicos que están en la raíz de su paquete; los subpaquetes son internos y el código de otros módulos no debe referenciarlos.
El nombre internal es una convención, no una palabra reservada: cualquier subpaquete es interno salvo que se publique. Usarlo hace evidente la intención al abrir el proyecto.
package com.datiacode.shop.catalog;
/**
* Public API of the catalog module: what is sold and at which price.
*/
public interface CatalogService {
Optional<ProductInfo> findProduct(String sku);
}@NamedInterface: publicar un subpaquete a propósito
ProductInfo vive en catalog.model, un subpaquete. Por la regla anterior sería interno y order no podría usarlo. Para publicarlo hay que declararlo explícitamente con @NamedInterface en el package-info.java del paquete.
Con eso, catalog expone dos interfaces: la raíz, que Spring Modulith llama interfaz sin nombre (<<UNNAMED>>), y la interfaz con nombre model. Desde fuera se refieren como catalog y catalog::model, y el resumen del módulo que genera el propio Spring Modulith lo confirma:
/**
* Read-only product data that the catalog module shares with other modules.
* <p>
* Exposed as the named interface {@code catalog::model}: a sub-package that would otherwise be internal to the
* module.
*/
@org.springframework.modulith.NamedInterface("model")
package com.datiacode.shop.catalog.model;package com.datiacode.shop.catalog.model;
public record ProductInfo(String sku, String name, BigDecimal unitPrice) {
}# Catalog
> Logical name: catalog
> Base package: com.datiacode.shop.catalog
> Excluded packages: none
> Named interfaces:
+ NamedInterface: name=<<UNNAMED>>, types=[ c.d.s.c.CatalogService ]
+ NamedInterface: name=model, types=[ c.d.s.c.m.ProductInfo ]
> Spring beans:
o ….internal.DefaultCatalogService
o ….internal.ProductRepositoryCómo se comunican los módulos: llamar o avisar
Como todos los módulos viven en la misma JVM, no hay HTTP entre ellos: se comunican por llamada directa o por evento, como resume la tabla.
En el proyecto, order llama directamente a catalog porque sin nombre y precio no puede guardar el pedido:
@Service
@Transactional
class DefaultOrderService implements OrderService {
private final OrderRepository orders;
private final CatalogService catalog;
// ...
private OrderLine toLine(NewOrder.Item item) {
return catalog.findProduct(item.sku())
.map(product -> new OrderLine(product.sku(), product.name(), item.quantity(), product.unitPrice()))
.orElseThrow(() -> new UnknownProductException(item.sku()));
}
}| Forma | Cuándo | En el proyecto |
|---|---|---|
| Llamada directa a la API pública de otro módulo | Necesitas una respuesta ahora para continuar | order pide a catalog nombre y precio antes de guardar el pedido |
| Evento | Comunicas que algo ha ocurrido y no te importa quién reacciona | order publica OrderCompleted; la reacción llega en el siguiente artículo |
DefaultOrderService es package-private y vive en order.internal. Lo que ve el resto de la aplicación es la interfaz OrderService.
Caso real: el ciclo que obligó a partir un módulo
El primer diseño del proyecto no tenía catalog. Había un único módulo inventory con productos, precios y stock, y order le preguntaba si había unidades antes de aceptar el pedido.
El problema apareció al planificar el siguiente paso: cuando se acepta un pedido, inventory tiene que descontar stock, así que escucha el evento OrderCompleted de order. Eso es una dependencia de inventory hacia order. Con la consulta de stock en sentido contrario, el resultado era un ciclo:
order ──(consulta stock)──▶ inventory
▲ │
└──(escucha OrderCompleted)──┘- verify() rechaza los ciclos entre módulos: la documentación exige que las dependencias formen un grafo dirigido acíclico. Y con razón: dos módulos que dependen el uno del otro no se pueden entender, probar ni extraer por separado. Son un único módulo con dos nombres.
- La solución fue separar lo que se vende (catalog: nombre y precio) de lo que hay en el almacén (inventory: unidades). Ahora order depende de catalog e inventory de order, sin ciclo.
- El cambio está en el historial del repositorio, en el commit «separa catalog de inventory para evitar el ciclo con order».
El precio de romper el ciclo: consistencia eventual
Lo valioso es que la herramienta obligó a tomar la decisión de forma explícita, en lugar de dejar que el ciclo se instalara en silencio. Y el proyecto la fija en un test, como se ve más abajo.
ApplicationModules.verify(): convertir la arquitectura en un test
Lo que convierte esas convenciones en reglas es este test:
class ModularityTest {
static final ApplicationModules MODULES = ApplicationModules.of(ShopApplication.class);
@Test
void verifiesModularStructure() {
MODULES.verify();
}
}- ApplicationModules.of(...) analiza las clases compiladas a partir del paquete de ShopApplication y construye el modelo de módulos.
- verify() valida ese modelo y lanza una excepción con todas las violaciones encontradas.
- No arranca el contexto de Spring ni necesita base de datos: se ejecuta como un test unitario más, con ./mvnw test en local y en cualquier pipeline de CI.
- Si alguien rompe una frontera, el build falla, igual que fallaría un test de negocio.
| verify() falla si… | Ejemplo |
|---|---|
| Un módulo usa un tipo no expuesto de otro | order inyecta catalog.internal.ProductRepository |
| Hay un ciclo entre módulos, directo o indirecto | order → inventory → order |
| Un módulo depende de otro no incluido en sus dependencias permitidas, si las declara | @ApplicationModule(allowedDependencies = "catalog") en el package-info.java de order |
Son las reglas que enumera la documentación de verificación. Si jMolecules está en el classpath, verify() dispara además sus reglas de arquitectura; este proyecto no lo usa. En la ejecución con la que se preparó este artículo, los tres tests de ModularityTest tardaron 0,99 s; al repetirlos nosotros dentro de un contenedor Docker en un servidor compartido, entre 1,8 y 1,9 s. Es una medida de este proyecto de ejemplo, no una promesa: en una base de código grande el análisis tardará más.
Spring Modulith vs ArchUnit: ¿son alternativas?
No. Spring Modulith utiliza ArchUnit internamente para parte de la verificación estructural —ApplicationModules importa las clases con ArchUnit, que es dependencia de spring-modulith-core—, pero añade un modelo explícito de módulos, APIs y dependencias que reutiliza para documentar y para las funcionalidades de runtime.
ArchUnit sirve para escribir reglas de arquitectura generales sobre cualquier código Java; Spring Modulith construye además un modelo específico de los módulos de una aplicación Spring Boot. Pueden coexistir y complementarse: ArchUnit para reglas propias y verify() para las fronteras entre módulos.
Romper una frontera a propósito: la rama demo/verify-roto
El repositorio tiene una rama, demo/verify-roto, que hace exactamente lo que no se debe: un componente de order que usa el repositorio interno de catalog para ahorrarse la API.
package com.datiacode.shop.order.internal;
import org.springframework.stereotype.Component;
import com.datiacode.shop.catalog.internal.ProductRepository;
/**
* DEMO ONLY: reaches into catalog's internals instead of using CatalogService.
*/
@Component
class StockShortcut {
private final ProductRepository products;
StockShortcut(ProductRepository products) {
this.products = products;
}
long countProducts() {
return products.count();
}
}[ERROR] Tests run: 3, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 1.891 s <<< FAILURE! -- in com.datiacode.shop.ModularityTest
[ERROR] com.datiacode.shop.ModularityTest.verifiesModularStructure -- Time elapsed: 0.077 s <<< ERROR!
org.springframework.modulith.core.Violations:
- Module 'order' depends on non-exposed type com.datiacode.shop.catalog.internal.ProductRepository within module 'catalog'!
StockShortcut declares constructor StockShortcut(ProductRepository) in (StockShortcut.java:0)
- Module 'order' depends on non-exposed type com.datiacode.shop.catalog.internal.ProductRepository within module 'catalog'!
Method <com.datiacode.shop.order.internal.StockShortcut.countProducts()> calls method <com.datiacode.shop.catalog.internal.ProductRepository.count()> in (StockShortcut.java:20)
- Module 'order' depends on non-exposed type com.datiacode.shop.catalog.internal.ProductRepository within module 'catalog'!
Field <com.datiacode.shop.order.internal.StockShortcut.products> has type <com.datiacode.shop.catalog.internal.ProductRepository> in (StockShortcut.java:0)
- Module 'order' depends on non-exposed type com.datiacode.shop.catalog.internal.ProductRepository within module 'catalog'!
Constructor <com.datiacode.shop.order.internal.StockShortcut.<init>(com.datiacode.shop.catalog.internal.ProductRepository)> has parameter of type <com.datiacode.shop.catalog.internal.ProductRepository> in (StockShortcut.java:0)
...
[INFO] BUILD FAILUREEl fallo no es conceptual: el build identifica el módulo, el tipo prohibido y cada punto de uso —el constructor, el campo, el parámetro y la llamada al método, con su línea—. No hace falta interpretar nada. La misma salida está guardada en el repositorio, en docs/verify-error.txt.
Package-private y verify(): doble protección
Hay un matiz en esa rama fácil de pasar por alto. En el proyecto, las clases de internal son package-private, así que Java ya impide la mayoría de los accesos ilegales en compilación, sin Spring Modulith. Para que el atajo compile, hubo que cambiar dos líneas en catalog:
-class Product {
+public class Product {
-interface ProductRepository extends JpaRepository<Product, Long> {
+public interface ProductRepository extends JpaRepository<Product, Long> {- Clases internas que tienen que ser públicas porque las usa otro subpaquete del mismo módulo (internal.web usando internal.domain, por ejemplo).
- Proxies, mappers generados o frameworks que exigen visibilidad pública.
- Los ciclos, que la visibilidad de Java no detecta en absoluto.
- El cambio inocente de class a public class en un pull request, que es exactamente lo que muestra la rama.
Tests de arquitectura propios con ApplicationModules
verify() aplica las reglas generales. Pero el modelo que construye ApplicationModules también se puede consultar como un modelo de la arquitectura, y eso permite fijar decisiones de diseño concretas del proyecto.
@Test
void exposesCatalogModelAsNamedInterface() {
var catalog = MODULES.getModuleByName("catalog").orElseThrow();
assertThat(catalog.getNamedInterfaces().getByName("model")).isPresent();
}
@Test
void orderDoesNotDependOnInventory() {
var order = MODULES.getModuleByName("order").orElseThrow();
// "Pedidos ya no conoce a inventario": inventory listens to order's events, never the other way round
assertThat(order.getAllDependencies(MODULES).containsModuleNamed("inventory")).isFalse();
}El segundo test es la decisión del caso del ciclo convertida en una prueba ejecutable. Si dentro de un año alguien vuelve a hacer que order consulte a inventory, el test falla con un nombre que explica por qué no se debe.
Documenter: documentación de arquitectura generada desde el código
La documentación de arquitectura tiene un problema conocido: a los tres meses deja de parecerse al código. Spring Modulith la genera a partir del mismo modelo que verifica, con el Documenter. El objetivo no es sustituir toda la documentación arquitectónica, sino evitar que la descripción estructural quede obsoleta respecto al código.
@Tag("documentation")
class DocumentationTest {
@Test
void writesModuleDocumentation() throws IOException {
var modules = ApplicationModules.of(ShopApplication.class);
new Documenter(modules, Options.defaults().withOutputFolder("docs/modules"))
.writeDocumentation()
.writeModulesAsPlantUml();
// Canvases do not list named interfaces; the module description does
var summary = modules.stream().map(Object::toString).collect(Collectors.joining(System.lineSeparator()));
Files.writeString(Path.of("docs/modules/modules.txt"), summary);
}
}| Archivo en docs/modules | Contenido |
|---|---|
| components.puml | Diagrama C4 de componentes con los módulos y sus dependencias |
| module-<nombre>.puml | Un diagrama por módulo con sus vecinos |
| module-<nombre>.adoc | Un canvas por módulo: paquete base, servicios, referencias a beans de otros módulos y eventos que escucha |
| all-docs.adoc | Un índice que agrupa diagramas y canvases |
| modules.txt | El resumen de texto de cada módulo, named interfaces incluidas; lo escribe el propio test |
Generar la documentación sin ensuciar cada build
No conviene regenerar la documentación en cada ejecución de tests: ensuciaría el directorio de trabajo y alargaría el build. Por eso el test lleva @Tag("documentation"), el pom.xml lo excluye por defecto y un perfil de Maven lo activa:
<properties>
<excludedGroups>documentation</excludedGroups>
</properties>
<profiles>
<profile>
<id>docs</id>
<properties>
<excludedGroups/>
<groups>documentation</groups>
</properties>
</profile>
</profiles>Con eso, ./mvnw test no lo ejecuta y ./mvnw test -Pdocs regenera la documentación. Encaja bien en un pipeline de release.
El diagrama real del tag blog-1-modulos
Este es el diagrama de componentes que genera el Documenter sobre el tag: no es una ilustración, es la salida tal cual.

Lo que el Documenter no cuenta: limitaciones observadas
La documentación generada es fiable en lo que muestra, pero no lo muestra todo. Probándolo sobre este proyecto con Spring Modulith 2.1.1 y las opciones por defecto, encontramos tres huecos; contrastados con la documentación oficial y con la propia librería, los tres tienen explicación.
- Lo primero es un valor por defecto del diagrama, no un límite. En la 2.1.1, las DiagramOptions que recibe writeModulesAsPlantUml admiten withElementsWithoutRelationships con el valor VISIBLE, y con esa opción, sobre este mismo tag, inventory y shared aparecen. El tag no lo usa: lo comprobamos aparte para este artículo.
- Lo segundo coincide con la documentación: entre las secciones que enumera para el canvas no figuran las named interfaces.
- Lo tercero también tiene explicación oficial: el canvas solo lista como publicados los eventos marcados con @DomainEvent de jMolecules o que implementan su interfaz DomainEvent. OrderCompleted es un record sin esas marcas, así que no aparece.
Nada de esto invalida la herramienta. Pero si alguien usa el diagrama para decidir qué módulos existen, conviene que sepa que, con las opciones por defecto, faltan dos.
/actuator/modulith: el modelo modular en tiempo de ejecución
La aplicación puede exponer el mismo modelo en /actuator/modulith, en JSON, para inspeccionar en runtime el modelo modular que Spring Modulith ha detectado sin abrir el código. Hacen falta tres piezas: la dependencia spring-modulith-actuator, Spring Boot Actuator —normalmente con spring-boot-starter-actuator— y la exposición del endpoint. El proyecto tiene las tres: ambas dependencias en el pom.xml y management.endpoints.web.exposure.include=health,info,modulith.
{
"catalog": {
"displayName": "Catalog",
"basePackage": "com.datiacode.shop.catalog",
"nested": [],
"type": "closed",
"shared": false,
"dependencies": []
},
"order": {
"displayName": "Order",
"basePackage": "com.datiacode.shop.order",
"nested": [],
"type": "closed",
"shared": false,
"dependencies": [
{
"target": "catalog",
"types": [
"USES_COMPONENT"
]
}
]
}
}La respuesta completa lista los cuatro módulos, también inventory y shared, que el diagrama por defecto omite; no incluye las named interfaces. El formato está descrito en la documentación de funcionalidades de producción. Como cualquier endpoint de Actuator, /actuator/modulith solo estará disponible si Spring Boot Actuator está incluido y el endpoint se ha expuesto mediante la configuración correspondiente, y exponerlo es una decisión que conviene tomar con criterio.
Errores habituales con Spring Modulith
| Error | Por qué es un problema | Qué hacer |
|---|---|---|
| Poner la clase @SpringBootApplication en un paquete que no es la raíz | La detección por defecto parte de ese paquete: los módulos que calcula no son los que esperas | Clase principal en el paquete raíz y módulos justo debajo |
| Crear módulos por capa (controller, service, repository) | Cada «módulo» depende de los demás y verify() se llena de ciclos | Módulos por funcionalidad de negocio |
| Exponer entidades JPA en la API | Acopla a otros módulos con tu esquema de base de datos | Records o DTO pensados para fuera |
| Usar @NamedInterface para silenciar a verify() | La frontera deja de significar algo | Pregúntate si el tipo pertenece a la API o si el módulo está mal dividido |
| Hacer todo public «por si acaso» | Pierdes la verificación más barata: el compilador | Package-private por defecto en internal |
| No ejecutar ModularityTest en CI | Las reglas solo se cumplen cuando alguien se acuerda | Es un test unitario más: va en el build normal |
| Creer que el diagrama generado lo cuenta todo | Con las opciones por defecto faltan los módulos aislados, y los canvases no muestran named interfaces | Contrástalo con modules.txt o con /actuator/modulith |
Cuándo utilizar Spring Modulith
Tiene sentido especialmente cuando hay un monolito Spring Boot con varios dominios, varios desarrolladores o equipos, riesgo real de acoplamiento o la posibilidad de extraer algún módulo en el futuro.
Probablemente aporta menos cuando la aplicación es pequeña y de un solo dominio, cuando necesitas aislamiento real de procesos —todos los módulos comparten JVM— o cuando ya operas servicios bien delimitados y el problema está en otro sitio.
Adoptarlo en un proyecto existente es incremental:
- Añadir la dependencia
- Crear ModularityTest
- Ejecutar verify()
- Usar los fallos como diagnóstico
La primera ejecución suele ser el mejor diagnóstico del estado real de la arquitectura.
Conclusión
Spring Modulith no inventa la modularidad: organizar por funcionalidad y ocultar los detalles internos es tan antiguo como la orientación a objetos. Lo que hace es convertir en ejecutables las fronteras que normalmente quedan escritas en una convención, en un diagrama o en la memoria del equipo: pasan a ser un test que falla.
Microservicios no es «el siguiente nivel» de un monolito; es una decisión de despliegue. Un monolito con fronteras verificadas te deja tomarla cuando de verdad la necesitas, y no antes.
En el siguiente artículo de la serie, los módulos dejan de llamarse y empiezan a avisarse: eventos entre módulos, el Event Publication Registry y qué pasa cuando la aplicación se cae a mitad de un evento. Y si te interesa por qué esta disciplina pesa todavía más cuando el sistema incorpora IA, lo planteamos en IA en Java con Spring AI y Spring Modulith.
Fuentes técnicas
- Spring Modulith — Reference Documentation
- Spring Modulith — Fundamentals
- Spring Modulith — Verifying Application Module Structure
- Spring Modulith — Documenting Application Modules
- Spring Modulith — Production-ready Features
- Spring Modulith — Runtime Support
- Spring Modulith — Appendix: compatibilidad y propiedades de configuración
- ArchUnit
- Repositorio del ejemplo: Datiacode-Tech/spring-modulith-shop, tag blog-1-modulos
Preguntas frecuentes
¿Qué es Spring Modulith?
Es un proyecto de Spring para diseñar y verificar aplicaciones Spring Boot como monolitos modulares: un único despliegue dividido en módulos con APIs explícitas y dependencias verificables. Construye un modelo de la aplicación, lo verifica en un test y genera documentación a partir de él.
¿Spring Modulith funciona con Spring Boot 3?
Sí, con la línea 1.x: la tabla de compatibilidad del apéndice oficial asocia Spring Modulith 1.1 a 1.4 con Spring Boot 3.2 a 3.5. Este artículo usa Spring Modulith 2.1.1 con Spring Boot 4.1.1. Elige la versión según esa tabla, no al revés.
¿Spring Modulith sustituye a los microservicios?
No. Sigues teniendo un único despliegue y todos los módulos comparten JVM. Lo que hace es darte fronteras verificadas dentro del monolito, de forma que la decisión de distribuir un módulo se pueda tomar más adelante, con su frontera ya trazada.
¿Tengo que reorganizar todo el proyecto para empezar?
No. Añade la dependencia y un test con ApplicationModules.of(...).verify(). Lo que falle te dice dónde están los problemas, y puedes ir corrigiendo módulo a módulo.
¿verify() ralentiza el build?
En este proyecto, los tres tests de ModularityTest tardaron 0,99 s en la ejecución con la que se preparó el artículo, y entre 1,8 y 1,9 s al repetirlos en Docker. No arrancan Spring: se comportan como un test unitario pesado, no como uno de integración.
¿Qué diferencia hay entre internal y cualquier otro subpaquete?
Ninguna para Spring Modulith: todo subpaquete es interno salvo que lo publiques con @NamedInterface. internal es solo una convención para que la intención sea evidente.
¿Puedo restringir de qué módulos depende cada módulo?
Sí, con @ApplicationModule(allowedDependencies = ...) en el package-info.java del módulo. A partir de ahí, verify() también falla si aparece una dependencia no declarada. Para permitir solo una named interface se usa la forma «catalog :: model». El proyecto de ejemplo no lo usa todavía.
¿El Documenter sustituye a la documentación de arquitectura?
Sustituye a la parte que describe la estructura, que es la que antes se desactualizaba. Las decisiones —por qué catalog e inventory son módulos distintos, por ejemplo— siguen necesitando texto escrito por alguien.
¿Se puede verificar la estructura al arrancar la aplicación?
Sí: con spring-modulith-runtime en el classpath y la propiedad spring.modulith.runtime.verification-enabled=true, que por defecto es false, la aplicación verifica los módulos al arrancar y se detiene si hay violaciones. El proyecto incluye esa dependencia pero no activa la propiedad: el sitio natural para verificar es el test, porque el error aparece antes de desplegar.
Sigue leyendo
Ver todos los artículos- Leer artículo
Spring AI · ArquitecturaDesarrollo con IA11 minDesarrollar IA en Java con Spring AI: por qué Spring Modulith es la pieza que falta
- Leer artículo
Desarrollo con IA · JavaDesarrollo con IA12 minStructured Output con Spring AI: de texto a objetos Java fiables
- Leer artículo
Desarrollo con IA · JavaDesarrollo con IA15 minTool Calling con Spring AI: cómo conectar un LLM con tu backend Java
