Tabla de Contenidos

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.

ControllerCancelOrderOrderServiceinvocacoordina

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.

Módulo de pedidosMódulo de stockCancelOrderUseCaseOrderServiceStockModuleApiStockServiceStockModelcoordinasiempre libera la reservaeste flujo solicita otra operacióndelega internamente

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:

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 Fundamentos y 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:

@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 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.