Salta al contenuto principale

05 - Communication Patterns: Sincrone, Asincrone e Event-Driven

05 - Communication Patterns: Sincrone, Asincrone e Event-Driven: Introduzione: Comunicazione tra Moduli La comunicazione tra moduli e il cuore pulsante di...

Cosa ne pensi di questo articolo?

05 - Communication Patterns: Sincrone, Asincrone e Event-Driven
ArchitectureFoto:Mikhail NilovPexels

In Breve

05 - Communication Patterns: Sincrone, Asincrone e Event-Driven: Introduzione: Comunicazione tra Moduli La comunicazione tra moduli e il cuore pulsante di...

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.

Domande frequenti

Qual è il vantaggio principale della comunicazione sincrona in-process rispetto alla comunicazione asincrona?

La comunicazione sincrona in-process offre la stessa latenza di una chiamata di metodo Java standard, senza overhead di rete.

Come funziona l'event bus in-process e quali sono i suoi vantaggi?

L'event bus in-process implementa il pattern publish-subscribe all'interno della stessa JVM, consentendo ai moduli di pubblicare eventi e ai moduli sottoscritti di reagire a essi. I suoi vantaggi includono disaccoppiamento completo e gestione delle transazioni.

Qual è il ruolo del Mediator Pattern nella comunicazione tra moduli?

Il Mediator Pattern introduce un intermediario che coordina la comunicazione tra moduli, consentendo ai moduli di inviare comandi o query al mediator, che li instrada al handler appropriato.

Come gestire i fallimenti nei flussi asincroni?

I pattern principali per gestire i fallimenti includono retry con backoff esponenziale, dead letter queue e circuit breaker.

Fonti e approfondimenti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti