Distribuindo Eventos com AWS SNS + SQS

    Foto do autor Rafael Sotero
    Rafael Sotero
    Compartilhar
    Compartilhar no LinkedInCompartilhar no FacebookCompartilhar no XCompartilhar no WhatsApp
    Imagem banner do post

    Quando um pagamento é processado, o cliente quer confirmação em tempo real, o serviço de "entrega" quer liberar o produto, o analytics quer métrica e o antifraude quer auditar. Se o `ms-billing` virar um polvo de chamadas HTTP síncronas, perdemos o desacoplamento do SQS. Mensageria continua sendo a saída, com um padrão diferente: em vez de uma fila ligada a um consumidor, um tópico SNS distribui a mesma mensagem para várias filas independentes.

    ℹ️ Este conteúdo é o Episódio 2 de 3 da série Dominando Mensageria na AWS. No Episódio 1 implementamos o padrão Point-to-Point com SQS. Aqui evoluímos para o padrão Pub/Sub com SNS + SQS.

    Do Point-to-Point ao Pub/Sub

    No episódio passado, o desenho era simples: produtor, fila, consumidor. Esse é o padrão Point-to-Point, que serve quando uma mensagem precisa ser processada por exatamente um destino.

    A limitação aparece quando vários serviços precisam reagir ao mesmo evento. O reflexo de quem pensa em REST é fazer o ms-billing chamar cada um por HTTP:

    Esse "emaranhado de chamadas síncronas" tem três problemas que aparecem rápido:

    • Acoplamento, o ms-billing vira um orquestrador disfarçado, conhecendo cada consumidor.
    • Fragilidade, se qualquer consumidor cair, a cascata de timeouts trava o pipeline.
    • Latência somada, o tempo total vira a soma de todas as chamadas.

    A saída é o padrão Pub/Sub. O produtor publica em um tópico, e qualquer assinante recebe. O produtor não sabe quem ouve, e novos consumidores entram sem mexer no produtor.

    E aqui aparece a regra de ouro da AWS: sempre coloque uma fila SQS na frente de cada consumidor, mesmo com SNS no meio. O SNS sozinho é push-based, e se o consumidor estiver fora do ar a mensagem se perde. Com a fila armazenando, ela espera até o consumidor processar. É o ganho do episódio 1 multiplicado por todos os consumidores.

    AWS SNS na prática

    O Amazon SNS (Simple Notification Service) é o serviço gerenciado de Pub/Sub da AWS. A unidade central é o tópico: produtores publicam, assinantes recebem. Diferente do SQS (pull-based), o SNS é push-based e suporta assinaturas HTTP/HTTPS, Email/SMS, AWS Lambda ou Amazon SQS.

    A combinação SNS com SQS tem nome próprio: fan-out. Um evento publicado vira mensagens em N filas, e cada consumidor lê seu fluxo no seu ritmo, com seu próprio Visibility Timeout.

    Eventos de Integração vs Eventos de Domínio

    Antes do código, vale separar dois conceitos que costumam ser confundidos:

    • Evento de integração (PaymentReceivedEvent), payload do webhook Stripe cruzando a fronteira do sistema. Carrega o que o Stripe contou, sem processamento.
    • Evento de domínio (PaymentProcessedEvent), fato consumado pelo billing após a regra de negócio: pagamento convertido em USD, imposto calculado, fatura persistida.

    O ingestor publica integração, o billing publica domínio. Quem quer saber sobre pagamentos processados assina payment-events, e não bisbilhota a billing-queue. Cada consumidor reage como achar melhor, sem ser orquestrado por ninguém.

    *Quer recapitular o que é o ms-ingestor e o ms-billing? Clique aqui, acesse o Episódio 1. *

    💡 Detalhe que evita dor de cabeça: ao criar a subscription SQS, marque RawMessageDelivery=true. Sem isso, o SNS embrulha o payload num envelope JSON e o @SqsListener precisa extrair o conteúdo do campo Message. Com a flag, a mensagem chega na fila como o JSON publicado e cai direto em um record Java.

    Por que dominar SNS + SQS?

    O padrão fan-out está em arquiteturas em produção mundo afora. O Canva conta no blog de engenharia que o pipeline de Product Analytics nasceu sobre SQS e SNS por serem fáceis de configurar, resilientes e elásticos. Mesmo depois de migrar o caminho principal para Kinesis por questão de custo, o SQS segue como fallback do funil que processa 25 bilhões de eventos por dia.

    A própria Twilio publicou um guia de produção em que SQS controla a vazão de chamadas à API com DelaySeconds e SNS distribui as respostas para múltiplos destinos, similar ao desenho fan-out deste artigo.

    No Brasil, o LinkedIn lista mais de 8.000 vagas com menção a AWS, é sem dúvidas a Cloud mais usada no mercado nacional, por isso não se engane, dominar event-driven na AWS é essencial. Para dar dimensão de escala, até onde vai o poder do SQS, na Prime Day 2025 o SQS bateu pico de 166 milhões de mensagens por segundo sustentando a operação da Amazon.

    Nosso Projeto

    Continuamos o cenário do Episódio 1: Stripe envia o webhook, o ms-payment-ingestor enfileira na billing-queue, o ms-billing consome e processa (câmbio Frankfurter, imposto, H2 com idempotência). A diferença: ao final do processamento, o billing publica um PaymentProcessedEvent no tópico SNS payment-events.

    Dois novos microsserviços assinam o tópico, cada um lendo da sua própria fila SQS:

    • ms-notification, está conectado com o cliente no navegador que espera uma notificação, poderia ser um e-mail ou sms, aqui usamos uma espécie de push notification com Server-Sent Events (SSE), quando o notifcação receber a confirmação de pagamento, avisa o cliente.
    • ms-fulfillment, consome da fulfillment-queue e loga a liberação do produto/serviço. Serviço simples, suficiente para mostrar que o segundo consumidor recebe a mensagem de forma independente.

    Cada microsserviço roda em uma porta local diferente: ingestor (8081), billing (8082), notification (8083), fulfillment (8084).

    Mão na Massa

    O projeto está disponível no repositório do blog. A stack é continuação do Episódio 1:

    • Java 25, Spring Boot 4.0.5, Spring Cloud AWS 4.0 (starters SQS e SNS).
    • Spring Data JPA + H2 no ms-billing, Spring Web com SseEmitter no ms-notification.
    • Docker com LocalStack emulando SQS e SNS.

    Clone o repositório (comandos assumem GitBash no Windows ou bash/zsh no Linux/Mac):

    Copiar
    git clone https://github.com/devsuperior/blog.git
    cd blog/articles/distribuindo-eventos-com-aws-sns-sqs/projects

    Subindo a infraestrutura local

    ⚠️ A partir de março de 2026, o LocalStack exige um LOCALSTACK_AUTH_TOKEN. Crie conta gratuita em app.localstack.cloud, gere o token e exporte como variável de ambiente antes de subir o container. Detalhes no guia oficial.

    A diferença em relação ao docker-compose.yml do episódio 1 é apenas o SERVICES=sqs,sns.

    Copiar
    services:
      localstack:
        image: localstack/localstack:latest
        container_name: localstack-sns-sqs
        ports:
          - "4566:4566"
        environment:
          - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN}
          - SERVICES=sqs,sns
          - AWS_DEFAULT_REGION=us-east-1
        volumes:
          - ./init-scripts:/etc/localstack/init/ready.d

    O script localstack/init-scripts/01-create-topology.sh cria automaticamente as 3 filas (billing-queue, notification-queue, fulfillment-queue), o tópico (payment-events) e as 2 subscriptions com RawMessageDelivery=true. O LocalStack o executa assim que fica saudável, então a topologia já estará pronta quando o container subir.

    Copiar
    # Exporte o token gerado em https://app.localstack.cloud
    export LOCALSTACK_AUTH_TOKEN=ls-xxxxxxxxxxxxxxxxxxxxxxxx
    
    # Credenciais dummy para o aws cli (LocalStack aceita qualquer valor)
    export AWS_SESSION_TOKEN=
    export AWS_ACCESS_KEY_ID=test
    export AWS_SECRET_ACCESS_KEY=test
    
    cd localstack
    docker-compose up -d

    Confirme que a topologia subiu:

    Copiar
    aws --endpoint-url=http://localhost:4566 sns list-topics --region us-east-1
    aws --endpoint-url=http://localhost:4566 sqs list-queues --region us-east-1
    aws --endpoint-url=http://localhost:4566 sns list-subscriptions --region us-east-1

    Você verá 1 tópico, 3 filas e 2 subscriptions. Infraestrutura no ar.

    Recap do ingestor (sem mudanças)

    O ms-payment-ingestor recebe o webhook e enfileira na billing-queue, igual ao episódio 1. A única alteração estética é o nome da classe DTO, agora PaymentReceivedEvent, deixando explícito que é um evento de integração.

    💡 Os blocos de código mostram apenas o essencial. Imports e boilerplate foram omitidos. Clone o projeto completo para acompanhar na IDE.

    Copiar
    public record PaymentReceivedEvent(
            String paymentId,
            BigDecimal amount,
            String currency,
            String status,
            Instant createdAt
    ) {}

    O PaymentQueueService continua fazendo sqsTemplate.send(billingQueue, event) e só. A novidade está toda no billing.

    ms-billing: agora também publica no SNS

    O pom.xml ganha uma dependência:

    Copiar
    <dependency>
        <groupId>io.awspring.cloud</groupId>
        <artifactId>spring-cloud-aws-starter-sns</artifactId>
    </dependency>

    E duas propriedades no application.properties apontam tópico e endpoint local:

    Copiar
    app.queue.billing=billing-queue
    app.topic.payment-events=payment-events
    Copiar
    # application-local.properties
    spring.cloud.aws.sns.endpoint=http://localhost:4566

    A starter SNS auto-configura o bean SnsTemplate, mesmo padrão do SqsTemplate. Para fazer a publicação invocamos o método sendNotification, veja abaixo:

    Copiar
    @Service
    public class PaymentEventPublisher {
    
        private final SnsTemplate snsTemplate;
    
        @Value("${app.topic.payment-events}")
        private String paymentEventsTopic;
    
        public PaymentEventPublisher(SnsTemplate snsTemplate) {
            this.snsTemplate = snsTemplate;
        }
    
        public void publish(PaymentProcessedEvent event) {
            log.info("Publicando PaymentProcessedEvent {} no tópico {}",
                    event.paymentId(), paymentEventsTopic);
            snsTemplate.sendNotification(paymentEventsTopic, event, null);
            log.info("PaymentProcessedEvent {} publicado com sucesso", event.paymentId());
        }
    }

    O método sendNotification(topicName, payload, subject) aceita o nome do tópico quando a região está configurada via spring.cloud.aws.region.static. O subject é um campo opcional curto, até 100 caracteres, que vai nos atributos da mensagem SNS e se torna útil quando o consumidor é Email (vira o assunto do e-mail) ou HTTP (vira header). Para SQS subscribers, como é o caso aqui, o subject é entregue como atributo da mensagem (Header) mas não vamos usar por enquanto, então passamos null sem problemas. Por baixo, o Spring Cloud AWS resolve a ARN, serializa via Jackson e chama Publish.

    O PaymentProcessedEvent é o evento de domínio, com os valores já calculados:

    Copiar
    public record PaymentProcessedEvent(
            String paymentId,
            BigDecimal processedAmountUsd,
            BigDecimal processedTaxUsd,
            Instant processedAt
    ) {}

    O BillingProcessorService ganha o publish no final, depois de persistir a fatura:

    Copiar
    public void process(PaymentReceivedEvent event) {
        if (repository.existsByPaymentId(event.paymentId())) {
            log.warn("Pagamento {} já foi processado, ignorando duplicata", event.paymentId());
            return;
        }
    
        BigDecimal amountUsd = currencyConverter.toUsd(event.amount(), event.currency());
        BigDecimal taxAmount = amountUsd.multiply(TAX_RATE).setScale(2, RoundingMode.HALF_UP);
        BigDecimal netAmount = amountUsd.subtract(taxAmount).setScale(2, RoundingMode.HALF_UP);
    
        ProcessedPayment payment = new ProcessedPayment(
                event.paymentId(), event.amount(), event.currency(),
                amountUsd.setScale(2, RoundingMode.HALF_UP),
                taxAmount, netAmount, "PROCESSED", Instant.now()
        );
        repository.save(payment);
    
        // Fan-out: publica o evento de domínio para todos os interessados
        PaymentProcessedEvent domainEvent = new PaymentProcessedEvent(
                payment.getPaymentId(),
                payment.getConvertedAmountUsd(),
                payment.getTaxAmount(),
                payment.getProcessedAt()
        );
        eventPublisher.publish(domainEvent);
    }

    A idempotência continua sendo a primeira checagem: se o paymentId já está no banco, o método retorna sem publicar, evitando que duplicatas vazem para os consumidores.

    ms-fulfillment: liberação do produto/serviço (o primeiro consumidor)

    O ms-fulfillment é proposital simples: recebe da fulfillment-queue e loga a liberação. O ponto aqui é o gancho do tema deste artigo. Apesar do SNS no meio, o ms-fulfillment faz a integração via SQS, sem saber que existe um tópico, um billing ou outros consumidores. É o fan-out que faz isso: o mesmo evento alimenta consumidores independentes, sem o produtor saber de nenhum. Um terceiro serviço, por exemplo um ms-analytics ou um ms-antifraude, entraria de forma idêntica: cria a fila, assina o tópico, escreve o listener.

    Copiar
    @Component
    public class FulfillmentQueueListener {
    
        private final FulfillmentService fulfillmentService;
    
        public FulfillmentQueueListener(FulfillmentService fulfillmentService) {
            this.fulfillmentService = fulfillmentService;
        }
    
        @SqsListener("${app.queue.fulfillment}")
        public void onPaymentProcessed(PaymentProcessedEvent event) {
            log.info("Fulfillment recebido para pagamento {}", event.paymentId());
            fulfillmentService.releaseProduct(event);
        }
    }
    Copiar
    @Service
    public class FulfillmentService {
    
        public void releaseProduct(PaymentProcessedEvent event) {
            log.info("Liberando produto/serviço para pagamento {}, valor líquido {} USD",
                    event.paymentId(),
                    event.processedAmountUsd().subtract(event.processedTaxUsd()));
        }
    }

    ms-notification: avisos para o cliente (o segundo consumidor)

    O ms-fulfillment se mostrou como o primeiro consumidor, mas como estamos falando de Fan/Out e consequentemente Pub/Sub, enquanto o ms-billing é o publicante, o ms-notification é o segundo inscrito, isso significa que a mesma mensagem chega paralelamente ao dois consumidores (fulfillment e notification).

    Notificação em tempo real é um dos vários destinos possíveis do mesmo evento. O PaymentProcessedEvent poderia disparar um e-mail, um SMS, um push notification mobile via Firebase ou um WebSocket dedicado. SSE é só uma das formas, e foi a escolhida aqui por dar demonstração visível em segundos no terminal ou no navegador, sem dependência externa. Como o stream é por paymentId, o cliente que fez aquele pagamento recebe apenas o evento dele, em vez de ouvir tudo o que passa pela fila.

    O controller expõe /api/notifications/stream/{paymentId} retornando um SseEmitter com timeout zero:

    Copiar
    @RestController
    @RequestMapping("/api/notifications")
    public class NotificationStreamController {
    
        private static final Logger log = LoggerFactory.getLogger(NotificationStreamController.class);
    
        private final NotificationBroadcaster broadcaster;
    
        public NotificationStreamController(NotificationBroadcaster broadcaster) {
            this.broadcaster = broadcaster;
        }
    
        @GetMapping(path = "/stream/{paymentId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
        public SseEmitter stream(@PathVariable String paymentId) {
            SseEmitter emitter = new SseEmitter(0L); // 0 = sem timeout (mantem aberto)
            broadcaster.register(paymentId, emitter);
            log.info("Novo cliente conectado ao stream do pagamento {}", paymentId);
            return emitter;
        }
    }

    O listener da fila é o gatilho: ao receber uma mensagem da notification-queue, delega ao broadcaster:

    Copiar
    @Component
    public class NotificationQueueListener {
    
        private final NotificationBroadcaster broadcaster;
    
        public NotificationQueueListener(NotificationBroadcaster broadcaster) {
            this.broadcaster = broadcaster;
        }
    
        @SqsListener("${app.queue.notification}")
        public void onPaymentProcessed(PaymentProcessedEvent event) {
            log.info("Notificacao recebida da fila para pagamento {}", event.paymentId());
            broadcaster.broadcast(event);
        }
    }

    O NotificationBroadcaster mantém listas de emitters por paymentId, com cleanup de conexões mortas e remoção da entry quando a lista fica vazia:

    Copiar
    @Service
    public class NotificationBroadcaster {
    
        private static final Logger log = LoggerFactory.getLogger(NotificationBroadcaster.class);
    
        private final Map<String, CopyOnWriteArrayList<SseEmitter>> emittersByPaymentId = new ConcurrentHashMap<>();
    
        public void register(String paymentId, SseEmitter emitter) {
            emittersByPaymentId
                    .computeIfAbsent(paymentId, key -> new CopyOnWriteArrayList<>())
                    .add(emitter);
    
            emitter.onCompletion(() -> removeEmitter(paymentId, emitter, "completion"));
            emitter.onTimeout(() -> removeEmitter(paymentId, emitter, "timeout"));
        }
    
        public void broadcast(PaymentProcessedEvent event) {
            List<SseEmitter> targets = emittersByPaymentId.get(event.paymentId());
            if (targets == null || targets.isEmpty()) {
                log.info("Nenhum cliente conectado ao stream do pagamento {}, evento descartado para SSE",
                        event.paymentId());
                return;
            }
    
            log.info("Broadcast do pagamento {} para {} cliente(s) conectado(s)",
                    event.paymentId(), targets.size());
    
            for (SseEmitter emitter : targets) {
                try {
                    emitter.send(SseEmitter.event()
                            .name("payment-processed")
                            .data(event));
                } catch (IOException e) {
                    log.warn("Falha ao enviar evento para emitter do pagamento {}, removendo: {}",
                            event.paymentId(), e.getMessage());
                    removeEmitter(event.paymentId(), emitter, "io-error");
                }
            }
        }
    
        private void removeEmitter(String paymentId, SseEmitter emitter, String reason) {
            CopyOnWriteArrayList<SseEmitter> list = emittersByPaymentId.get(paymentId);
            if (list == null) {
                return;
            }
            list.remove(emitter);
            if (list.isEmpty()) {
                emittersByPaymentId.remove(paymentId, list);
            }
            log.info("Emitter do pagamento {} removido (motivo: {}), restam {}",
                    paymentId, reason, list.size());
        }
    }

    A escolha das estruturas resolve dois problemas em paralelo: ConcurrentHashMap permite alterações concorrentes do map sem lock global, e CopyOnWriteArrayList deixa a iteração no broadcast segura sem ConcurrentModificationException. Quando a lista de um paymentId esvazia, a entry sai do map, evitando leak ao longo do dia, conforme paymentIds nascem e morrem.

    Teste fim-a-fim

    Suba os 4 serviços, cada um em um terminal, com o profile local:

    Copiar
    cd ms-payment-ingestor && SPRING_PROFILES_ACTIVE=local ./mvnw spring-boot:run
    cd ms-billing           && SPRING_PROFILES_ACTIVE=local ./mvnw spring-boot:run
    cd ms-notification      && SPRING_PROFILES_ACTIVE=local ./mvnw spring-boot:run
    cd ms-fulfillment       && SPRING_PROFILES_ACTIVE=local ./mvnw spring-boot:run

    Em um terminal separado, conecte ao stream SSE do paymentId que vai usar logo a seguir, antes de disparar o webhook. O endpoint agora exige o paymentId como path param, então o stream entrega apenas eventos daquele pagamento e ignora os demais.

    Copiar
    curl -N http://localhost:8083/api/notifications/stream/pay_e2e_001

    A conexão fica aberta. Em outro terminal, dispare o webhook:

    Copiar
    curl -X POST http://localhost:8081/api/payments/webhook \
      -H "Content-Type: application/json" \
      -d '{
        "paymentId": "pay_e2e_001",
        "amount": 299.90,
        "currency": "BRL",
        "status": "succeeded",
        "createdAt": "2026-04-29T10:30:00Z"
      }'

    A resposta é imediata: {"status":"accepted"}. Em segundos, as duas coisas acontecem em paralelo:

    • O terminal do ms-fulfillment loga "Liberando produto/serviço para pagamento pay_e2e_001, valor líquido aproximadamente 57 USD" (o valor exato varia conforme a cotação BRL/USD do dia).
    • O terminal do ms-notification consome o evento e dispara para o cliente ver a notificação no termina do curl -N que mostra o evento SSE: event:payment-processed seguido do JSON do PaymentProcessedEvent.

    Se você checar o H2 console em http://localhost:8082/h2-console (JDBC URL jdbc:h2:mem:billingdb, user sa, sem senha) mostra a fatura persistida em SELECT * FROM processed_payments.

    Para sentir a idempotência, dispare o mesmo curl com o mesmo paymentId. O ms-billing loga "ignorando duplicata" e nem publica no SNS. Nenhum evento novo no SSE, nenhum log novo no fulfillment, mesma linha no H2. Idempotência garantida na fronteira do domínio, antes do fan-out.

    Conclusão

    Em poucas linhas a mais que o episódio 1, saímos de um pipeline ponto a ponto para Pub/Sub genuíno. Os ganhos:

    • O produtor ignora os consumidores, basta publicar e seguir a vida.
    • Os consumidores são independentes, cada um com sua fila, ritmo e Visibility Timeout.
    • Novos serviços viram plug-and-play, basta criar uma fila e assinar o tópico.
    • O cliente final recebe notificação em tempo real via SSE, sem polling.

    Tudo isso sem mexer em uma linha do produtor original. É o poder do isolamento que o tópico SNS oferece.

    Reforçando o ponto central deste episódio: Pub/Sub não é só uma forma elegante de enviar mensagens, é uma mudança de quem conhece quem. O produtor publica e some. Os consumidores aparecem por conta própria, cada um responsável pelo seu pedaço, no seu ritmo. Adicionar um terceiro consumidor é trivial e consiste em três passos previsíveis: cria fila, assina tópico, escreve listener.

    E é o fan-out que dá esse poder de escala. Um único PaymentProcessedEvent pode alimentar ms-notification para o cliente final, ms-fulfillment para o time logístico, um ms-analytics para o time de dados, um ms-antifraude para o time de risco e um ms-audit para a trilha regulatória, sem que o ms-billing precise sequer saber da existência deles. É o oposto do emaranhado de chamadas síncronas com que abrimos o artigo.

    No próximo episódio, dois problemas que aparecem assim que o sistema cresce: O que fazer quando o processamento da mensagem falha? Como monitorar em tempo real seu sistema Event Driven? É o que vamos atacar no Episódio 3. Até lá! 🚀

    Fontes

    Foto do autor Rafael Sotero
    Rafael Sotero
    Especialista em Engenharia de Software
    Tenho mais de 12 anos de experiência na concepção e implementação de soluções robustas e escaláveis. Atuei em grandes empresas do setor financeiro e telecom levando inovação e simplicidade em ambientes distribuídos com Java, Python, e Cloud AWS.