sabato 26 settembre 2026

Architettura Enterprise su AWS: Gestione Attiva della DLQ con Spring Boot 4.1.1, Java 25 e DDD

Nel panorama dello sviluppo software moderno, l'integrazione di sistemi di messaggistica asincrona è fondamentale per garantire scalabilità, resilienza e disaccoppiamento tra i microservizi. Amazon Simple Queue Service (SQS) è lo standard de facto per la gestione delle code in ambiente AWS.

Questo progetto nasce come evoluzione di una semplice integrazione, per trasformarsi in un vero e proprio microservizio production-ready.


Il Progetto: Cosa stiamo costruendo?

Immaginiamo di dover sviluppare un Microservizio di Elaborazione Notifiche per una piattaforma e-commerce. Il sistema riceve richieste per inviare comunicazioni critiche ai clienti. Questi processi richiedono tempo e possono fallire. Un'elaborazione sincrona bloccherebbe l'utente; l'approccio asincrono tramite AWS SQS è la soluzione ideale.

In questo progetto, adotteremo:

  • Java 25 e Spring Boot 4.1.1: Per sfruttare le ultime funzionalità.
  • Clean Architecture e DDD (Domain-Driven Design): La logica di core isolata dai dettagli implementativi di AWS.
  • Gestione Attiva degli Errori tramite DLQ (Dead Letter Queue): Creeremo una coda secondaria di sicurezza per i messaggi falliti.
  • OpenTofu (IaC): Infrastruttura definita tramite codice.

La Struttura del Progetto

src/main/java/com/tuoblog/enterprise/
├── application/           # Livello di Orchestrazione
│   └── usecase/
│       └── PublishMessageUseCase.java
├── domain/                # Il cuore del Business
│   ├── model/
│   │   └── BusinessMessage.java
│   └── port/
│       └── MessagePublisherPort.java
├── infrastructure/        # Connessione ad AWS
│   └── messaging/
│       ├── SqsDlqConsumerAdapter.java      
│       ├── SqsMainConsumerAdapter.java     
│       └── SqsMessagePublisherAdapter.java 
└── presentation/          # Esposizione API REST
    └── api/
        └── MessageController.java

1. L'Infrastruttura come Codice (OpenTofu)

Nel file main.tofu, definiamo la coda principale e la DLQ. La redrive_policy indica che, dopo 3 tentativi falliti, il messaggio deve essere dirottato nella coda di errore.

provider "aws" {
  region = "eu-south-1" 
}

# 1. Creazione della Coda di Errore (DLQ)
resource "aws_sqs_queue" "app_dlq" {
  name = "enterprise-app-dlq"
  message_retention_seconds = 1209600 # 14 giorni
}

# 2. Creazione della Coda Principale
resource "aws_sqs_queue" "app_main_queue" {
  name = "enterprise-app-queue"
  
  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.app_dlq.arn
    maxReceiveCount     = 3 
  })
}

output "main_queue_url" { value = aws_sqs_queue.app_main_queue.url }
output "dlq_queue_url" { value = aws_sqs_queue.app_dlq.url }

2. Configurazione del Progetto (Maven e YAML)

Il pom.xml definisce il nostro stack tecnologico.

<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
    </parent>
    <groupId>com.tuoblog.enterprise</groupId>
    <artifactId>sqs-ddd-demo</artifactId>
    <version>1.0.0</version>
    <properties>
        <java.version>25</java.version>
        <spring-cloud-aws.version>3.3.0</spring-cloud-aws.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>io.awspring.cloud</groupId>
            <artifactId>spring-cloud-aws-starter-sqs</artifactId>
            <version>${spring-cloud-aws.version}</version>
        </dependency>
    </dependencies>
</project>

Nel file src/main/resources/application.yml specificheremo i nomi delle code:

spring:
  application:
    name: sqs-ddd-microservice
  cloud:
    aws:
      region:
        static: eu-south-1

aws:
  sqs:
    queue:
      main: enterprise-app-queue
      dlq: enterprise-app-dlq

3. Livello di Dominio e Applicazione (DDD)

Questo livello contiene le regole di business assolute.

package com.tuoblog.enterprise.domain.model;
import java.util.UUID;

public record BusinessMessage(UUID id, String content) {
    public BusinessMessage {
        if (content == null || content.isBlank()) {
            throw new IllegalArgumentException("Il contenuto è obbligatorio");
        }
    }
}
package com.tuoblog.enterprise.domain.port;
import com.tuoblog.enterprise.domain.model.BusinessMessage;

public interface MessagePublisherPort {
    void publish(BusinessMessage message);
}
package com.tuoblog.enterprise.application.usecase;
import com.tuoblog.enterprise.domain.model.BusinessMessage;
import com.tuoblog.enterprise.domain.port.MessagePublisherPort;
import org.springframework.stereotype.Service;
import java.util.UUID;

@Service
public class PublishMessageUseCase {
    private final MessagePublisherPort publisherPort;

    public PublishMessageUseCase(MessagePublisherPort publisherPort) {
        this.publisherPort = publisherPort;
    }

    public BusinessMessage execute(String content) {
        BusinessMessage message = new BusinessMessage(UUID.randomUUID(), content);
        publisherPort.publish(message); // Logica agnostica
        return message;
    }
}

4. L'Infrastruttura: Producer, Consumer e DLQ

Qui è dove il nostro codice si accoppia ad AWS.

package com.tuoblog.enterprise.infrastructure.messaging;
import com.tuoblog.enterprise.domain.model.BusinessMessage;
import com.tuoblog.enterprise.domain.port.MessagePublisherPort;
import io.awspring.cloud.sqs.operations.SqsTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class SqsMessagePublisherAdapter implements MessagePublisherPort {
    private final SqsTemplate sqsTemplate;
    private final String queueName;

    public SqsMessagePublisherAdapter(SqsTemplate sqsTemplate, 
                                      @Value("${aws.sqs.queue.main}") String queueName) {
        this.sqsTemplate = sqsTemplate;
        this.queueName = queueName;
    }

    @Override
    public void publish(BusinessMessage message) {
        sqsTemplate.send(queueName, message);
    }
}

Il Listener Principale:

package com.tuoblog.enterprise.infrastructure.messaging;
import com.tuoblog.enterprise.domain.model.BusinessMessage;
import io.awspring.cloud.sqs.annotation.SqsListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Component
public class SqsMainConsumerAdapter {
    private static final Logger log = LoggerFactory.getLogger(SqsMainConsumerAdapter.class);

    @SqsListener("${aws.sqs.queue.main}")
    public void consumeMainMessage(BusinessMessage message) {
        log.info("📩 [CODA PRINCIPALE] Ricevuto messaggio ID: {}", message.id());
        
        if ("SIMULATE_FATAL_ERROR".equals(message.content())) {
            log.error("❌ Rilevata richiesta di errore. Simulo un blocco critico.");
            throw new RuntimeException("Errore inaspettato! Scatta il retry...");
        }
        log.info("✅ Elaborazione completata con successo.");
    }
}

Il Listener della DLQ (Gestione Errori):

package com.tuoblog.enterprise.infrastructure.messaging;
import com.tuoblog.enterprise.domain.model.BusinessMessage;
import io.awspring.cloud.sqs.annotation.SqsListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Component
public class SqsDlqConsumerAdapter {
    private static final Logger log = LoggerFactory.getLogger(SqsDlqConsumerAdapter.class);

    @SqsListener("${aws.sqs.queue.dlq}")
    public void consumeErrorMessage(BusinessMessage failedMessage) {
        log.warn("⚠️ [DLQ - CODA DI ERRORE] Intercettato messaggio fallito in via definitiva!");
        log.warn("Dettagli -> ID: {}, Contenuto: '{}'", failedMessage.id(), failedMessage.content());
        log.info("👯‍♂️ Salvataggio su database per ispezione manuale...");
    }
}

5. L'API REST (Presentation Layer)

package com.tuoblog.enterprise.presentation.api;
import com.tuoblog.enterprise.application.usecase.PublishMessageUseCase;
import com.tuoblog.enterprise.domain.model.BusinessMessage;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/messages")
public class MessageController {
    private final PublishMessageUseCase publishMessageUseCase;

    public MessageController(PublishMessageUseCase publishMessageUseCase) {
        this.publishMessageUseCase = publishMessageUseCase;
    }

    @PostMapping
    public ResponseEntity<String> createMessage(@RequestBody String payload) {
        BusinessMessage message = publishMessageUseCase.execute(payload);
        return ResponseEntity.ok("Accodato con successo ID: " + message.id());
    }

    @PostMapping("/simulate-error")
    public ResponseEntity<String> triggerErrorSequence() {
        BusinessMessage message = publishMessageUseCase.execute("SIMULATE_FATAL_ERROR");
        return ResponseEntity.status(202).body(
            "Messaggio 'velenoso' inviato (ID: " + message.id() + ")."
        );
    }
}

6. Collaudo Finale

Dopo aver applicato OpenTofu e avviato Spring Boot, usa cURL da terminale:

Test 1: Flusso corretto

curl -X POST http://localhost:8080/api/v1/messages -d "Tutto funziona alla grande!"

Test 2: Flusso di Errore (Intervento della DLQ)

curl -X POST http://localhost:8080/api/v1/messages/simulate-error

Separare le logiche tramite DDD e gestire proattivamente gli errori con una DLQ assicura che il tuo software sia pronto per l'ambiente di produzione, garantendo manutenibilità anche su progetti a lungo termine.

Nessun commento: