====== 04 - Monolito modular: módulos y Spring Modulith ======
En un **monolito modular**, cada módulo protege su interior y expone un contrato. Este tema define qué vive dentro de un módulo, cómo ofrece su **API pública**, cómo la consume otro módulo y cómo se verifica que se respetan los límites. Los conceptos son independientes del framework; **Spring Modulith** aparece como la herramienta que deriva el modelo de módulos de la organización en paquetes y automatiza las comprobaciones.
===== 1. Casos de uso y coordinación =====
Un **caso de uso** coordina una operación completa de la aplicación: obtiene las entradas, invoca los servicios necesarios y organiza el resultado. Expresa un flujo de aplicación; no debe contener las reglas internas de los modelos ni sustituir a los servicios de dominio. No invoca otro caso de uso: cada caso de uso es un punto de entrada independiente al comportamiento de la aplicación.
@startuml
class Controller
class "CancelOrder" as UseCase
class OrderService
Controller --> UseCase : invoca
UseCase --> OrderService : coordina
@enduml
Invocar un caso de uso desde otro acopla flujos que deberían poder componerse y reutilizarse de forma independiente. Si varios flujos necesitan una misma regla, se comparte mediante un servicio de dominio, no encadenando casos de uso.
==== 1.1 Servicio o caso de uso: ¿quién coordina? ====
Una colaboración pertenece a un **servicio** cuando forma parte de su comportamiento de dominio de manera consistente. Si depende del flujo concreto que se está ejecutando, la coordinación corresponde al **caso de uso**. Los servicios pueden colaborar con otros servicios; si necesitan otro módulo, usan su API pública.
^ Situación ^ Responsable de coordinar ^
| Cada cancelación debe liberar la reserva de stock | El servicio de pedidos colabora con la API pública de stock en cada cancelación |
| Solo un flujo específico envía una notificación adicional al cancelar | El caso de uso de ese flujo invoca la API pública correspondiente |
Toda colaboración entre módulos cruza el límite mediante la API pública del módulo requerido, no mediante sus servicios privados. El criterio de “siempre” distingue el comportamiento estable de un servicio de una decisión propia de un flujo, pero no es una regla mecánica: las invariantes del dominio deben quedar protegidas aunque un flujo sea su único consumidor.
===== 2. Dentro del módulo =====
==== 2.1 Modelos anémicos y enriquecidos ====
Un **modelo anémico** representa datos y deja las reglas en otros componentes. Un **modelo enriquecido** combina estado y comportamiento: protege sus invariantes y ofrece operaciones que expresan acciones del negocio.
// Modelo anémico: estructura de datos
public record OrderData(long id, OrderStatus status) {}
// Modelo enriquecido: estado y comportamiento
public class Order {
private OrderStatus status;
public void cancel() {
if (status != OrderStatus.PENDING) {
throw new OrderCannotBeCancelledException();
}
status = OrderStatus.CANCELLED;
}
}
^ **Estilo** ^ **Ventajas** ^ **Costes y límites** ^
| **Anémico** | Estructura sencilla; puede compartirse como datos entre componentes | El comportamiento queda fuera del objeto; compartir el mismo tipo entre capas acopla contratos y dispersa reglas |
| **Enriquecido** | Reglas e invariantes junto al estado que protegen | Se necesita DTO y mapeo para separar el modelo interno de los contratos externos |
Los estilos se combinan según la complejidad del dominio. Complementan a los simples **value objects** (tema 03): el modelo enriquecido mantiene invariantes que implican varias propiedades y transiciones de estado.
==== 2.2 Servicios con contratos de DTO ====
Un **servicio de dominio** expresa una operación que combina varios modelos o una regla que no pertenece naturalmente a uno solo. Cuando su contrato cruza límites entre capas o módulos, se expresa con **DTO**: lo que cruza el límite son datos explícitos; el modelo enriquecido queda dentro.
public record CancelOrderDto(long orderId) {}
public record OrderSummaryDto(long id, OrderStatus status) {}
public interface OrderService {
OrderSummaryDto cancel(CancelOrderDto request);
}
public class OrderServiceImpl implements OrderService {
private final OrderRepository orders;
private final OrderMapper mapper;
public OrderServiceImpl(OrderRepository orders, OrderMapper mapper) {
this.orders = orders;
this.mapper = mapper;
}
public OrderSummaryDto cancel(CancelOrderDto request) {
Order order = orders.findById(request.orderId())
.orElseThrow(OrderNotFoundException::new);
order.cancel();
return mapper.toSummary(order);
}
}
**MapStruct** genera el código repetitivo de los mapeadores entre DTO y modelos; el mapeo transforma datos, no aplica reglas. Su construcción se resuelve en la composición de la aplicación, sin añadir dependencias de Spring al dominio:
@Mapper
public interface OrderMapper {
OrderSummaryDto toSummary(Order order);
}
El **repositorio** completa el cuadro: la interfaz se define en ''domain'' y la implementación vive en ''persistence'', la misma **inversión de dependencias** vista en el tema 01; su detalle con JPA se desarrolla en el tema 05.
===== 3. La API pública del módulo =====
Cuando un módulo necesita capacidades de otro, utiliza su **API pública interna**. No accede a los servicios privados ni a los modelos internos del módulo proveedor.
@startuml
package "Módulo de pedidos" {
class CancelOrderUseCase
class OrderService
}
package "Módulo de stock" {
interface StockModuleApi
class StockService
class StockModel
}
CancelOrderUseCase --> OrderService : coordina
OrderService --> StockModuleApi : siempre libera la reserva
CancelOrderUseCase ..> StockModuleApi : este flujo solicita otra operación
StockModuleApi --> StockService : delega internamente
StockService --> StockModel
@enduml
La API pública interna define un contrato entre módulos, normalmente una interfaz Java y sus DTO: no implica una llamada HTTP ni convierte el monolito modular en una aplicación distribuida. Un módulo puede necesitar varios contratos: la **interfaz de módulo** para otros módulos del sistema y, aparte, el **contrato HTTP** para clientes externos. Son decisiones distintas: la API pública interna no obliga a exponer la operación por HTTP, ni al revés.
El contrato entre módulos es una decisión de diseño, no una consecuencia de la tecnología. Igual que una API HTTP se diseña pensando en sus consumidores, la API pública interna se diseña pensando en los módulos que la necesitarán, sin exponer automáticamente lo interno.
==== 3.1 El consumidor declara su propio contrato ====
El módulo consumidor no tiene por qué importar el contrato del proveedor tal cual: puede definir en su ''domain'' una interfaz con los datos que él necesita —con sus propios ''record'' internos— y una implementación en su ''persistence'' que llama a la API pública del otro módulo y traduce los resultados:
// domain del módulo de pedidos: lo que este módulo necesita del stock
public interface StockSupply {
record Reservation(long orderId, int units) {}
Reservation reserve(long orderId, int units);
}
// persistence del módulo de pedidos: traduce entre su contrato y el del stock
@Named
class StockSupplyAdapter implements StockSupply {
private final StockModuleApi stock;
public Reservation reserve(long orderId, int units) {
var reserved = stock.reserve(new StockReserveDto(orderId, units));
return new Reservation(reserved.orderId(), reserved.units());
}
}
Así, los tipos internos del proveedor no se filtran al consumidor, y un cambio en el contrato del proveedor se absorbe en un único punto. Es un límite más: consumir la API pública **y no propagarla** hacia el interior propio.
==== 3.2 DTO: los tipos que cruzan el límite ====
Los tipos que la API pública intercambia son parte del contrato: ''record'' simples, sin comportamiento, pensados para delimitar el dato que cada uso necesita. Los modelos enriquecidos no se exponen a través de la API pública: quien los necesita accede a ellos dentro del propio módulo.
Un contrato con DTO simples se documenta y se prueba; un contrato que devuelva modelos enriquecidos acopla a quien consume a la evolución de esos modelos.
===== 4. Estructura del módulo en paquetes =====
En **Java**, el mecanismo natural para expresar los límites es la organización en paquetes. Cada módulo separa su **API pública** de la implementación interna (''internal''):
com.example
├── Application.java (raíz de la aplicación)
├── app (raíz de composición: configuración y cableado)
├── orders (paquete raíz del módulo)
│ ├── package-info.java (modelo de módulo: dependencias e interfaz expuesta)
│ ├── api
│ │ ├── facade (API pública: interfaz y DTO del contrato)
│ │ └── http (contrato HTTP: controllers y DTO web)
│ └── internal
│ ├── application (casos de uso)
│ ├── domain (modelos, servicios, excepciones, interfaz de repositorio)
│ ├── infrastructure (piezas técnicas: hash, tokens, clientes externos)
│ └── persistence (entidades, DAO, repositorio e implementación, mapeo)
└── stock (misma estructura)
El compilador no impide por sí solo que el código de un módulo acceda a los tipos públicos del ''internal'' de otro. Los límites se expresan con la estructura y se verifican con pruebas de estructura, como se hace con **ArchUnit** desde 1.º DAW.
Que el código compile no significa que se respeten los módulos. La estructura de paquetes expresa los límites; las verificaciones automatizadas los imponen.
===== 5. Verificación de los límites =====
Las reglas del monolito modular son verificables de forma automatizada:
* **Sin ciclos**: el grafo de dependencias entre módulos debe ser acíclico.
* **Solo por la API pública**: un módulo no puede referirse a los tipos de ''internal'' de otro.
* **Dependencias declaradas**: cada módulo declara de qué otros módulos depende.
==== 5.1 Reglas ArchUnit ====
@AnalyzeClasses(packages = "com.example")
class ModuleBoundariesTest {
@ArchTest
static void modulesOnlyUsePublicContracts(JavaClasses classes) {
noClasses().that().resideOutsideOfPackage("..orders..")
.should().dependOnClassesThat()
.resideInAPackage("..orders.internal..")
.check(classes);
}
@ArchTest
static void domainStaysFrameworkFree(JavaClasses classes) {
noClasses().that().resideInAPackage("..orders..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..",
"jakarta.validation..", "org.hibernate..", "org.mapstruct..")
.check(classes);
}
}
==== 5.2 Spring Modulith ====
**Spring Modulith** deriva el modelo de módulos de la estructura en paquetes y verifica las reglas anteriores sin escribir cada una a mano. Cada paquete hijo directo del paquete raíz es un módulo; un módulo declara su nombre, sus dependencias y qué subpaquetes expone mediante anotaciones en el ''package-info.java'':
@org.springframework.modulith.ApplicationModule(
displayName = "Orders",
allowedDependencies = "stock"
)
package com.example.orders;
Por defecto, la API de un módulo con subpaquetes es su **paquete raíz**: todos los subpaquetes quedan fuera de la API y otros módulos no deben usarlos. Para exponer un subpaquete concreto se declara una **interfaz con nombre**; en la estructura del epígrafe anterior, el subpaquete ''api.facade'' sería la superficie consumible por otros módulos:
@org.springframework.modulith.NamedInterface("facade")
package com.example.orders.api.facade;
La verificación completa es una prueba más:
@Test
void moduleStructureIsValid() {
ApplicationModules.of(Application.class).verify();
}
Si se detecta alguna infracción, ''verify()'' lanza una excepción describiendo la violación. El modelo de módulos detectado también se puede inspeccionar y es la fuente de la **documentación** que la herramienta genera:
ApplicationModules modules = ApplicationModules.of(Application.class);
modules.printDocumentation(); // los diagramas también pueden exportarse a fichero
El diagrama de módulos generado por la herramienta se revisa en equipo en cada ejecución de las pruebas de estructura. El detalle del modelo de módulos y las formas de verificación están desarrollados en [[documentacion:spring-modulith-fundamentals|Fundamentos]] y [[documentacion:spring-modulith-verification|Verificación de la estructura modular]].
===== 6. La raíz de composición =====
Los componentes del módulo pueden registrarse con estereotipos de **Spring** (''@Service'', ''@Repository'', ''@Component'') o mantenerse sin anotaciones de Spring y resolver el cableado en un único punto: la **raíz de composición**, un paquete ''app'' con visibilidad sobre todos los módulos:
@Configuration
class OrdersModuleConfig {
@Bean
OrderServiceImpl orderServiceImpl(OrderRepository orders, OrderMapper mapper) {
return new OrderServiceImpl(orders, mapper);
}
// La misma instancia hace de service privado y de facade público:
@Bean
OrderService orderService(OrderServiceImpl impl) {
return impl; // cara privada del módulo
}
@Bean
OrdersFacade ordersFacade(OrderServiceImpl impl) {
return impl; // cara pública: el contrato consumible por otros módulos
}
}
Los componentes internos —casos de uso, modelos, mapeadores de persistencia— son clases planas con las anotaciones estándar de **Jakarta** (''@Named'', ''@Inject''), sin depender de Spring. La raíz de composición sí usa Spring: es infraestructura de la aplicación, no del dominio. Es además **la única pieza que puede ver todos los módulos**; a cambio hay que evitar la tentación de escribir ahí lógica de negocio.
Exponer la misma instancia con dos caras (servicio privado y facade pública) evita duplicar implementaciones y dispersar anotaciones de Spring por el módulo; la raíz de composición centraliza el conocimiento de qué implementaciones están en uso.
===== 7. Pruebas de casos de uso y módulos =====
Probar el caso de uso como coordinador:
* Verificar qué servicios y APIs públicas invoca para cada flujo.
* Comprobar los datos enviados y la gestión del resultado o error.
* Simular servicios y APIs de otros módulos para aislar la coordinación.
* Probar aparte las reglas de los servicios, los modelos y los mapeadores relevantes.
@Test
void notifiesStockWhenThisFlowCancelsOrder() {
when(orderService.cancel(42L)).thenReturn(cancelledOrder);
cancelOrder.execute(42L);
verify(stockModuleApi).releaseReservation(
new ReleaseReservationRequest(42L));
}
No invocar la implementación completa de otros módulos en cada prueba del caso de uso: se comprueba el contrato de colaboración, mientras que la API pública y sus implementaciones tienen sus propias pruebas. Además, conviene verificar la **integración entre módulos**: que un módulo funciona conectado a las implementaciones reales de las APIs que consume. **Spring Modulith** arranca solo el módulo y sus dependencias directas:
@ApplicationModuleTest
class OrderModuleIntegrationTest {
// El módulo orders arranca con sus dependencias directas.
}
^ Nivel ^ Qué se aísla ^ Qué se integra ^
| Prueba del caso de uso | Todo lo externo: servicios propios se simulan según el criterio de la capa de dominio, APIs de otros módulos siempre | Solo la coordinación del flujo |
| Prueba de módulo (''@ApplicationModuleTest'') | El resto de la aplicación | El módulo completo y sus dependencias directas |
| Verificación de estructura | - | Las reglas de dependencia de todo el modelo de módulos |
La **prueba de módulo** complementa a las unitarias del caso de uso: se arranca el módulo real con sus dependencias directas, se ejecuta el flujo y se comprueba su resultado. Ver [[documentacion:spring-modulith-testing|Pruebas de integración de módulos]] para el detalle.
Los casos de uso coordinan flujos y colaboran con servicios o APIs públicas de módulos. No deben invocar otros casos de uso; las reglas compartidas pertenecen a servicios o modelos de dominio. Un caso de uso consume siempre las APIs públicas de los módulos que necesita, nunca sus servicios internos. La estructura de módulos se verifica de forma automatizada, no solo en la revisión de código.