Introduzione: Comunicazione tra Moduli
La comunicazione tra moduli e il cuore pulsante di un modular monolith. A differenza dei microservizi, dove la comunicazione avviene esclusivamente via rete (HTTP, gRPC, message broker), nel modular monolith abbiamo il vantaggio di poter scegliere tra comunicazione sincrona in-process e comunicazione asincrona basata su eventi, ciascuna con trade-off specifici in termini di accoppiamento, latenza e debuggabilita.
In questo articolo analizzeremo i tre principali pattern di comunicazione: chiamate dirette di metodo, event bus in-process, e messaging asincrono. Vedremo come implementarli con Spring Boot e quando scegliere l'uno o l'altro in base al contesto.
Cosa Imparerai in Questo Articolo
- Comunicazione sincrona: method calls tramite interfacce, vantaggi e limiti
- Event Bus in-process: pub-sub all'interno della stessa JVM
- Messaging asincrono: RabbitMQ e Kafka per decoupling avanzato
- Mediator Pattern: disaccoppiamento delle dipendenze tra moduli
- CQRS completo: separazione comandi e query
- Consistenza: strong consistency vs eventual consistency
- Gestione dei fallimenti: retry, dead letter queue, circuit breaker
- Debugging e osservabilità nei flussi asincroni
Pattern 1: Comunicazione Sincrona (Direct Method Calls)
Il pattern più semplice: un modulo chiama direttamente un metodo dell'interfaccia pubblica di un altro modulo. La chiamata avviene in-process, senza overhead di rete, con la stessa latenza di una chiamata di metodo Java standard (nanosecondi).
Vantaggi
- Semplicita: nessuna infrastruttura aggiuntiva, nessun broker di messaggi
- Strong consistency: la chiamata e parte della stessa transazione
- Debugging diretto: stack trace completo, breakpoint, step-through
- Type safety: il compilatore verifica i contratti tra moduli
Svantaggi
- Accoppiamento temporale: il chiamante e bloccato finchè il chiamato non risponde
- Cascading failures: se il modulo chiamato fallisce, il chiamante fallisce
- Dipendenze dirette: il modulo chiamante conosce l'interfaccia del chiamato
// Comunicazione sincrona: Order chiama Catalog via interfaccia
@Service
class OrderServiceImpl implements OrderModuleApi {
// Dipendenza esplicita verso l'API del modulo Catalog
private final CatalogModuleApi catalogModule;
private final UserModuleApi userModule;
@Override
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
// Chiamata sincrona 1: verifica utente
UserDto user = userModule.findById(cmd.userId())
.orElseThrow(() -> new UserNotFoundException(cmd.userId()));
// Chiamata sincrona 2: verifica prodotti
List<ProductDto> products = cmd.productIds().stream()
.map(id -> catalogModule.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id)))
.toList();
// Logica locale del modulo Order
Order order = Order.create(user.id(), products);
orderRepository.save(order);
return order.toDto();
// Tutto avviene nella stessa transazione ACID
}
}
Pattern 2: Event Bus In-Process
L'event bus in-process implementa il pattern publish-subscribe all'interno della stessa JVM. Un modulo pubblica un evento, e zero o più moduli sottoscritti reagiscono all'evento. Il produttore non conosce i consumatori: il disaccoppiamento e completo.
Spring Framework fornisce un event bus in-process nativo attraverso
ApplicationEventPublisher e le annotazioni @EventListener
e @TransactionalEventListener.
Vantaggi
- Disaccoppiamento: il produttore non conosce i consumatori
- Estensibilita: nuovi consumatori possono essere aggiunti senza modificare il produttore
- Nessuna infrastruttura: funziona all'interno della stessa JVM senza broker esterni
Svantaggi
- Debugging più complesso: il flusso non e lineare, serve tracciare gli eventi
- Ordine di esecuzione: l'ordine dei listener non e garantito
- Perdita di eventi: se l'applicazione crasha, gli eventi in memoria vengono persi
// Event Bus in-process con Spring Events
// 1. Definizione dell'evento (nel package api del modulo)
public record OrderCreatedEvent(
UUID orderId,
UUID userId,
Money total,
List<UUID> productIds,
Instant timestamp
) {}
// 2. Pubblicazione dell'evento (nel modulo Order)
@Service
class OrderServiceImpl {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepository.save(order);
// Pubblica evento DOPO il commit della transazione
eventPublisher.publishEvent(new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getTotal(),
order.getProductIds(),
Instant.now()
));
return order.toDto();
}
}
// 3. Consumatori in moduli diversi
// Modulo Payment: processa il pagamento
@Service
class PaymentEventHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void handleOrderCreated(OrderCreatedEvent event) {
paymentService.initiatePayment(
event.userId(), event.total()
);
}
}
// Modulo Inventory: riserva lo stock
@Service
class InventoryEventHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void handleOrderCreated(OrderCreatedEvent event) {
for (UUID productId : event.productIds()) {
inventoryService.reserveStock(productId);
}
}
}
// Modulo Notification: invia conferma
@Service
class NotificationEventHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void handleOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(
event.userId(), event.orderId()
);
}
}
TransactionalEventListener vs EventListener
@EventListener esegue il listener durante la transazione del
produttore. Se il listener fallisce, la transazione originale viene rollbackata.
@TransactionalEventListener(phase = AFTER_COMMIT) esegue il listener
dopo il commit della transazione, garantendo che l'evento sia processato
solo se la transazione originale ha avuto successo.
Pattern 3: Mediator Pattern
Il Mediator Pattern introduce un intermediario che coordina la comunicazione tra moduli. Invece di iniettare direttamente le interfacce dei moduli target, un modulo invia comandi o query al mediator, che li instrada al handler appropriato.
// Mediator Pattern: disaccoppia completamente i moduli
// Interfaccia del Mediator
public interface Mediator {
<R> R send(Command<R> command);
<R> R query(Query<R> query);
}
// Comando: CreateOrder restituisce OrderDto
public record CreateOrderCommand(
UUID userId, List<UUID> productIds
) implements Command<OrderDto> {}
// Handler nel modulo Order
@Component
class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderDto> {
@Override
public OrderDto handle(CreateOrderCommand cmd) {
Order order = Order.create(cmd.userId(), cmd.productIds());
orderRepository.save(order);
return order.toDto();
}
}
// Implementazione del Mediator
@Component
class MediatorImpl implements Mediator {
private final Map<Class<?>, CommandHandler<?, ?>> handlers;
@SuppressWarnings("unchecked")
public <R> R send(Command<R> command) {
CommandHandler<Command<R>, R> handler =
(CommandHandler<Command<R>, R>) handlers.get(command.getClass());
if (handler == null) {
throw new NoHandlerException(command.getClass());
}
return handler.handle(command);
}
}
// Utilizzo: il chiamante non conosce il modulo target
@RestController
class OrderController {
private final Mediator mediator;
@PostMapping("/orders")
OrderDto createOrder(@RequestBody CreateOrderRequest request) {
return mediator.send(new CreateOrderCommand(
request.userId(), request.productIds()
));
}
}
Pattern 4: Messaging Asincrono con RabbitMQ
Per scenari che richiedono decoupling avanzato, resilienza ai fallimenti e possibilità di retry, un message broker esterno come RabbitMQ offre garanzie aggiuntive rispetto all'event bus in-process: persistenza dei messaggi, dead letter queue, retry automatici, e distribuzione su più istanze.
// Messaging asincrono con RabbitMQ e Spring AMQP
// Configurazione
@Configuration
class RabbitConfig {
@Bean
public TopicExchange orderExchange() {
return new TopicExchange("order.events");
}
@Bean
public Queue paymentQueue() {
return QueueBuilder.durable("payment.order-created")
.withArgument("x-dead-letter-exchange", "order.events.dlx")
.build();
}
@Bean
public Binding paymentBinding() {
return BindingBuilder
.bind(paymentQueue())
.to(orderExchange())
.with("order.created");
}
}
// Produttore nel modulo Order
@Service
class OrderEventPublisher {
private final RabbitTemplate rabbitTemplate;
public void publishOrderCreated(OrderCreatedEvent event) {
rabbitTemplate.convertAndSend(
"order.events", // exchange
"order.created", // routing key
event // payload
);
}
}
// Consumatore nel modulo Payment
@Component
class PaymentOrderListener {
@RabbitListener(queues = "payment.order-created")
public void handleOrderCreated(OrderCreatedEvent event) {
try {
paymentService.processPayment(
event.userId(), event.total()
);
} catch (Exception e) {
// Il messaggio viene reinviato alla DLQ dopo 3 retry
throw new AmqpRejectAndDontRequeueException(e);
}
}
}
CQRS: Separazione Comandi e Query
Il pattern CQRS (Command Query Responsibility Segregation) separa completamente il percorso dei comandi (scrittura) dal percorso delle query (lettura). I comandi passano attraverso le API dei moduli e modificano lo stato. Le query accedono a proiezioni denormalizzate ottimizzate per la lettura.
// CQRS: Command side (scrittura)
// Passa attraverso le API dei moduli con validazione
@Service
class OrderCommandService {
@Transactional
public UUID createOrder(CreateOrderCommand cmd) {
// Validazione, logica di dominio, persistenza
Order order = Order.create(cmd);
orderRepo.save(order);
events.publish(new OrderCreatedEvent(order));
return order.getId();
}
}
// CQRS: Query side (lettura)
// Accede a viste denormalizzate, nessuna logica di dominio
@Service
class OrderQueryService {
private final OrderReadModelRepository readModelRepo;
// Query veloce: la vista contiene già tutti i dati necessari
public OrderDetailsView getOrderDetails(UUID orderId) {
return readModelRepo.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
// Query di lista con filtri
public Page<OrderSummaryView> searchOrders(
OrderSearchCriteria criteria, Pageable pageable) {
return readModelRepo.search(criteria, pageable);
}
}
// Vista denormalizzata: contiene dati da più moduli
@Entity
public class OrderDetailsView {
private UUID orderId;
private String customerName; // dal modulo User
private String customerEmail; // dal modulo User
private List<OrderItemView> items;
private String productNames; // dal modulo Catalog
private BigDecimal total;
private String paymentMethod; // dal modulo Payment
private String paymentStatus; // dal modulo Payment
}
Gestione dei Fallimenti
Nei flussi asincroni, i fallimenti sono inevitabili. Ecco i pattern principali per gestirli:
Retry con Backoff Esponenziale
Se un handler fallisce, riprova con intervalli crescenti: 1s, 2s, 4s, 8s. Dopo un numero massimo di tentativi, il messaggio viene spostato in una Dead Letter Queue (DLQ) per analisi manuale.
Dead Letter Queue
I messaggi che non possono essere processati dopo tutti i retry vengono spostati in una coda dedicata. Un operatore o un processo automatizzato può analizzare e riprovare i messaggi falliti.
Circuit Breaker
Se un modulo target fallisce ripetutamente, il circuit breaker interrompe le chiamate per un periodo, evitando di sovraccaricare un modulo già in difficolta. Dopo un timeout, il circuit breaker riprova gradualmente.
Debugging e Osservabilità
I flussi asincroni sono più difficili da debuggare rispetto alle chiamate sincrone. Ecco le pratiche fondamentali:
- Correlation ID: ogni flusso ha un ID univoco che attraversa tutti gli eventi e le chiamate
- Structured Logging: log strutturati con correlation ID, modulo sorgente, modulo target
- Event Store: salva tutti gli eventi pubblicati per tracciabilita e replay
- Health Check: verifica che tutti i moduli e i broker siano operativi
Prossimo Articolo
Nel prossimo articolo affronteremo il tema del deployment e scaling del modular monolith: strategie di deploy blue-green, feature flags per rilasci graduali, auto-scaling intelligente basato su metriche, e quando estrarre un modulo come microservizio per scalabilità isolata.







