Hands-on · Arquitectura

Spring Modulith en producción

Comunicación entre módulos, pitfalls y cómo hacerlo failsafe
para migrar el backend de POS retail desde microservicios

Base: Spring Boot 4.x · Spring Modulith 2.1 GA (2.2 en RC) · Java 21+
← → / espacio para navegar · T índice · F pantalla completa · L tema

Contexto

Por qué Modulith y no "más microservicios"

Monolito modular = un deployable, límites de dominio verificados en build y en runtime.

Lo que ganamos

  • Un solo deploy, una sola JVM: se acaba el "distributed monolith" con 5 repos y versionado cruzado.
  • Transacciones locales donde antes había sagas por HTTP.
  • Eventos con outbox integrado (Event Publication Registry) sin Kafka obligatorio.
  • Límites testeables: verify() rompe el build si alguien cruza un módulo.

Lo que perdemos (y hay que compensar)

  • Aislamiento de fallas por proceso: un OOM tumba todo → bulkheads dentro de la JVM.
  • Escalado independiente por servicio → escalar el todo horizontalmente.
  • Deploy independiente → disciplina de feature flags y releases chicos.
  • Tentación de "acceso directo" a la tabla del otro módulo.
Regla de oro: un módulo de Modulith debe poder extraerse mañana a un microservicio sin reescribir su dominio. Si eso no es cierto, el límite está mal.
Mapa

Módulos de referencia para el backend POS

Los 5 servicios actuales → 5 módulos top-level + un shared mínimo.

ticket orquesta la venta articulos catálogo · precios · stock promos motor de reglas envases retornables · vales pagos medios · autorizaciones shared Money · SucursalId · tipos técnicos (sin lógica de negocio) API síncrona (cotizar) TicketCerrado (evento) PagoConfirmado → descontar stock

Azul = llamada a API pública del módulo (lectura/cálculo). Verde punteado = evento de dominio asíncrono (efecto colateral).

Estructura

Anatomía de un módulo

Paquete top-level = módulo. Lo público es la raíz (o named interfaces). Todo subpaquete es interno por default.

com.tipre.suite
├── TipreSuiteApplication.java
├── articulos
│   ├── ArticulosApi.java          ← público
│   ├── ArticuloSnapshot.java      ← público (record)
│   ├── StockDescontado.java       ← evento público
│   ├── api/                       ← @NamedInterface("api")
│   └── internal/                  ← invisible para otros
│       ├── ArticuloEntity.java
│       ├── ArticuloRepository.java
│       └── ArticulosService.java
├── promos
├── envases
├── pagos
├── ticket
└── shared
    └── package-info.java          ← @ApplicationModule(type = OPEN)
// articulos/package-info.java
@org.springframework.modulith.ApplicationModule(
    displayName = "Artículos",
    allowedDependencies = { "shared" }
)
package com.tipre.suite.articulos;

// ticket/package-info.java
@org.springframework.modulith.ApplicationModule(
    displayName = "Ticket",
    allowedDependencies = {
        "shared", "articulos", "promos :: api", "envases"
    }
)
package com.tipre.suite.ticket;

allowedDependencies

Lista blanca explícita. Sin esto, cualquier dependencia acíclica es válida y el grafo se ensucia en silencio. Declaralo desde el día 1.

Hands-on 1

Verificar la arquitectura en el build

Este test es el contrato. Si falla, no se mergea.

package com.tipre.suite;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;

class ModularityTests {

    static final ApplicationModules modules = ApplicationModules.of(TipreSuiteApplication.class);

    @Test
    void verificaLimitesDeModulos() {
        // Falla ante: ciclos, acceso a paquetes internos, dependencias no permitidas
        modules.verify();
    }

    @Test
    void generaDocumentacion() {
        // PlantUML/C4 + canvas por módulo en target/spring-modulith-docs
        new Documenter(modules)
            .writeModulesAsPlantUml()
            .writeIndividualModulesAsPlantUml()
            .writeModuleCanvases();
    }
}

CI

Corré ModularityTests en cada PR. Publicá los diagramas como artefacto del pipeline.

Runtime (2.1+)

También podés verificar la estructura al arrancar la app, útil si hay módulos en jars separados.

Ojo

verify() sólo ve lo que compila en el classpath; reflection, SQL crudo cruzado y JNDI no los detecta.

Comunicación

Las 4 formas en que un módulo habla con otro

Elegir mal acá es la causa #1 de acoplamiento y de incidentes.

MecanismoTransacciónGarantíaUsar paraEvitar para
1. Llamada a API pública
sync
Misma TX del callerInmediata, falla propagaConsultas y cálculos: precio, cotizar promos, validar envaseEfectos colaterales en otro módulo
2. Evento + @EventListener
sync
Misma TXTodo o nadaInvariantes que deben ser atómicas (raro)Casi todo: es acoplamiento disfrazado
3. Evento + @ApplicationModuleListener
async · default
TX nueva, post-commitAt-least-once vía Event Publication RegistryEfectos: descontar stock, consumir promo, emitir fiscal, auditoríaRespuestas que el caller necesita ya
4. Evento externalizado
broker
Post-commitAt-least-once hacia Kafka/AMQP/SQSIntegración con sistemas fuera del monolito (microservicios que quedan, BI, sucursales)Comunicación interna del monolito
Heurística: si el caller necesita el resultado → API (1). Si es "esto pasó, reaccioná" → evento async (3). Si cruza el límite del proceso → externalizar (4). El (2) casi nunca.
Hands-on 2

Mecanismo 1: API pública del módulo

Interfaz chica, DTOs inmutables, nada de entidades JPA cruzando el límite.

// articulos/ArticulosApi.java  (público)
package com.tipre.suite.articulos;

import java.util.Optional;

public interface ArticulosApi {
    Optional<ArticuloSnapshot> buscarPorEan(String ean, long sucursalId);
}

// articulos/ArticuloSnapshot.java  (público)
package com.tipre.suite.articulos;

import com.tipre.suite.shared.Money;

public record ArticuloSnapshot(
        long articuloId,
        String ean,
        String descripcion,
        Money precioUnitario,
        boolean pesable,
        long versionPrecio) {}
// articulos/internal/ArticulosService.java
package com.tipre.suite.articulos.internal;

import com.tipre.suite.articulos.ArticuloSnapshot;
import com.tipre.suite.articulos.ArticulosApi;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
class ArticulosService implements ArticulosApi {

    private final ArticuloRepository repo;

    ArticulosService(ArticuloRepository repo) {
        this.repo = repo;
    }

    @Override
    @Transactional(readOnly = true)
    public Optional<ArticuloSnapshot> buscarPorEan(String ean, long sucursalId) {
        return repo.findByEanAndSucursal(ean, sucursalId)
                   .map(ArticuloMapper::toSnapshot);
    }
}
Pitfall: devolver ArticuloEntity "porque es más rápido". Otro módulo hace getProveedor() lazy fuera de la TX y tenés LazyInitializationException + acoplamiento al schema.
Hands-on 3

Mecanismo 3: evento de dominio asíncrono

El publicador no sabe quién escucha. El listener corre después del commit, en su propia TX.

// ticket/TicketCerrado.java  (evento público)
package com.tipre.suite.ticket;

import com.tipre.suite.shared.Money;
import java.time.Instant;
import java.util.List;
import java.util.UUID;

public record TicketCerrado(
        UUID eventId,          // idempotencia
        UUID ticketId,
        long sucursalId,
        int caja,
        Instant cerradoEn,
        Money total,
        List<Linea> lineas,
        int schemaVersion) {   // evolución del evento

    public record Linea(long articuloId, String ean,
                        long cantidadMilli, Money importe) {}
}

// ticket/internal/CierreTicketService.java
@Service
class CierreTicketService {
    private final TicketRepository repo;
    private final ApplicationEventPublisher events;
    // ctor...

    @Transactional
    public void cerrar(UUID ticketId) {
        TicketEntity t = repo.findForUpdate(ticketId);
        t.cerrar();                               // cambia estado
        events.publishEvent(TicketMapper.toEvento(t)); // se registra en la MISMA TX
    }
}
// articulos/internal/StockListener.java
package com.tipre.suite.articulos.internal;

import com.tipre.suite.ticket.TicketCerrado;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;

@Component
class StockListener {

    private final StockService stock;
    private final ProcessedEventRepository processed;

    StockListener(StockService stock, ProcessedEventRepository processed) {
        this.stock = stock;
        this.processed = processed;
    }

    // = @Async + @Transactional(REQUIRES_NEW) + @TransactionalEventListener(AFTER_COMMIT)
    @ApplicationModuleListener
    void on(TicketCerrado evt) {
        // Idempotencia: el registro garantiza at-least-once, NO exactly-once
        if (!processed.marcarSiNuevo(evt.eventId(), "articulos.stock")) {
            return;
        }
        evt.lineas().forEach(l ->
            stock.descontar(evt.sucursalId(), l.articuloId(), l.cantidadMilli()));
    }
}
El corazón del failsafe

Event Publication Registry (EPR) = outbox transaccional

Al publicar dentro de una TX, Modulith persiste una fila por listener en event_publication, en la misma TX del negocio.

TX negocioUPDATE ticket + INSERT event_publication COMMITestado: PUBLISHED Listener asyncestado: PROCESSING COMPLETED FAILED / stale RESUBMITTED

Crash a mitad

Si la JVM muere después del commit y antes de que termine el listener, la fila queda incompleta y se reprocesa.

2.x: modelo de estados

Estados explícitos (published, processing, failed, resubmitted) y staleness configurable; soporta multi-instancia sin lock distribuido.

Consecuencia

Entrega at-least-once. Todo listener tiene que ser idempotente. Sin excepciones.

Hands-on 4

Configurar el EPR para producción

<!-- pom.xml -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.modulith</groupId>
      <artifactId>spring-modulith-bom</artifactId>
      <version>2.1.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-core</artifactId>
  </dependency>
  <!-- JDBC registry: recomendado incluso con JPA -->
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jdbc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-insight</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
# application.yml
spring:
  modulith:
    events:
      jdbc:
        schema-initialization:
          enabled: false   # en prod el schema lo maneja Flyway
      # UPDATE (default) | DELETE | ARCHIVE
      completion-mode: archive
      republish-outstanding-events-on-restart: false # ver slide multi-instancia
      staleness:
        # namespace 2.x: cuánto tiempo en cada estado
        # antes de considerarse fallido (ver reference doc
        # de tu versión para las keys exactas)
  task:
    execution:
      pool:
        core-size: 8
        max-size: 32
        queue-capacity: 2000   # ¡nunca ilimitado!
      shutdown:
        await-termination: true
        await-termination-period: 30s
  lifecycle:
    timeout-per-shutdown-phase: 40s
Versiones: 2.x requiere Boot 4. Si migrás desde 1.x, el schema de event_publication cambia: escribí la migración Flyway antes de subir de versión.
Hands-on 5

Reintentos, limpieza y alertas del EPR

Un evento fallido que nadie mira es un ticket sin descontar stock o sin CAE.

package com.tipre.suite.ops;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Duration;
import org.springframework.modulith.events.CompletedEventPublications;
import org.springframework.modulith.events.IncompleteEventPublications;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
class EventPublicationMaintenance {

    private final IncompleteEventPublications incomplete;
    private final CompletedEventPublications completed;

    EventPublicationMaintenance(IncompleteEventPublications incomplete,
                                CompletedEventPublications completed,
                                MeterRegistry meters,
                                EventPublicationStats stats) {
        this.incomplete = incomplete;
        this.completed = completed;
        // Gauge de pendientes (query COUNT sobre event_publication sin completion_date)
        Gauge.builder("modulith.events.incomplete", stats, EventPublicationStats::countIncomplete)
             .register(meters);
    }

    // Reintento controlado: sólo lo viejo, para no competir con el listener en vuelo
    @Scheduled(fixedDelayString = "PT1M")
    void reintentar() {
        incomplete.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(5));
    }

    // Evitar que la tabla crezca sin límite
    @Scheduled(cron = "0 15 3 * * *")
    void purgar() {
        completed.deletePublicationsOlderThan(Duration.ofDays(7));
    }
}

Alerta P1

incomplete > N durante 10 min, o cualquier evento pendiente > 30 min.

Poison message

Un evento que falla siempre se reintenta para siempre. Contá intentos y mandalo a revisión manual.

Multi-instancia

En 1.x este scheduler corre en todos los nodos: usá ShedLock. En 2.x el modelo de estados evita el doble procesamiento.

Hands-on 6

Idempotencia: el listener que se puede ejecutar 2 veces

-- Una tabla por módulo (o una compartida con columna consumer)
CREATE TABLE articulos.processed_event (
    event_id     UNIQUEIDENTIFIER NOT NULL,
    consumer     VARCHAR(100)     NOT NULL,
    processed_at DATETIME2        NOT NULL DEFAULT SYSUTCDATETIME(),
    CONSTRAINT pk_processed_event PRIMARY KEY (event_id, consumer)
);
package com.tipre.suite.articulos.internal;

import java.util.UUID;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
class ProcessedEventRepository {

    private final JdbcTemplate jdbc;

    ProcessedEventRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    /** true si es la primera vez. Corre en la TX del listener:
     *  si el negocio falla, el INSERT también hace rollback. */
    boolean marcarSiNuevo(UUID eventId, String consumer) {
        try {
            jdbc.update("INSERT INTO articulos.processed_event(event_id, consumer) VALUES (?, ?)",
                        eventId, consumer);
            return true;
        } catch (DuplicateKeyException dup) {
            return false;
        }
    }
}

Alternativas válidas

Operaciones naturalmente idempotentes (SET stock = X WHERE version = v), upsert por clave de negocio (ticketId + línea), o estado en la entidad destino.

No sirve

"Chequear y después insertar" sin constraint única: dos instancias pasan el check a la vez. La DB es la que tiene que decir que no.

Hands-on 7

Mecanismo 4: externalizar eventos a un broker

Clave en la migración: los microservicios que todavía no migraste siguen recibiendo eventos.

package com.tipre.suite.ticket;

import org.springframework.modulith.events.Externalized;

// target :: routing key (SpEL) → particiona por sucursal en Kafka
@Externalized("pos.ticket.cerrado::#{#this.sucursalId()}")
public record TicketCerradoV1(
        java.util.UUID eventId,
        java.util.UUID ticketId,
        long sucursalId,
        java.math.BigDecimal total,
        java.time.Instant cerradoEn) {}
<dependency>
  <groupId>org.springframework.modulith</groupId>
  <artifactId>spring-modulith-events-kafka</artifactId>
</dependency>
<!-- o -amqp, -jms, -aws-sqs, -aws-sns -->

Contrato separado

El evento externo es un DTO versionado (V1), no el evento interno. Lo interno puede cambiar libremente.

Outbox (2.1+)

Hay modo outbox para externalizar (Namastack, JobRunr) como alternativa al listener async integrado. Evaluálo si el broker cae seguido.

Orden

Sin partition key, Kafka no garantiza orden por sucursal/caja. Los consumidores igual tienen que tolerar desorden.

Pitfalls · Parte 1

Errores que vas a ver en los primeros 30 días

Renombrar el método del listener

El EPR identifica al listener por su firma. Si renombrás o movés el método con eventos pendientes, esas filas quedan huérfanas y nunca se reprocesan. Drená la cola antes del deploy.

Renombrar/mover la clase del evento

El payload se guarda serializado con el tipo. Un evento pendiente de una clase que ya no existe no se puede deserializar. Versioná: TicketCerradoV2 nuevo, V1 vive hasta vaciarse.

Publicar fuera de una TX

@TransactionalEventListener no se dispara sin transacción activa (salvo fallbackExecution). El evento se pierde en silencio.

Entidades JPA dentro del evento

Proxies lazy, payload enorme, serialización que falla. Eventos = records con IDs + snapshot mínimo.

@TransactionalEventListener pelado que escribe

Corre AFTER_COMMIT en la TX ya cerrada: los writes no se commitean. Usá @ApplicationModuleListener (trae REQUIRES_NEW).

Pool async con cola infinita

Pico de cierre de caja a las 20 hs + listener lento = cola en memoria creciendo hasta OOM. Acotá queue-capacity y monitoreá.

Pitfalls · Parte 2

Errores de diseño (más caros de arreglar)

El módulo "shared" como basurero

Empieza con Money y termina con TicketUtils. Regla: shared no tiene lógica de negocio ni dependencias hacia otros módulos. Revisalo en cada PR.

Foreign keys entre módulos

ticket_linea.articulo_id → articulos.articulo con FK real impide extraer el módulo y acopla migraciones. Guardá el ID como valor y snapshot de lo que necesitás.

JOINs cross-módulo en queries

Reportes que joinean 4 schemas. Solución: read models propios alimentados por eventos, o un módulo reporting explícito de sólo lectura.

Ciclos "porque es un solo dominio"

promos necesita artículos, artículos necesita promos para mostrar precio final. Cortalo con eventos o invirtiendo la dependencia (port en promos, implementado por articulos).

Calcular el ticket por eventos

Cotizar promos o totales es síncrono por naturaleza: la caja espera respuesta. Usar eventos ahí da latencia y estados intermedios absurdos.

Migrar 1:1 los microservicios

Si dos servicios hablaban 15 veces por request, probablemente son un solo módulo. Redibujá los límites, no los copies.

Datos

Ownership de datos por módulo

  • Un schema por módulo en SQL Server (articulos.*, ticket.*…). Usuario de DB por módulo si querés enforcement duro.
  • Migraciones por módulo: Flyway con locations separadas. Modulith 2.x soporta migraciones Flyway conscientes de módulos.
  • Nadie lee tablas de otro módulo. Ni con @Query nativa.
  • event_publication y processed_event en schema técnico propio.

Test que lo hace cumplir

ArchUnit: ninguna clase fuera de ..articulos.. puede referenciar @Table(schema="articulos"). Y grep en CI de SQL nativo con schemas ajenos.

src/main/resources/db/migration/
├── __root/               ← infraestructura (event_publication, ...)
│   └── V1__event_publication.sql
├── articulos/
│   ├── V1__schema.sql
│   └── V2__stock_por_sucursal.sql
├── promos/
│   └── V1__schema.sql
├── envases/
├── pagos/
└── ticket/
    ├── V1__schema.sql
    └── V2__ticket_linea_snapshot.sql
Failsafe · Parte 1

Bulkheads dentro de un solo proceso

En microservicios el aislamiento lo daba el proceso. Ahora hay que construirlo.

package com.tipre.suite.config;

import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
class ExecutorsConfig {

    // Pool dedicado para integraciones lentas (fiscal, adquirentes)
    @Bean("integracionesExecutor")
    Executor integracionesExecutor() {
        ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
        ex.setThreadNamePrefix("integ-");
        ex.setCorePoolSize(4);
        ex.setMaxPoolSize(8);
        ex.setQueueCapacity(500);
        ex.setWaitForTasksToCompleteOnShutdown(true);
        ex.setAwaitTerminationSeconds(30);
        ex.initialize();
        return ex;
    }
}

// Listener que usa ese pool en vez del default
@Component
class FiscalListener {
    @Async("integracionesExecutor")
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    void on(PagoConfirmado evt) { /* llamar gateway fiscal */ }
}

Pools separados

Un gateway fiscal colgado no puede consumir los threads que descuentan stock. Pool por tipo de integración externa.

Connection pool

Hikari con tamaño acorde: listeners async + requests HTTP compiten por las mismas conexiones. Medí hikaricp.connections.pending.

Resilience4j en salidas

Timeout + circuit breaker + retry con backoff en toda llamada HTTP saliente. Nunca timeout infinito.

Virtual threads

Con Java 21+ (spring.threads.virtual.enabled=true) el costo de bloquear baja, pero el límite real pasa a ser el pool de conexiones: igual necesitás semáforos.

Failsafe · Parte 2

Saga del cierre de venta (sin 2PC)

Cada paso es una TX local + evento. Las fallas se compensan, no se rollbackean.

#MóduloAcción (TX local)Evento emitidoSi falla
1ticketCerrar ticket, estado PENDIENTE_PAGOTicketCerradoError sync a la caja, nada se publicó
2pagosAutorizar medio(s) de pagoPagoConfirmado / PagoRechazadoRechazo → ticket vuelve a ABIERTO (compensación)
3articulosDescontar stockStockDescontadoReintento EPR; stock negativo se permite y se alerta
4promosConsumir cupos / acumular puntosPromoConsumidaReintento; idempotente por ticketId
5ticketEmisión fiscal (CAE/CAEA)ComprobanteEmitidoContingencia CAEA; jamás bloquear la caja

Estado explícito en el agregado

El ticket guarda su máquina de estados. Un job reconcilia tickets trabados en un estado intermedio más de X minutos.

Pagos: el paso no compensable

Un cobro aprobado no se "deshace" con rollback: requiere reverso/anulación hacia el adquirente. Modelalo como comando explícito, con su propia idempotencia.

Failsafe · Parte 3

Despliegue y operación

N instancias stateless

Mínimo 2 detrás del balanceador. Nada en memoria que no se pueda perder; sesiones y cachés compartidos o reconstruibles.

Graceful shutdown

server.shutdown=graceful + await-termination en los executors. Lo que no termina queda en el EPR y se reintenta.

Health por módulo

HealthIndicators propios: EPR pendientes, circuit breakers abiertos, lag de externalización. Readiness ≠ liveness.

Observabilidad

spring-modulith-starter-insight: trazas con spans por módulo y endpoint actuator /modulith. Ves qué módulo es lento sin adivinar.

Feature flags

Cada flujo migrado detrás de un flag por sucursal. Rollback = apagar flag, no redeploy.

Las cajas no dependen del backend

El POS debe seguir vendiendo offline (store & forward). El backend es fuente de verdad eventual, no un single point of failure de la venta.

server:
  shutdown: graceful
management:
  endpoints.web.exposure.include: health,info,metrics,prometheus,modulith
  endpoint.health.probes.enabled: true
  tracing.sampling.probability: 0.1
Hands-on 8

Testear un módulo aislado con Scenario

@ApplicationModuleTest levanta sólo el módulo (y opcionalmente sus dependencias). Rápido y prueba el contrato de eventos.

package com.tipre.suite.articulos;

import static org.assertj.core.api.Assertions.assertThat;

import com.tipre.suite.shared.Money;
import com.tipre.suite.ticket.TicketCerrado;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;

@ApplicationModuleTest // modo STANDALONE por default
class StockAlCerrarTicketTests {

    @Autowired StockQueries stock;

    @Test
    void descuentaStockUnaSolaVezAunqueElEventoLlegueDosVeces(Scenario scenario) {
        UUID eventId = UUID.randomUUID();
        TicketCerrado evt = new TicketCerrado(eventId, UUID.randomUUID(), 1L, 3,
                Instant.now(), Money.ars("1000"),
                List.of(new TicketCerrado.Linea(42L, "7790001", 2000, Money.ars("1000"))), 1);

        scenario.publish(evt)
                .andWaitForStateChange(() -> stock.disponible(1L, 42L))
                .andVerify(disp -> assertThat(disp).isEqualTo(8000));

        // Re-entrega simulada (at-least-once)
        scenario.publish(evt)
                .andWaitForStateChange(() -> stock.ultimoEventoProcesado(1L, 42L))
                .andVerify(ultimo -> assertThat(stock.disponible(1L, 42L)).isEqualTo(8000));
    }
}
Migración

Plan de migración desde microservicios (strangler)

  1. Esqueleto: app Modulith con los 5 módulos vacíos, ModularityTests y EPR en CI.
  2. Contratos: definí eventos internos y DTOs externos versionados antes de mover lógica.
  3. Primer módulo hoja: el de menos dependencias (envases o articulos). Expone la misma API HTTP que el servicio viejo.
  4. Gateway enruta ese path al monolito por feature flag/sucursal piloto.
  5. Eventos externalizados mantienen alimentados a los servicios que aún no migraron.
  6. Repetir módulo a módulo; ticket y pagos al final (más riesgo, más integraciones).
  7. Apagar servicios viejos sólo tras N días sin tráfico.

No hagas dual-write

Escribir en la DB vieja y en la nueva desde el mismo request = inconsistencia garantizada. Una sola fuente de verdad por agregado en cada momento; el otro lado se alimenta por eventos.

Datos históricos

Migración de datos por módulo con scripts idempotentes y conteos de reconciliación (tickets, totales por día/sucursal) antes del corte.

Piloto

Una sucursal, una semana, con comparación de totales vs. sistema viejo. Recién después, rollout escalonado.

Checklist

Antes de ir a producción

  • ApplicationModules.verify() en CI, bloqueante
  • allowedDependencies declarado en todos los módulos
  • Ninguna entidad JPA en APIs públicas ni en eventos
  • Ninguna FK ni query entre schemas de módulos
  • Todos los listeners async son idempotentes (con test de doble entrega)
  • Eventos con eventId y versión; política de evolución escrita
  • EPR con JDBC, schema por Flyway, completion-mode definido
  • Job de reintento + purga + alerta de pendientes
  • Executors acotados y separados para integraciones externas
  • Timeouts y circuit breakers en toda salida HTTP
  • Graceful shutdown probado con kill durante carga
  • Procedimiento de deploy: drenar EPR antes de renombrar listeners/eventos
  • Trazas por módulo y dashboard de latencia por módulo
  • Feature flags por sucursal para cada flujo migrado
  • Test de caos: matar una instancia en pleno cierre de caja
  • Reconciliación de totales vs. sistema viejo en el piloto
Resumen

Tres ideas para llevarse

1 · Límites verificados

Si no lo chequea el build, no existe. verify() + allowedDependencies + schema por módulo.

2 · Eventos async + EPR

Efectos colaterales vía @ApplicationModuleListener. At-least-once ⇒ idempotencia siempre.

3 · Aislamiento a mano

Un proceso, muchos dominios: bulkheads, timeouts, monitoreo del EPR y la caja nunca bloqueada por el backend.

Índice

    1 / 1