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
Monolito modular = un deployable, límites de dominio verificados en build y en runtime.
verify() rompe el build si alguien cruza un módulo.Los 5 servicios actuales → 5 módulos top-level + un shared mínimo.
Azul = llamada a API pública del módulo (lectura/cálculo). Verde punteado = evento de dominio asíncrono (efecto colateral).
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;
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.
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();
}
}
Corré ModularityTests en cada PR. Publicá los diagramas como artefacto del pipeline.
También podés verificar la estructura al arrancar la app, útil si hay módulos en jars separados.
verify() sólo ve lo que compila en el classpath; reflection, SQL crudo cruzado y JNDI no los detecta.
Elegir mal acá es la causa #1 de acoplamiento y de incidentes.
| Mecanismo | Transacción | Garantía | Usar para | Evitar para |
|---|---|---|---|---|
| 1. Llamada a API pública sync | Misma TX del caller | Inmediata, falla propaga | Consultas y cálculos: precio, cotizar promos, validar envase | Efectos colaterales en otro módulo |
2. Evento + @EventListenersync | Misma TX | Todo o nada | Invariantes que deben ser atómicas (raro) | Casi todo: es acoplamiento disfrazado |
3. Evento + @ApplicationModuleListenerasync · default | TX nueva, post-commit | At-least-once vía Event Publication Registry | Efectos: descontar stock, consumir promo, emitir fiscal, auditoría | Respuestas que el caller necesita ya |
| 4. Evento externalizado broker | Post-commit | At-least-once hacia Kafka/AMQP/SQS | Integración con sistemas fuera del monolito (microservicios que quedan, BI, sucursales) | Comunicación interna del monolito |
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);
}
}
ArticuloEntity "porque es más rápido". Otro módulo hace getProveedor() lazy fuera de la TX y tenés LazyInitializationException + acoplamiento al schema.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()));
}
}
Al publicar dentro de una TX, Modulith persiste una fila por listener en event_publication, en la misma TX del negocio.
Si la JVM muere después del commit y antes de que termine el listener, la fila queda incompleta y se reprocesa.
Estados explícitos (published, processing, failed, resubmitted) y staleness configurable; soporta multi-instancia sin lock distribuido.
Entrega at-least-once. Todo listener tiene que ser idempotente. Sin excepciones.
<!-- 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
event_publication cambia: escribí la migración Flyway antes de subir de versión.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));
}
}
incomplete > N durante 10 min, o cualquier evento pendiente > 30 min.
Un evento que falla siempre se reintenta para siempre. Contá intentos y mandalo a revisión manual.
En 1.x este scheduler corre en todos los nodos: usá ShedLock. En 2.x el modelo de estados evita el doble procesamiento.
-- 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;
}
}
}
Operaciones naturalmente idempotentes (SET stock = X WHERE version = v), upsert por clave de negocio (ticketId + línea), o estado en la entidad destino.
"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.
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 -->
El evento externo es un DTO versionado (V1), no el evento interno. Lo interno puede cambiar libremente.
Hay modo outbox para externalizar (Namastack, JobRunr) como alternativa al listener async integrado. Evaluálo si el broker cae seguido.
Sin partition key, Kafka no garantiza orden por sucursal/caja. Los consumidores igual tienen que tolerar desorden.
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.
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.
@TransactionalEventListener no se dispara sin transacción activa (salvo fallbackExecution). El evento se pierde en silencio.
Proxies lazy, payload enorme, serialización que falla. Eventos = records con IDs + snapshot mínimo.
@TransactionalEventListener pelado que escribeCorre AFTER_COMMIT en la TX ya cerrada: los writes no se commitean. Usá @ApplicationModuleListener (trae REQUIRES_NEW).
Pico de cierre de caja a las 20 hs + listener lento = cola en memoria creciendo hasta OOM. Acotá queue-capacity y monitoreá.
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.
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.
Reportes que joinean 4 schemas. Solución: read models propios alimentados por eventos, o un módulo reporting explícito de sólo lectura.
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).
Cotizar promos o totales es síncrono por naturaleza: la caja espera respuesta. Usar eventos ahí da latencia y estados intermedios absurdos.
Si dos servicios hablaban 15 veces por request, probablemente son un solo módulo. Redibujá los límites, no los copies.
articulos.*, ticket.*…). Usuario de DB por módulo si querés enforcement duro.@Query nativa.event_publication y processed_event en schema técnico propio.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
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 */ }
}
Un gateway fiscal colgado no puede consumir los threads que descuentan stock. Pool por tipo de integración externa.
Hikari con tamaño acorde: listeners async + requests HTTP compiten por las mismas conexiones. Medí hikaricp.connections.pending.
Timeout + circuit breaker + retry con backoff en toda llamada HTTP saliente. Nunca timeout infinito.
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.
Cada paso es una TX local + evento. Las fallas se compensan, no se rollbackean.
| # | Módulo | Acción (TX local) | Evento emitido | Si falla |
|---|---|---|---|---|
| 1 | ticket | Cerrar ticket, estado PENDIENTE_PAGO | TicketCerrado | Error sync a la caja, nada se publicó |
| 2 | pagos | Autorizar medio(s) de pago | PagoConfirmado / PagoRechazado | Rechazo → ticket vuelve a ABIERTO (compensación) |
| 3 | articulos | Descontar stock | StockDescontado | Reintento EPR; stock negativo se permite y se alerta |
| 4 | promos | Consumir cupos / acumular puntos | PromoConsumida | Reintento; idempotente por ticketId |
| 5 | ticket | Emisión fiscal (CAE/CAEA) | ComprobanteEmitido | Contingencia CAEA; jamás bloquear la caja |
El ticket guarda su máquina de estados. Un job reconcilia tickets trabados en un estado intermedio más de X minutos.
Un cobro aprobado no se "deshace" con rollback: requiere reverso/anulación hacia el adquirente. Modelalo como comando explícito, con su propia idempotencia.
Mínimo 2 detrás del balanceador. Nada en memoria que no se pueda perder; sesiones y cachés compartidos o reconstruibles.
server.shutdown=graceful + await-termination en los executors. Lo que no termina queda en el EPR y se reintenta.
HealthIndicators propios: EPR pendientes, circuit breakers abiertos, lag de externalización. Readiness ≠ liveness.
spring-modulith-starter-insight: trazas con spans por módulo y endpoint actuator /modulith. Ves qué módulo es lento sin adivinar.
Cada flujo migrado detrás de un flag por sucursal. Rollback = apagar flag, no redeploy.
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
@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));
}
}
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.
Migración de datos por módulo con scripts idempotentes y conteos de reconciliación (tickets, totales por día/sucursal) antes del corte.
Una sucursal, una semana, con comparación de totales vs. sistema viejo. Recién después, rollout escalonado.
ApplicationModules.verify() en CI, bloqueanteallowedDependencies declarado en todos los móduloseventId y versión; política de evolución escritaSi no lo chequea el build, no existe. verify() + allowedDependencies + schema por módulo.
Efectos colaterales vía @ApplicationModuleListener. At-least-once ⇒ idempotencia siempre.
Un proceso, muchos dominios: bulkheads, timeouts, monitoreo del EPR y la caja nunca bloqueada por el backend.