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-billingvira 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@SqsListenerprecisa extrair o conteúdo do campoMessage. 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 dafulfillment-queuee 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 comSseEmitternoms-notification. - Docker com LocalStack emulando SQS e SNS.
Clone o repositório (comandos assumem GitBash no Windows ou bash/zsh no Linux/Mac):
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.
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.
# 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:
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.
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:
<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:
app.queue.billing=billing-queue app.topic.payment-events=payment-events
# 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:
@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:
public record PaymentProcessedEvent( String paymentId, BigDecimal processedAmountUsd, BigDecimal processedTaxUsd, Instant processedAt ) {}
O BillingProcessorService ganha o publish no final, depois de persistir a fatura:
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.
@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); } }
@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:
@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:
@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:
@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:
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.
curl -N http://localhost:8083/api/notifications/stream/pay_e2e_001
A conexão fica aberta. Em outro terminal, dispare o webhook:
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-fulfillmentloga "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 -Nque mostra o evento SSE:event:payment-processedseguido do JSON doPaymentProcessedEvent.
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
- Amazon SNS, Página oficial
- Amazon SNS Developer Guide
- Spring Cloud AWS 4.0.0, SNS Reference
- Amazon SNS, Raw Message Delivery
- Spring Web, SseEmitter (Spring Framework Docs)
- LocalStack, SNS Documentation
- LocalStack, Auth Token
- Frankfurter API, Currency Exchange Rates
- Stripe, Webhooks Overview
- Canva, Product Analytics Event Collection (Engineering Blog)
- Twilio, Call Twilio APIs Serverless on Amazon AWS (Engineering Blog)
- Lyft, ML Platform Rearchitecture (InfoQ, dez/2025)
- AWS, Prime Day 2025 Key Metrics and Milestones
- LinkedIn Brasil, Vagas AWS
- LinkedIn Brasil, Vagas AWS SQS
- LinkedIn Brasil, Vagas AWS SNS
- Glassdoor Brasil, Senior Software Engineer
- Glassdoor Brasil, Senior Cloud Engineer


