Lab 31 — Quebrar o monolito pelo corte certo
O problema, e a empresa que o tem
A Cadência, do L03, cresceu: 45 lojas, cinco pessoas no time, e o mesmo monólito .NET 8 em ECS Fargate que atendia pedido agora também administra o catálogo de produtos e envia e-mail de confirmação. Os três domínios vivem no mesmo processo, no mesmo schema do RDS e na mesma suíte de testes.
O sintoma que trouxe o time até aqui: um designer pediu para trocar o texto do e-mail de confirmação. A mudança foi uma string. O pipeline rodou as 2.400 asserções de integração da suíte inteira — pedido, catálogo e notificação — e levou 40 minutos. O deploy reiniciou a mesma task que atende todo pedido em andamento. Nenhuma dessas duas coisas tinha relação com o que mudou.
A reação óbvia — "separa em microsserviços" — é onde a maioria dos times erra: separar por SI só não resolve nada; separar pela fronteira ERRADA transforma um deploy lento num bug de concorrência raro, que é pior. Este laboratório aplica um teste único a três candidatos de extração antes de escrever qualquer Terraform.
O que este laboratório NÃO é
Não é "reescrever em microsserviços". Só UM serviço é extraído, de propósito — e estoque, que reprova o teste de fronteira transacional deste módulo, fica exatamente onde estava. Extrair tudo de uma vez é o erro mais caro desta banda, e o laboratório mostra por que evitá-lo é decisão, não preguiça.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação.
- Aplicar o teste de fronteira transacional a um candidato de extração e justificar a decisão por escrito, não por intuição.
- Nomear o antipadrão "monólito distribuído" e reconhecer o sintoma dele numa arquitetura real.
- Implementar o padrão outbox no EF Core, gravando pedido e evento na mesma transação.
- Explicar por que SQS Standard exige consumidor idempotente, e implementar essa checagem.
- Provar que a criação do pedido sobrevive à indisponibilidade do serviço extraído.
- Provar que a reentrega de uma mensagem não produz e-mail duplicado.
- Distinguir qual dos três candidatos de extração (notificações, catálogo, estoque) passa e qual reprova o teste, e explicar por quê.
- Diagnosticar overselling como sintoma de fronteira transacional quebrada, não como bug de concorrência genérico.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Fronteira por capacidade de negócio | SAP-C02 | teste "a transação termina sozinha?" aplicado a 3 candidatos | reconhecer decomposição por camada técnica como o erro clássico do cenário de prova |
| Acoplamento temporal síncrono vs assíncrono | SAP-C02, DVA-C02 | fila SQS no lugar da chamada de método entre pedido e notificação | por que uma chamada HTTP direta recria o monólito com rede no meio |
| Monólito distribuído (antipadrão) | SAP-C02 | alternativa descartada explicitamente na seção de decisões | reconhecer o sintoma: deploy separado, indisponibilidade ainda acoplada |
| Consistência eventual e idempotência | SAP-C02, DVA-C02 | consumidor confere pedido_id antes de agir | SQS Standard entrega pelo menos uma vez, nunca exatamente uma |
| Dual-write problem / transactional outbox | SAP-C02 | tabela outbox na mesma transação do pedido | por que publicar antes do commit perde evento, e depois do commit também pode perder |
| Database per service | SAP-C02 | RDS próprio para o serviço extraído | isolamento de falha e de schema; o trade-off é não poder mais fazer JOIN entre domínios |
| Saga como alternativa a 2PC | SAP-C02 | citado e adiado — estoque não é extraído neste laboratório | reconhecer quando a fronteira exige compensação (L35) em vez de extração direta |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve dois serviços com deploy, repositório e pipeline separados, e pede se isso é "arquitetura de microsserviços". A resposta certa verifica ACOPLAMENTO, não FORMA: se a chamada entre eles é síncrona no caminho crítico, é monólito distribuído com aparência de microsserviço — a rede entre eles não trocou dependência por independência.
Requisitos, e como cada um muda o desenho
A coluna da direita é obrigatória: requisito que não deixa marca no desenho é intenção, não decisão.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Mudança de domínio não re-testa outro domínio | suíte separada, minutos, não 40 min | repositório e pipeline próprios para o serviço extraído |
| Pedido não pode falhar por causa do e-mail | zero acoplamento síncrono | fila entre os dois; outbox garante que o evento não se perde nem trava o commit |
| Sem overselling | zero venda além do estoque disponível | estoque permanece na MESMA transação do pedido — não é extraído neste laboratório |
| Cliente tolera atraso na confirmação | até alguns minutos | autoriza fila com retry em vez de exigir resposta síncrona ao cliente |
| Entrega de e-mail resiliente a falha transitória | reentrega automática | DLQ após 5 tentativas mais alarme — não perda silenciosa |
| Time de 5 devs, sem SRE dedicado | operação simples | um corte só neste laboratório, não dois — catálogo fica para depois |
| Rastreabilidade de qual pedido gerou qual e-mail | auditável | log de envio no banco do serviço de notificação, indexado por pedido_id |
Requisito sem coluna de desenho é intenção, não decisão
Cada linha acima só entrou porque alguém consegue apontar, no diagrama de produção, a peça exata que ela justifica. "Sem overselling" não virou um serviço novo — virou a AUSÊNCIA deliberada de um, porque a peça que atenderia esse requisito ainda não é segura de construir.
Arquitetura de hoje: o monólito que testa tudo para mudar uma linha
Este é o desenho que a Cadência tem, herdado do L03, e ele continua legítimo: publica de verdade e serviu bem até cinco pessoas. O laboratório começa por medir o custo dele em vez de assumi-lo — porque "está lento" não é discutível, e "40 minutos e reinício total para uma string" é.
- → commit em qualquer arquivo do monorepo
- → build da imagem única, após a suíte inteira passar
- → atualiza o serviço com a imagem nova
- → pull da imagem que contém os três domínios
- → HTTPS 443
- → encaminha para o mesmo processo, qualquer rota
- → lê e escreve pedido, produto e notificação na mesma transação
- Fora da AWS
- Gestão e governança
- Compute
- Rede e entrega
- Banco de dados
Este desenho é o L03 já em produção — e continua legítimo como está. O defeito não é a topologia: é que os três domínios de negócio (pedido, catálogo, notificação) compilam no mesmo binário, testam na mesma suíte e implantam na mesma task. Percorra os passos e repare que o cliente que só faz pedido paga o preço de uma mudança que ele nunca vai perceber.
- Um commit, uma suíte inteira. A suíte de integração não sabe distinguir "mudei um template de e-mail" de "mudei a regra de preço do pedido": ela roda as 2.400 asserções dos três domínios porque todos compartilham o mesmo `DbContext` de teste e o mesmo processo de inicialização.
- Uma imagem, três domínios compilados juntos. O build só produz um artefato. Não existe "publicar só a notificação": o ECR recebe sempre o binário inteiro, e o próximo passo implanta ele inteiro também.
- O redeploy reinicia quem não mudou. A troca de imagem do serviço ECS substitui a task inteira. O código de pedido e de catálogo, que não mudou uma linha, é recompilado, reempacotado e reiniciado junto.
- O cliente que só faz pedido sente a mudança de notificação. Durante o rollout — mesmo o rolling update sem janela do L03 — existe uma janela de reinício. Ela acontece para TODO cliente, mesmo os que nunca vão notar a mudança que a motivou.
- A transação única no banco é o que amarra tudo por baixo. Pedido, produto e log de notificação vivem no mesmo schema, na mesma conexão, na mesma unidade de trabalho do EF Core. É o que torna qualquer separação ingênua arriscada: quebrar essa transação sem entender o que ela garante quebra uma garantia junto.
- Por que ninguém separou isso ainda. Porque funcionou até cinco pessoas e 45 lojas. Separar por separar, sem saber qual fronteira é segura, troca "deploy lento" por "bug de concorrência raro" — e o segundo é pior.
O defeito não está em nenhuma linha de configuração
Diferente do L03, aqui não há um parâmetro errado para corrigir. O defeito é estrutural: três domínios de negócio compartilham processo, schema e suíte. Nenhum ajuste de `deployment_maximum_percent` ou de health check resolve isso — a mudança tem de ser na fronteira, não no ajuste fino.
O teste, antes de qualquer Terraform
A pergunta que decide se um serviço está pronto para ser extraído é uma só: este serviço consegue terminar a própria transação sem depender de outro serviço no mesmo commit? Aplicada aos três candidatos do monólito da Cadência, ela dá três respostas diferentes — e só isso já vale mais do que qualquer diagrama.
| Candidato | A escrita que ele faz | Precisa ser atômica com o pedido? | Resultado do teste |
|---|---|---|---|
| Notificações | grava um log de e-mail enviado | Não — o pedido é válido mesmo que o e-mail atrase, falhe uma vez ou seja reenviado | PASSA — extraído neste laboratório |
| Catálogo / Produto | grava preço e nome de referência | Não no momento da escrita do pedido — o preço e o nome são copiados (snapshot) para dentro do próprio pedido, então nunca há JOIN vivo com o catálogo depois | PASSA, mas fica para depois — dois cortes de uma vez custa mais do que dois cortes valem |
| Estoque | decrementa a quantidade disponível do produto | Sim — sem atomicidade, duas requisições concorrentes podem ler "1 disponível" ao mesmo tempo e vender a mesma unidade duas vezes | REPROVA — continua no monólito até existir saga (L35) |
Overselling não é bug raro; é matemática de concorrência
Se estoque fosse extraído hoje com uma chamada de rede simples entre pedido e estoque, a janela entre "ler disponibilidade" e "decrementar" deixa de ser protegida por uma transação de banco e passa a ser protegida por nada. Duas requisições na mesma unidade, no mesmo milissegundo, conseguem as duas passar na checagem — e a loja vende o que não tem. Isto é dinheiro saindo da conta da Cadência, não um detalhe técnico.
Arquitetura para produção: o corte único
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. Estoque não aparece como serviço separado porque a decisão foi NÃO extraí-lo — e essa ausência também é rastreável: ela vem de reprovar o teste da seção anterior.
- → pipeline e suíte só do serviço de notificações
- → HTTPS — cria o pedido
- → rotas de pedido e catálogo
- → grava pedido + linha de outbox, mesma transação
- → publica PedidoCriado depois do commit (worker de outbox)
- → entrega PedidoCriado, pelo menos uma vez
- → checa e grava idempotência pelo pedido_id
- → envia e-mail de confirmação
- → esgotou as tentativas de entrega
- → profundidade da fila de mensagens não processadas
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
- Integração de apps
- Gestão e governança
A mudança estrutural não é "mais uma caixa": é a existência de DUAS unidades de deploy, cada uma com o próprio pipeline, o próprio banco e a própria suíte — e uma fila no lugar da chamada de método que existia dentro do processo. Percorra os passos: cada peça nova resolve exatamente o requisito que a extraiu, e estoque continua de propósito no monólito.
- O pedido escreve dois registros na mesma transação. A tabela `outbox_messages` recebe uma linha no MESMO `SaveChanges` que grava o pedido. Ou os dois são commitados juntos, ou nenhum é — é isso que impede o "dual-write problem": nunca existe um pedido salvo sem a intenção de publicar registrada.
- A publicação só acontece depois do commit. Um worker em segundo plano lê linhas não publicadas do outbox e as envia para a fila. Se ele falhar, tenta de novo na próxima varredura — o dado de origem (a linha no outbox) não desaparece enquanto não for marcado como publicado.
- A fila separa os dois relógios. A criação do pedido não espera o e-mail. Se o serviço de notificações estiver fora do ar, a mensagem fica retida na fila até ele voltar — a criação do pedido continua respondendo 201 normalmente.
- A idempotência mora no consumidor, não na fila. Fila padrão entrega PELO MENOS uma vez, não exatamente uma. O consumidor confere se o `pedido_id` já está no log de envio antes de acionar o SES — é essa checagem, e não a fila, que impede e-mail duplicado.
- O e-mail que falha não é o pedido que falha. Uma rejeição do SES — endereço inválido, limite de envio — fica isolada no serviço de notificações. O pedido já está commitado do outro lado da fila havia minutos; nada aqui desfaz isso.
- Mensagem envenenada vai para a DLQ, não em laço infinito. Depois do número máximo de tentativas, a fila redireciona a mensagem para a DLQ em vez de reentregá-la para sempre. O alarme sobre a profundidade dela é o que avisa alguém, em vez de o cliente descobrir sozinho que nunca recebeu confirmação.
- Deploy do serviço de notificações não testa pedido. É a diferença estrutural em relação ao desenho anterior: o pipeline que roda quando alguém muda o texto de um e-mail não toca mais em nenhuma linha de código de pedido ou catálogo.
O payload que o outbox publica na fila, e que o consumidor de notificacoes le. Contem so o que o consumidor precisa — nao o pedido inteiro.
{
"pedidoId": "b6f2a9d4-2e1a-4b3c-9a7f-1c8d6e0f2a11",
"produtoId": "f1a2b3c4-5678-4abc-9def-0123456789ab",
"nomeProdutoNoMomento": "Cadeira de escritorio ergonomica",
"criadoEm": "2026-08-07T14:22:31.618Z"
}Por que o payload não carrega o pedido inteiro
Enviar só os campos que o consumidor de fato usa evita que o serviço de notificações dependa silenciosamente de um campo do pedido que pode mudar de forma no monólito. Menos campo no contrato é menos superfície de quebra quando um dos dois lados evolui sozinho — que é exatamente o ganho que motivou a extração.
As decisões, e o que se perde em cada uma
📋 A Cadência cresceu de 30 para 45 lojas e de duas para cinco pessoas no time. Mudar o texto de um e-mail de confirmação exige rodar as 2.400 asserções da suíte inteira e reimplantar o artefato completo. O time quer parar de pagar esse preço, sem herdar transação distribuída antes de precisar dela.
Notificações é o único dos três candidatos que passa no teste de fronteira transacional sem ressalva: o pedido é válido mesmo que o e-mail atrase, falhe ou seja reenviado. Extrair um corte só, e não dois, mantém a superfície operacional do time de cinco pessoas gerenciável — provar o padrão com um serviço antes de escalar para mais é o que evita repetir um erro de decomposição em paralelo, em vez de um por vez.
Alt: Extrair Estoque junto — Reprova o teste: sem atomicidade com a criação do pedido, duas requisições concorrentes podem vender a última unidade duas vezes. Corrigir isso exige saga com compensação (L35), que o time ainda não tem — extrair agora troca um bug impossível por um raro, o que não é vitória.
Alt: Extrair Catálogo também, no mesmo corte — Passa no mesmo teste que Notificações — o pedido copia preço e nome para dentro do próprio item, sem depender de leitura ao vivo. Mas dois serviços novos de uma vez dobram pipeline, banco e superfície de falha para um time que nunca operou nem um. Fica para depois de o primeiro corte provar o padrão.
Alt: Extrair Notificações com chamada HTTP síncrona, sem fila — Troca uma dependência interna (chamada de método) por uma externa igualmente rígida: se o serviço de notificações cair, a criação do pedido cai junto. É o "monólito distribuído" — parece microsserviço porque tem deploy separado, e continua acoplado porque a indisponibilidade de um derruba o outro.
Alt: Não extrair nada; só separar em projetos/namespaces dentro do mesmo deploy — Melhora a organização do código e não resolve o problema real, que é acoplamento de DEPLOY e de TESTE. O pipeline continua rodando a suíte inteira e reimplantando o artefato inteiro para qualquer mudança, porque a fronteira de processo não mudou.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Qual serviço extrair primeiro | Notificações | Catálogo; Estoque | passa no teste de fronteira transacional sem ressalva nenhuma | catálogo fica para depois; o time reavalia quando o próximo ponto de dor aparecer |
| Como propagar o evento | Outbox + SQS | chamada HTTP síncrona; publicar antes do commit sem outbox | nunca publica um evento cujo pedido não foi commitado, nem perde o pedido se a fila estiver fora do ar | um worker a mais rodando, e o evento chega com atraso de segundos, não instantâneo |
| Persistência do serviço extraído | RDS próprio (instância separada) | schema separado na mesma instância do monólito | isolamento de falha e de deploy de schema completos; um ALTER TABLE de notificações não trava nada do monólito | uma instância RDS a mais na fatura, ainda que pequena |
| Idempotência da entrega | checagem por pedido_id no consumidor | confiar na deduplicação nativa da fila | SQS Standard entrega pelo menos uma vez, não exatamente uma; a aplicação tem de tolerar reentrega | mais uma tabela e uma consulta a cada mensagem processada |
| O que NÃO extrair agora | Estoque continua no monólito | extrair com chamada síncrona; extrair já com saga | decrementar estoque exige atomicidade com a criação do pedido — sem saga (L35), extrair agora troca "impossível vender 2× o último item" por "possível, ocasionalmente" | nenhuma; adiar corretamente não é abrir mão, é sequenciar |
| Rollback do serviço extraído | independente, por tag SHA (reusa o L03) | rollback conjunto dos dois serviços | cada serviço tem o próprio histórico de deploy; reverter notificações não reverte pedido | os dois podem ficar em revisões "descasadas" — aceitável, porque não compartilham transação |
A dívida que este corte cria, e que este módulo não paga
Com dois serviços e um contrato de evento em JSON cru, mudar o formato do evento sem combinar com quem consome quebra o consumidor em silêncio. Registro de schema e versionamento de evento é o L24; enquanto você não tiver isso, trate o payload como contrato público e só adicione campo, nunca remova ou renomeie.
Construir: a infraestrutura do corte
Duas peças novas sustentam o corte inteiro: um banco que o monólito não enxerga, e uma fila que só o worker de outbox pode escrever.
# extracao.tf — a infraestrutura do corte: banco proprio, fila, e os dois papeis
# ── O banco do servico extraido. Nao e o mesmo RDS do monolito. ───────────────
resource "aws_db_instance" "notificacoes" {
identifier = "${var.projeto}-notificacoes"
engine = "postgres"
engine_version = "16"
instance_class = "db.t4g.micro" # volume baixo: so o log de envio, sem pedido nem catalogo
db_name = "notificacoes"
username = "app"
manage_master_user_password = true # segredo gerenciado pelo Secrets Manager, nunca em variavel
allocated_storage = 20
db_subnet_group_name = aws_db_subnet_group.privada.name
vpc_security_group_ids = [aws_security_group.rds_notificacoes.id]
# Um ALTER TABLE aqui nao trava NENHUMA tabela do monolito: e um banco fisicamente
# separado. E o ponto central do padrao "database per service".
skip_final_snapshot = false
final_snapshot_identifier = "${var.projeto}-notificacoes-final"
}
# O security group do RDS aceita conexao SO da task de notificacoes — nunca
# 0.0.0.0/0, e nunca a task do monolito, que nao tem nada para ler ali.
resource "aws_security_group_rule" "rds_notificacoes_entrada" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aws_security_group.rds_notificacoes.id
source_security_group_id = aws_security_group.task_notificacoes.id
}
# ── A fila entre os dois servicos, com DLQ dedicada. ───────────────────────────
resource "aws_sqs_queue" "pedido_criado_dlq" {
name = "${var.projeto}-pedido-criado-dlq"
message_retention_seconds = 1209600 # 14 dias — o teto do SQS; da tempo de investigar sem perder a mensagem
}
resource "aws_sqs_queue" "pedido_criado" {
name = "${var.projeto}-pedido-criado"
# Nao alteramos o padrao de 30s de visibility timeout aqui de proposito: o
# consumidor processa em centenas de ms, entao o padrao ja sobra. So vale
# revisar se o processamento (envio via SES) ficar lento o suficiente para a
# mensagem voltar a ficar visivel ANTES de terminar — meça no seu ambiente.
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.pedido_criado_dlq.arn
maxReceiveCount = 5 # cinco tentativas: falha transitoria se recupera antes disso
})
}
# Quem pode escrever na fila e o SEGREDO de nao virar chamada sincrona: e o
# outbox worker do monolito, e mais ninguem.
resource "aws_sqs_queue_policy" "somente_outbox_publica" {
queue_url = aws_sqs_queue.pedido_criado.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = aws_iam_role.task_monolito.arn }
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.pedido_criado.arn
}]
})
}
# ── Papel de tarefa do MONOLITO: so pode ENVIAR para a fila, nada alem disso. ──
data "aws_iam_policy_document" "monolito_publica_evento" {
statement {
effect = "Allow"
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.pedido_criado.arn] # restrito a UMA fila; sem "*"
}
}
# ── Papel de tarefa do servico de NOTIFICACOES: consome a fila e envia e-mail. ─
data "aws_iam_policy_document" "notificacoes_consome_e_envia" {
statement {
effect = "Allow"
actions = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
resources = [aws_sqs_queue.pedido_criado.arn] # restrito a mesma fila; sem "*"
}
statement {
effect = "Allow"
actions = ["ses:SendEmail", "ses:SendRawEmail"]
# Restrito a IDENTIDADE verificada, nao a conta inteira: o servico so pode
# enviar EM NOME do dominio da Cadencia, nunca se passar por outro remetente.
resources = [aws_ses_domain_identity.cadencia.arn]
}
}
resource "aws_ecs_service" "notificacoes" {
name = "${var.projeto}-notificacoes"
cluster = aws_ecs_cluster.principal.id
task_definition = aws_ecs_task_definition.notificacoes.arn
desired_count = 2
launch_type = "FARGATE"
# Reusa o mesmo par mínimo/máximo do L03 — o rollout sem janela nao e
# exclusivo do monolito; e propriedade de QUALQUER servico ECS bem configurado.
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
deployment_circuit_breaker { enable = true, rollback = true }
network_configuration {
subnets = aws_subnet.privada[*].id
security_groups = [aws_security_group.task_notificacoes.id]
assign_public_ip = false
}
# Sem load_balancer: este servico nao tem rota publica, so consome fila.
}
output "fila_pedido_criado" {
value = aws_sqs_queue.pedido_criado.url
description = "URL da fila; o monolito publica aqui depois do commit"
}
Por que o `sqs:SendMessage` não leva "*"
A ação aceita `Resource` específico, e a política restringe a UMA fila — diferente do `ecr:GetAuthorizationToken` do L03, que é operação de conta e não tem recurso para restringir. Aqui não há justificativa para largura: o monólito só publica num lugar, e a política diz exatamente isso.
Construir: o outbox no monólito restante
O outbox não é a fila — é a garantia de que publicar não é um passo separado de gravar o pedido. A tabela vive no MESMO banco e na MESMA transação do EF Core.
// OutboxMessage.cs — a linha que garante que publicar nao e um passo separado do commit
public class OutboxMessage
{
public Guid Id { get; set; }
public string TipoEvento { get; set; } = "PedidoCriado";
public string PayloadJson { get; set; } = default!;
public DateTimeOffset CriadoEm { get; set; }
public DateTimeOffset? PublicadoEm { get; set; } // null = ainda nao publicado
}
// PedidoService.cs — o ponto em que a garantia nasce
public async Task<Pedido> CriarPedidoAsync(NovoPedidoDto dto, AppDb db)
{
// A verificacao de estoque e o decremento acontecem AQUI, na mesma
// transacao do pedido — e e por isso que estoque NAO foi extraido.
var produto = await db.Produtos.FindAsync(dto.ProdutoId)
?? throw new ProdutoNaoEncontradoException(dto.ProdutoId);
if (produto.QuantidadeDisponivel < dto.Quantidade)
throw new EstoqueInsuficienteException(dto.ProdutoId);
produto.QuantidadeDisponivel -= dto.Quantidade;
var pedido = new Pedido
{
Id = Guid.NewGuid(),
ProdutoId = produto.Id,
// Preco e nome SNAPSHOTADOS para dentro do pedido — e o que torna
// Catalogo um candidato valido a extrair depois, sem quebrar pedido antigo.
PrecoNoMomento = produto.Preco,
NomeProdutoNoMomento = produto.Nome,
Quantidade = dto.Quantidade,
CriadoEm = DateTimeOffset.UtcNow,
};
db.Pedidos.Add(pedido);
// A linha de outbox entra no MESMO SaveChanges do pedido. Ou as duas
// sao commitadas juntas, ou nenhuma e — isto e o que fecha o dual-write
// problem: nunca existe um pedido salvo sem a intencao de publicar salva.
db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
TipoEvento = "PedidoCriado",
PayloadJson = JsonSerializer.Serialize(new PedidoCriadoEvento(
pedido.Id, pedido.ProdutoId, pedido.NomeProdutoNoMomento, pedido.CriadoEm)),
CriadoEm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync(); // UM commit; pedido e outbox nascem juntos ou nao nascem
return pedido;
}
// OutboxPublisherWorker.cs — o unico lugar que fala com a fila do lado do monolito
public class OutboxPublisherWorker(
IServiceScopeFactory scopeFactory, IAmazonSQS sqs, IConfiguration config,
ILogger<OutboxPublisherWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var filaUrl = config["Filas:PedidoCriado"]!;
while (!ct.IsCancellationRequested)
{
using var escopo = scopeFactory.CreateScope();
var db = escopo.ServiceProvider.GetRequiredService<AppDb>();
// So le o que ainda nao foi publicado. A leitura NAO precisa de
// lock especial: se dois workers rodassem em paralelo (nao roda,
// desired_count controla isto por task), publicar duas vezes seria
// absorvido pela idempotencia do CONSUMIDOR, nao daqui.
var pendentes = await db.OutboxMessages
.Where(m => m.PublicadoEm == null)
.OrderBy(m => m.CriadoEm)
.Take(50)
.ToListAsync(ct);
foreach (var msg in pendentes)
{
try
{
await sqs.SendMessageAsync(new SendMessageRequest
{
QueueUrl = filaUrl,
MessageBody = msg.PayloadJson,
MessageAttributes = new()
{
["TipoEvento"] = new MessageAttributeValue
{ DataType = "String", StringValue = msg.TipoEvento },
},
}, ct);
msg.PublicadoEm = DateTimeOffset.UtcNow;
}
catch (Exception ex)
{
// Nao marca como publicado. Fica pendente e tenta de novo
// no proximo ciclo — e a fila fora do ar NAO perde o pedido.
log.LogWarning(ex, "falha ao publicar outbox {Id}; tenta de novo no proximo ciclo", msg.Id);
}
}
await db.SaveChangesAsync(ct);
// O intervalo domina o atraso percebido na confirmacao — ver a
// formula na secao de arquitetura de producao.
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}
}
Pedido pago sem confirmação nenhuma, e nada acusa erro
Sem a tabela outbox — publicando direto na fila dentro do mesmo método, antes do commit — existe uma janela em que o SQS aceitou a mensagem e o commit do pedido ainda pode falhar depois (violação de constraint, timeout de conexão). O cliente recebe um e-mail de um pedido que não existe, ou o inverso: o commit teve sucesso e a chamada à fila falhou depois, e ninguém jamais é notificado. As duas falhas são silenciosas, e é isso que o outbox fecha.
Construir: o consumidor idempotente do serviço de notificações
Do outro lado da fila, a pergunta não é "recebi a mensagem?" — é "já agi sobre este pedido antes?". A diferença entre as duas é o que evita e-mail duplicado.
// NotificacaoConsumerWorker.cs — o lado que le a fila e decide se envia
public class NotificacaoConsumerWorker(
IAmazonSQS sqs, IAmazonSimpleEmailService ses, IServiceScopeFactory scopeFactory,
IConfiguration config, ILogger<NotificacaoConsumerWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var filaUrl = config["Filas:PedidoCriado"]!;
while (!ct.IsCancellationRequested)
{
var resposta = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = filaUrl,
MaxNumberOfMessages = 10,
WaitTimeSeconds = 20, // long polling: evita custo e latencia de sondar vazio
}, ct);
foreach (var msg in resposta.Messages)
{
var evento = JsonSerializer.Deserialize<PedidoCriadoEvento>(msg.Body)!;
using var escopo = scopeFactory.CreateScope();
var db = escopo.ServiceProvider.GetRequiredService<NotificacaoDb>();
// A CHECAGEM QUE IMPORTA. SQS padrao entrega PELO MENOS uma vez,
// entao esta mensagem pode ser a segunda entrega da mesma
// notificacao. E a linha abaixo, nao a fila, que impede o
// segundo e-mail.
var jaEnviado = await db.EnviosLog
.AnyAsync(e => e.PedidoId == evento.PedidoId, ct);
if (!jaEnviado)
{
await ses.SendEmailAsync(MontarEmailConfirmacao(evento), ct);
db.EnviosLog.Add(new EnvioLog
{
PedidoId = evento.PedidoId,
EnviadoEm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync(ct);
}
else
{
log.LogInformation("pedido {Id} ja tinha e-mail enviado; mensagem reentregue, ignorada",
evento.PedidoId);
}
// So apaga DEPOIS do envio (ou da checagem) ter sucesso. Se o
// processo cair entre enviar e apagar, a reentrega vai bater
// no log e nao reenviar — a mesma checagem cobre os dois casos.
await sqs.DeleteMessageAsync(filaUrl, msg.ReceiptHandle, ct);
}
}
}
}
Por que a checagem vem ANTES do efeito colateral, não depois
Enviar o e-mail e só então checar duplicidade não protege nada — o e-mail já saiu. A ordem certa é: checar, e só se a checagem disser "não enviado ainda", agir. É o mesmo princípio de "prontidão antes de aceitar tráfego" do L03, aplicado a mensagem em vez de requisição HTTP.
Implantar, e provar que o corte segura
Cinco provas. Nenhuma aceita "o e-mail chegou" como resultado — cada uma tem um número ou um estado esperado.
# provas.sh — cinco medicoes; nenhuma conclusao vem de "parece que funcionou"
PROJETO=ffv-lab; REGIAO=us-east-1
FILA=$(terraform output -raw fila_pedido_criado)
# ── Prova 1: mudar so notificacao nao re-testa pedido ────────────────────────
# Antes do corte: 2.400 asserções, ~40 min. Depois, so a suite do servico
# extraido roda para uma mudanca de template.
time dotnet test src/Notificacoes.Tests --filter Categoria=Notificacao
# Esperado: suite do servico de notificacoes sozinha, na ordem de segundos a
# poucos minutos — nao os ~40 min da suite inteira do monolito.
# ── Prova 2: pedido sobrevive ao servico de notificacoes fora do ar ──────────
aws ecs update-service --cluster $PROJETO --service ${PROJETO}-notificacoes --desired-count 0
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$API/pedidos" -d @exemplo-pedido.json
# Esperado: 201. O pedido existe no banco do monolito mesmo com notificacoes a zero.
aws sqs get-queue-attributes --queue-url "$FILA" \
--attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages'
# Esperado: >= 1 — a mensagem ficou retida esperando o consumidor voltar.
aws ecs update-service --cluster $PROJETO --service ${PROJETO}-notificacoes --desired-count 2
# ── Prova 3: reentrega da fila nao duplica e-mail ─────────────────────────────
# Forca reentrega: recebe sem apagar (simula falha do consumidor apos enviar).
MSG=$(aws sqs receive-message --queue-url "$FILA" --visibility-timeout 1 \
--query 'Messages[0].Body' --output text)
sleep 2 # o timeout de 1s expira; a mensagem volta a ficar visivel
aws ses get-send-statistics --query 'SendDataPoints[-1].DeliveryAttempts'
# Esperado: 1 envio real no SES, mesmo com 2 entregas da fila — a checagem por
# pedido_id no consumidor absorveu a segunda.
# ── Prova 4: o outbox nao perde o evento com a fila fora do ar ───────────────
aws sqs set-queue-attributes --queue-url "$FILA" --attributes Policy='{}' # nega temporariamente
curl -s -X POST "$API/pedidos" -d @exemplo-pedido.json
psql -c "select count(*) from outbox_messages where publicado_em is null;"
# Esperado: >= 1 linha pendente — o pedido existe, o evento so nao foi publicado ainda.
# restaure a policy da fila e rode de novo:
psql -c "select count(*) from outbox_messages where publicado_em is null;"
# Esperado: 0 — o worker publicou assim que a fila voltou a aceitar.
# ── Prova 5: mensagem envenenada vai para a DLQ apos 5 tentativas ────────────
aws sqs send-message --queue-url "$FILA" --message-body '{"pedidoId":"nao-existe"}'
# consumidor lanca excecao de desserializacao 5 vezes (maxReceiveCount)
sleep 60
DLQ=$(terraform output -raw fila_dlq 2>/dev/null || echo "${PROJETO}-pedido-criado-dlq")
aws sqs get-queue-attributes --queue-url "$DLQ" \
--attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages'
# Esperado: 1 — a mensagem saiu da fila principal e nao voltou em laço.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Mudança isolada não re-testa tudo | suíte filtrada do serviço de notificações | roda em segundos a poucos minutos, não ~40 min | se ainda roda a suíte inteira, o pipeline não foi realmente separado — só o runtime foi |
| 2 · Pedido sobrevive à queda da notificação | desired_count = 0 no serviço extraído + POST | 201 no pedido; mensagem retida na fila | se o POST falhar, existe chamada síncrona no caminho — é o monólito distribuído |
| 3 · Reentrega não duplica e-mail | força mensagem visível de novo via visibility timeout | 1 envio no SES apesar de 2 entregas | se houver 2 envios, o consumidor não está checando pedido_id antes de agir |
| 4 · Outbox não perde evento com fila fora do ar | nega a policy da fila temporariamente | linha permanece pendente e publica assim que a fila volta | se a linha some ou nunca publica, o worker não está tratando falha como "tentar de novo" |
| 5 · Mensagem envenenada vai para a DLQ | mensagem malformada, aguarda 5 tentativas | 1 mensagem na DLQ, fila principal drenada | se ficar reentregando para sempre, o `maxReceiveCount` não está configurado na redrive |
A prova que mais importa é a segunda, não a primeira
Um deploy mais rápido é conforto; um pedido que sobrevive ao serviço de notificações estar fora do ar é a garantia que este laboratório existe para entregar. Se a prova 2 falhar, as outras quatro não compensam — o corte não fez o que deveria.
Quebrar de propósito: três falhas e o diagnóstico
As três parecem o mesmo sintoma superficial — "notificação não está funcionando direito". O que as separa é a causa, e cada uma ensina uma parte diferente do corte.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Publicar antes do commit (sem outbox) | mova a chamada ao SQS para ANTES do `SaveChangesAsync` do pedido, e force uma falha de constraint no banco | e-mail de confirmação de um pedido que não existe no banco | log do consumidor mostra pedido_id sem correspondência no banco do monólito | outbox na mesma transação; publicar só depois de o commit ter sucesso |
| Consumidor sem checagem de idempotência | remova a consulta a `EnviosLog` antes de chamar o SES | cliente recebe dois e-mails do mesmo pedido, de forma esporádica | log do consumidor mostra dois `ReceiveMessage` para o mesmo `pedidoId` | checar e gravar `pedido_id` antes do efeito colateral, não depois |
| Estoque extraído com chamada síncrona, sem saga | simule dois `POST /pedidos` simultâneos para o último item em estoque | as duas requisições recebem 201; o estoque fica negativo | log de ambas as chamadas mostra "1 disponível" lido pelas duas ao mesmo tempo | manter estoque na mesma transação do pedido; ou implementar saga com reserva (L35) |
Segurança: o que a fronteira nova expõe
Separar em dois serviços cria uma superfície que não existia: agora há uma fila e uma identidade de e-mail que alguém com a credencial errada pode abusar.
| Risco | Probabilidade | Impacto | Prevenção | Detecção | Resposta |
|---|---|---|---|---|---|
| Papel do monólito consegue LER da fila, não só publicar | média | médio | política da fila restringe `sqs:SendMessage` a um principal; nenhuma outra ação | CloudTrail em ações de SQS fora do principal esperado | revogar e restringir a política; auditar o que foi lido indevidamente |
| Serviço de notificações envia e-mail em nome de domínio não verificado | baixa | alto | `ses:SendEmail` restrito ao ARN da identidade verificada, não à conta | métricas de rejeição do SES por identidade não reconhecida | revogar a permissão; verificar a identidade correta antes de reautorizar |
| Mensagem malformada injetada diretamente na fila | baixa | médio | validação de schema no consumidor antes de desserializar; DLQ como rede | taxa de mensagens na DLQ acima do normal | inspecionar a DLQ; a ausência de efeito colateral (não enviou e-mail) já limitou o dano |
| Credencial do banco de notificações vaza pela variável de ambiente | média | alto | segredo gerenciado pelo Secrets Manager, lido em tempo de execução — nunca em `environment` | busca por padrão de credencial no grupo de logs | rotacionar via Secrets Manager; a rotação não exige tocar no código |
| Papel de execução com permissão além do necessário | média | médio | papel de execução só lê ECR e escreve log; papel de tarefa é o único com SQS e SES | IAM Access Analyzer sobre uso real | derivar a política do uso medido — é o L41 |
Papel de tarefa comprometido pode enviar e-mail em nome da Cadência
Se a política de `ses:SendEmail` não estiver restrita à identidade verificada do domínio, uma task comprometida pode enviar e-mail arbitrário usando a reputação de envio da conta — o que derruba a reputação do domínio inteiro nos provedores de e-mail, não só o dessa task. É exposição de segurança com efeito que sai da AWS: afeta a entregabilidade de TODO e-mail legítimo da empresa depois disso.
O time da Cadência quer extrair o serviço de Estoque do monólito de pedidos para escalar a leitura de disponibilidade separadamente. Qual é o problema real com essa extração agora?
Observabilidade: as perguntas que o painel do corte tem de responder
- O pedido está sendo criado sem depender do serviço de notificações estar de pé?
- Quantas linhas de outbox estão pendentes há mais tempo do que o esperado?
- A fila está acumulando mais rápido do que o consumidor processa?
- Quantas mensagens foram parar na DLQ, e desde quando?
- O SES está rejeitando envio por reputação ou por limite de taxa?
| Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|
| linhas de `outbox_messages` com `publicado_em IS NULL` há > 5 min | worker parado ou fila recusando publicação | > 0 por mais de 5 min |
| `ApproximateNumberOfMessages` da fila principal | consumidor mais lento que a chegada de pedidos | crescendo por > 10 min sem estabilizar |
| `ApproximateNumberOfMessagesVisible` da DLQ | mensagens envenenadas ou contrato quebrado | > 0 |
| taxa de rejeição do SES (`Reputation.BounceRate`) | lista suja ou identidade com problema | > 5% |
| `RunningTaskCount` do serviço de notificações | capacidade de consumo abaixo do desenhado | < desired_count por > 2 min |
A métrica que engana logo depois do corte
Latência de "pedido para e-mail entregue" NÃO é a mesma coisa que latência de resposta da API — a primeira inclui o intervalo do worker de outbox e a entrega da fila, por desenho. Alarmar sobre ela com o limiar de uma API síncrona gera ruído constante para um atraso que é esperado e aceito pelo requisito.
Escala: 10, 10 mil, 1 milhão de pedidos
| Volume | O que acontece | O que passa a doer | O que fazer |
|---|---|---|---|
| 10 pedidos/dia | outbox e fila praticamente ociosos | nada; é o cenário do laboratório | nada |
| 10 mil pedidos/dia | worker de outbox varre lotes de 50 várias vezes por minuto | o intervalo de 2 s do worker passa a ser a parcela dominante do atraso | reduzir o intervalo ou aumentar o tamanho do lote — os dois custam mais consulta ao banco |
| 1 milhão de pedidos/dia | a fila escala de forma transparente; o consumidor não | poucas tasks de notificação viram o gargalo, e a DLQ cresce por capacidade, não por bug | escalar `desired_count` do serviço de notificações por profundidade da fila (CloudWatch) |
| Falha de AZ | a task de notificações numa AZ cai; o outbox continua acumulando no monólito | nada se perde — o outbox é o buffer durável enquanto a capacidade de consumo se recupera | confirmar que o serviço de notificações também está em 2 AZs, como o do L03 |
| Pico de campanha (10× por uma hora) | a fila absorve o pico sem que o consumidor precise acompanhar em tempo real | atraso na confirmação cresce temporariamente — é o requisito de "minutos" sendo usado | nenhuma ação se o atraso ficar dentro do combinado com o negócio |
O ganho que a extração já paga sozinho em pico
No desenho do monólito, um pico de pedidos competia por CPU com o envio de e-mail no mesmo processo. Com a fila no meio, o pico de criação de pedido não disputa recurso nenhum com o envio — a fila é o amortecedor, e ele já existe antes de qualquer ajuste de escala.
Custo: o que este corte acrescenta à fatura
A peça nova mais visível na fatura é o segundo RDS. O resto — fila, e-mail, alarme — fica perto de gratuito no volume da Cadência.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | 10 pedidos/dia | uma instância RDS pequena ligada 24h; fila e SES desprezíveis | linha fixa, baixa | considerar Aurora Serverless v2 se o padrão de uso for muito irregular (L15) |
| Produção pequena | 230 pedidos/dia (Cadência hoje) | RDS pequeno + volume baixo de mensagens e e-mails | previsível | nenhuma; é o cenário deste laboratório |
| Alta escala | 1 milhão de pedidos/dia | RDS maior para o serviço de notificações; volume de mensagens e e-mails vira linha visível | cresce com o número de pedidos, não com o número de serviços | lote de envio de e-mail onde o produto permitir; ver L95 para padrão de lote em outro contexto |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| RDS do serviço extraído | hora ligada + armazenamento, igual a qualquer RDS | é uma instância a mais na fatura, mesmo pequena — o database-per-service tem esse custo fixo |
| SQS | por requisição (envio, recebimento, exclusão) | long polling reduz chamadas de recebimento vazias, que contam igual a uma chamada com mensagem |
| SES | por e-mail enviado | monitore taxa de rejeição — endereço inválido cobra o envio igual e ainda machuca a reputação |
| CloudWatch | por métrica e alarme | valor pequeno e fixo; não é onde se economiza |
O custo que este corte evita, e que não aparece na fatura da AWS
Antes do corte, uma mudança de texto de e-mail custava 40 minutos de pipeline e o tempo de um dev esperando. Depois, a mesma mudança roda a suíte própria em minutos. Multiplicado pela frequência de mudanças de UX de e-mail — que não é rara — o tempo de time economizado supera o custo do RDS extra rapidamente.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | pipeline e teste separados por serviço extraído | contrato de evento em JSON cru, sem schema versionado | schema registry via EventBridge (L24) | média |
| Segurança | IAM restrito por ação e recurso; segredo por Secrets Manager | identidade SES verificada mas sem DKIM/SPF documentado no laboratório | configurar autenticação de domínio completa para o e-mail | média |
| Confiabilidade | pedido sobrevive à queda da notificação; DLQ evita perda silenciosa | estoque ainda decide em transação local — não sobrevive a uma extração futura sem saga | saga com compensação quando estoque for extraído (L35) | alta |
| Eficiência de performance | fila absorve pico sem competir por CPU com criação de pedido | intervalo do worker de outbox é fixo, não adaptativo à carga | ajustar intervalo dinamicamente pela profundidade do outbox pendente | baixa |
| Otimização de custos | um RDS pequeno a mais, dimensionado ao volume real | dois RDS parados fora de horário de teste continuam cobrando | agendar desligamento em ambiente de não produção (fora do escopo aqui) | baixa |
| Sustentabilidade | processamento assíncrono em lote reduz picos de CPU ociosa esperando | duas instâncias RDS pequenas podem ser menos eficientes que uma maior compartilhada | revisar consolidação quando o número de serviços extraídos crescer | baixa |
Evolução em níveis: quando o próximo corte se justifica
A terceira arquitetura não é um desenho: é a resposta a QUANDO cortar de novo. Cada nível resolve um risco e compra outro — e a ordem não é arbitrária: cada um depende do anterior ter resolvido o que ele resolve.
Pedido, catálogo e notificação num processo, um banco, uma suíte. Onde a Cadência estava, e continua legítimo até o time crescer.Notificações extraída, com outbox, fila e banco próprios. Pedido e catálogo continuam juntos, porque nenhum dos dois quebra o teste sozinho.Quando o time crescer o suficiente para operar um terceiro serviço, catálogo sai também — já passou no teste desde este laboratório.Com vários serviços, nome lógico substitui IP fixo, e mTLS entra entre eles.Estoque finalmente extraído, com saga e compensação; isolamento multi-tenant onde fizer sentido.Dezenas de serviços, EventBridge como espinha dorsal (L24) com schema versionado, e o histórico de eventos de pedido virando dado para recomendação de produto e detecção de padrão de fraude.A ordem não é negociável, e o motivo é concreto
Extrair estoque (nível 5) antes de o time ter operado UM serviço extraído (nível 2) é pular a parte em que se aprende a operar deploy, teste e observabilidade separados — e fazer isso pela primeira vez justo no candidato que reprova o teste de fronteira é a combinação mais cara possível de erro.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não decide a fronteira, e forçá-la seria o antipadrão que a própria série critica. O teste "este serviço termina a própria transação sozinho?" é lógica determinística sobre onde uma escrita acontece e o que ela precisa garantir — não é um padrão estatístico que um modelo aprenderia melhor que a pergunta direta.
Há um uso adjacente que parece atraente e não se justifica ainda: treinar um modelo sobre o histórico de commits para sugerir automaticamente onde cortar o próximo serviço, olhando quais tabelas mudam juntas com mais frequência ("acoplamento de mudança"). A técnica existe na literatura de engenharia de software, mas exige um volume de histórico que a Cadência não tem: poucos meses de commits de um monólito não são amostra suficiente para um modelo confiável, e a decisão errada aqui — a fronteira do sistema — é cara demais para delegar a um sinal fraco.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | sugerir onde cortar o próximo serviço, olhando acoplamento de mudança no histórico de commits |
| Por que uma regra não bastaria? | o teste de fronteira transacional já é a regra, e ela é suficiente — os três candidatos deste módulo foram decididos só com ela |
| De onde viriam os dados? | histórico de commits, tabelas tocadas por transação, frequência de deploy por módulo |
| Qual o risco? | poucos meses de histórico é amostra pequena demais; um sinal fraco sugerindo a fronteira errada custa uma extração inteira refeita |
| Por que não agora? | a Cadência tem um corte no total. Não existe "padrão de acoplamento" a aprender com uma amostra de um |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "sugerir a próxima fronteira de microsserviço" troca uma pergunta que tem resposta verificável — este serviço termina sozinho a própria transação? — por uma sugestão estatística sobre padrão de commit, que não carrega garantia nenhuma sobre atomicidade. Fronteira de serviço é decisão de design com consequência de dado incorreto; não é um problema de previsão.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| Dividir por camada técnica ("serviço de banco", "serviço de UI") | parece organização lógica — é como pastas de código já costumam ser separadas | toda operação de negócio ainda cruza várias camadas técnicas; nada foi realmente desacoplado, só reorganizado | todo endpoint chama três "serviços" técnicos em sequência síncrona; nenhum deploy é de fato independente | dividir por capacidade de negócio com fronteira transacional própria | nunca como fronteira de SERVIÇO; pode ser organização de pastas dentro de um serviço |
| Extrair e continuar chamando por HTTP síncrono no caminho crítico | parece mais simples que aprender fila/evento; o código migra quase igual — troca chamada de método por chamada HTTP | é o "monólito distribuído" nomeado: parece microsserviço porque tem deploy separado, e continua acoplado porque a indisponibilidade de um derruba o outro | timeout em cascata; um serviço fora do ar derruba outro que não deveria depender dele | assíncrono via fila/evento quando a consistência permitir | leitura síncrona que a UI precisa AGORA para decidir algo — não escrita cross-service |
| Extrair serviço que precisa de atomicidade com o restante, sem saga | parece o próximo passo natural depois de o primeiro corte ter funcionado | quebra a garantia que existia: "impossível vender 2× a última unidade" vira "possível, ocasionalmente" | overselling sob concorrência, raro e difícil de reproduzir em teste manual | manter no mesmo banco/transação até ter saga com compensação (L35) | nunca sem saga ou 2PC — e 2PC tem custo próprio de disponibilidade |
| Publicar evento antes do commit da transação, sem outbox | parece mais simples: publica e depois salva, numa linha só de código a menos | é o dual-write problem — se o commit falhar depois, notificou um pedido que não existe; se a publicação falhar depois de commitar, perde o evento em silêncio | e-mail de confirmação de pedido que não existe no banco, ou pedido sem confirmação nenhuma | outbox na mesma transação, worker que publica depois | quando perder ou duplicar o evento tem custo zero — ex.: log analítico não crítico |
| Consumidor não idempotente | no dia 1 a fila entrega uma vez cada mensagem, e parece óbvio que sempre será assim | SQS Standard entrega PELO MENOS uma vez — reentrega por timeout de visibilidade e falha de rede é normal, não exceção | cliente recebe dois e-mails de confirmação do mesmo pedido, esporadicamente | checar chave de idempotência (pedido_id) antes de agir | nunca em efeito colateral externo (e-mail, cobrança); tolerável em leitura pura |
| Dois bancos lendo a mesma tabela ("banco compartilhado" disfarçado de per-service) | parece atalho para não duplicar dado — os dois serviços leem a mesma tabela `produtos` | qualquer mudança de schema num serviço quebra o outro silenciosamente; não há fronteira real, só a ilusão dela | migration em um serviço derruba o outro em produção, sem nenhuma mudança de código nele | cada serviço só acessa o próprio banco; dado necessário é copiado (snapshot) ou pedido por API/evento | nunca como arquitetura permanente; tolerável como passo de transição, com prazo definido |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Pedido falha quando notificações está fora do ar | chamada síncrona no lugar de fila — regressão para monólito distribuído | ver se a criação do pedido depende de resposta do serviço de notificações | trace/log do endpoint de criação de pedido | publicar no outbox e retornar; nunca aguardar a notificação no caminho crítico |
| Cliente recebe dois e-mails do mesmo pedido | consumidor não idempotente combinado com reentrega da fila | contar `ApproximateReceiveCount` da mensagem | log do consumidor + tabela de log de envio | checar `pedido_id` antes de acionar o SES |
| Evento nunca chega à fila | worker de outbox parado, ou publicou antes do commit e falhou em silêncio | contar linhas não publicadas na tabela outbox | tabela `outbox_messages`, log do worker | reiniciar/corrigir o worker; garantir que só publica linha já commitada |
| Mensagens acumulando na fila principal sem consumo | consumidor parado ou tasks insuficientes para o volume | observar `ApproximateNumberOfMessages` crescendo sem `RunningTaskCount` acompanhar | métrica da fila e do serviço ECS de notificações | escalar tasks ou investigar exceção que trava o laço de consumo |
| Mensagens indo para a DLQ constantemente | exceção determinística no processamento — ex.: contrato do evento mudou | ler uma amostra da DLQ | conteúdo da mensagem na DLQ e o log de erro do consumidor | corrigir o consumidor ou o contrato do evento; reprocessar a DLQ manualmente depois |
| Overselling esporádico do mesmo produto | estoque foi extraído (ou tratado) de forma não atômica com o pedido | reproduzir concorrência com duas requisições simultâneas para o mesmo item | onde o decremento de estoque acontece em relação ao commit do pedido | manter estoque na mesma transação, ou implementar saga com reserva |
| Deploy do serviço de notificações continua re-testando pedido | repositório/pipeline não foi realmente separado, só o runtime | verificar se existe um pipeline único ainda rodando tudo | configuração de CI/CD | pipeline e suíte de testes próprios por serviço, não só serviço ECS separado em runtime |
A pergunta que resolve metade destes casos
Antes de mexer em código, pergunte: a falha aconteceu ANTES ou DEPOIS do commit do pedido? Antes, o problema é síncrono demais — algo está bloqueando o caminho crítico. Depois, o problema é do lado assíncrono — outbox, fila ou consumidor — e o pedido, esse, está seguro.
Limpeza: o que o destroy não leva
Este corte acrescenta um RDS inteiro e uma fila com potencial de reter mensagem — os dois merecem atenção antes de destruir.
# 1. Antes de destruir, confira se ha mensagem retida — apagar a fila com
# mensagem dentro PERDE a confirmacao de pedidos que ainda nao foram notificados.
aws sqs get-queue-attributes --queue-url "$(terraform output -raw fila_pedido_criado)" \
--attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible
# Se houver mensagem, espere o consumidor drenar ou copie o conteudo antes de seguir.
# 2. Confira tambem a DLQ — mensagem la e exatamente o que faltou investigar.
aws sqs get-queue-attributes --queue-url "$(terraform output -raw fila_dlq)" \
--attribute-names ApproximateNumberOfMessages
# 3. Derrube o que o Terraform administra.
terraform destroy -auto-approve
# 4. SNAPSHOT FINAL DO RDS DE NOTIFICACOES: com skip_final_snapshot = false, o
# destroy cria um snapshot que SOBREVIVE e cobra armazenamento indefinidamente.
aws rds describe-db-snapshots \
--db-instance-identifier ffv-lab-notificacoes \
--query "DBSnapshots[].DBSnapshotIdentifier" --output table
# apague o que nao precisar manter:
# aws rds delete-db-snapshot --db-snapshot-identifier <id>
# 5. IDENTIDADE VERIFICADA NO SES: nao cobra parada, mas fica registrada na conta.
aws ses list-identities --identity-type Domain
# 6. Prova final: nada com o nome do projeto de pe.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab \
--query "ResourceTagMappingList[].ResourceARN" --output tableApagar a fila sem checar mensagem pendente perde confirmação de pedido real
Uma mensagem na fila principal ou na DLQ representa um pedido que ainda não teve a confirmação processada. Destruir a fila sem antes drenar ou registrar esse conteúdo é perda IRREVERSÍVEL — diferente de um recurso que só cobra parado, aqui o dado em trânsito desaparece com o `terraform destroy`, sem aviso e sem chance de recuperação.
| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Serviço ECS de notificações | sim | não | a definição de task permanece registrada, sem custo |
| RDS de notificações | sim, com snapshot final | sim, o snapshot | `skip_final_snapshot = false` cria um snapshot que sobrevive ao destroy |
| Fila SQS e DLQ | sim | não, mas a MENSAGEM dentro se perde | não há custo de fila parada, mas o conteúdo não sobrevive ao destroy |
| Identidade verificada no SES | não gerenciada pelo destroy do serviço | não, mas fica registrada | verificação de domínio não expira automaticamente |
| Grupo de logs de notificações | depende de `skip_destroy` | sim, retenção | tem ciclo próprio; sobrevive ao serviço que o alimentava |
| O que veio do L01/L03 | não faz parte deste destroy | sim, por hora | ALB, RDS do monólito, endpoint de VPC, NAT Gateway — rode a limpeza deles separadamente |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Qualquer mudança re-testa e reimplanta tudo | repositório e pipeline próprios do serviço extraído | a suíte separada só cobre o que realmente pode ter quebrado |
| Pedido não pode depender do e-mail para existir | fila entre os dois serviços | desacopla o relógio da criação do pedido do relógio do envio de e-mail |
| Evento pode se perder entre gravar e publicar | tabela outbox na mesma transação | garante que publicar não é um passo que pode falhar sozinho, separado do commit |
| Fila entrega mais de uma vez | checagem de idempotência por pedido_id | é a checagem, não a fila, que impede efeito colateral duplicado |
| Estoque não pode vender 2× a última unidade | estoque continua na transação do pedido | reprovou o teste de fronteira transacional — extrair agora trocaria garantia por bug raro |
| Um serviço extraído não pode derrubar o outro | comunicação assíncrona, sem HTTP síncrono no caminho crítico | é o que separa extração real de monólito distribuído |
| Mensagem que nunca vai processar não pode travar a fila para sempre | DLQ com `maxReceiveCount` | isola o problema numa mensagem sem impedir as boas de seguir |
| Dado de negócio do serviço extraído precisa ficar isolado | RDS próprio, não schema compartilhado | um ALTER TABLE de um lado não pode travar o outro |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Notificação fora do ar derrubar pedido | fila entre os dois serviços | chamada síncrona reintroduzida por engano em outro caminho |
| Evento perdido entre gravar e publicar | outbox na mesma transação do pedido | contrato de evento que muda de formato sem versionamento — é o L24 |
| E-mail duplicado por reentrega | checagem de idempotência por pedido_id | duplicidade se a checagem for removida ou tiver bug |
| Mensagem envenenada travando a fila | DLQ com `maxReceiveCount` | a causa raiz da mensagem malformada — é preciso investigar a DLQ, não só ela existir |
| Overselling | estoque na transação do pedido, não extraído | concorrência se estoque for extraído sem saga no futuro |
- Cliente envia POST /pedidos.
- A API confere estoque e grava o pedido — na mesma transação que decrementa a quantidade.
- A mesma transação grava uma linha de outbox com o evento PedidoCriado.
- A API responde 201 imediatamente; o e-mail não está no caminho crítico.
- O worker de outbox publica a linha pendente na fila SQS, e só então marca como publicada.
- A fila entrega ao consumidor de notificações — pelo menos uma vez, não exatamente uma.
- O consumidor checa se o pedido_id já está no log de envio antes de agir.
- Se não estava, envia o e-mail via SES e grava o log; se já estava, ignora e apaga a mensagem.
- Se falhar 5 vezes, a mensagem vai para a DLQ e o alarme de profundidade dispara.
- O pedido, do início ao fim, nunca esperou nenhum desses passos para existir.
Desafio — sem roteiro
O requisito
Extraia um SEGUNDO contexto delimitado do que sobrou do monólito, respeitando a mesma regra de fronteira transacional que o primeiro extraído já seguiu — nenhuma transação de banco pode atravessar dois serviços.
Critério de aceite — executável, não "verifique se funciona"
Uma operação que antes era uma única transação local agora é uma sequência de passos entre dois serviços, e o teste prova que uma falha no PASSO 2 não deixa o passo 1 "pela metade" sem que o sistema saiba — seja por compensação, seja por reprocessamento seguro.
- Dica 1: A primeira pergunta não é "que código eu movo", é "que dado hoje mora numa tabela só e amanhã precisa morar em duas" — aí a fronteira transacional aparece sozinha.
- Dica 2: Se a operação precisa ser tudo-ou-nada entre os dois serviços novos, considere o padrão Saga (com compensação) em vez de tentar simular uma transação distribuída de baixo nível.
- Dica 3: Teste isso derrubando o segundo serviço NO MEIO de uma operação (ex: matando o processo) e observando o estado do primeiro — se ele ficar "pendente" de forma visível e recuperável, o desenho está certo.
Lembrete de limpeza
Todo recurso que este desafio criar entra no mesmo `terraform destroy` do laboratório — nada fica para trás cobrando sozinho.
Perguntas frequentes
❓ Como saber se um serviço está pronto para ser extraído de um monólito?
❓ Dividir um monólito por camada técnica é uma boa fronteira de serviço?
❓ O que é um monólito distribuído (distributed monolith)?
❓ Por que extrair o estoque de um monólito de pedidos é arriscado sem saga?
❓ O que é o dual-write problem e como o padrão outbox resolve isso?
❓ SQS Standard garante que cada mensagem é entregue só uma vez?
❓ Cada microsserviço precisa mesmo de seu próprio banco de dados?
❓ Como decidir entre chamada síncrona (HTTP) e assíncrona (fila) entre dois serviços?
Fixando
Um time implementa "salvar o pedido, depois enviar o evento para a fila" sem tabela de outbox. O processo cai exatamente entre o commit do pedido e a chamada ao SQS. O que acontece?
Um time extrai o serviço de Notificações para um ECS, repositório e pipeline próprios. Mas o endpoint de criação de pedido continua chamando o serviço de notificações por HTTP e esperando 200 antes de responder ao cliente. Esse é um bom corte de microsserviço?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L03 no ar (ECR imutável, rolling update), EF Core básico, noção de transação de banco |
| Conhecimentos adquiridos | o teste de fronteira transacional aplicado a candidatos reais; o antipadrão "monólito distribuído" nomeado e reconhecível; o padrão outbox contra o dual-write problem; por que consumidor de fila padrão precisa ser idempotente; database per service e o trade-off dele |
| Limitação que fica | o contrato do evento é JSON cru sem schema versionado; catálogo passou no teste mas não foi extraído; estoque continua exigindo saga antes de poder sair do monólito |
| Próximo exemplo recomendado | L32 — síncrono ou assíncrono entre serviços, com disponibilidade composta calculada. Reusa os dois serviços deste laboratório para comparar as duas formas na mesma integração |
| Também habilitado por este módulo | L38 (multi-tenant: linha, schema ou conta) usa o mesmo raciocínio de fronteira para decidir isolamento entre clientes; L35 (saga) é o caminho para finalmente extrair estoque |
| Data da última validação técnica | 7 de agosto de 2026 |
Fontes consultadas: microservices.io (Chris Richardson) — os padrões Database per service, Saga e Transactional outbox, que sustentam a arquitetura de produção deste módulo; Sam Newman, Building Microservices — a definição de fronteira por capacidade de negócio e o antipadrão do monólito distribuído; e o guia do desenvolvedor do Amazon SQS, para o comportamento de entrega pelo menos uma vez e o funcionamento de redrive policy que sustenta a necessidade de consumidor idempotente. Os padrões de arquitetura vêm de literatura consolidada de engenharia de software, não de um único produto — a AWS entra como a implementação (SQS, SES, RDS), não como a origem da decisão de fronteira.
O que não foi verificado, e você deve conferir na sua conta
O número de tentativas antes da DLQ (5) e o intervalo do worker de outbox (2 s) são os escolhidos para o volume de exemplo da Cadência, não uma recomendação universal — derive os dois do seu volume real e do custo de uma notificação atrasada no seu produto. A hipótese de que "minutos de atraso na confirmação é aceitável" também é do cenário de exemplo: em um produto onde a confirmação imediata é parte da experiência (retirada em loja em poucos minutos, por exemplo), essa hipótese não vale e muda a decisão entre síncrono e assíncrono.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…