Escolhemos DynamoDB no papel. Voltamos atrás antes da primeira linha de código.

A decisão estava fechada, documentada e replicada em três serviços: o catálogo sairia do monolito Laravel para um microsserviço serverless com DynamoDB multi-tabela. Tinha justificativa, tinha modelo de acesso mapeado, tinha os GSIs desenhados.
Reabrimos tudo na fase de modelagem. Não porque alguém mudou de ideia — porque o próprio desenho tinha deixado uma pista, e a gente demorou pra ler.
Este post é sobre essa pista.
O contexto
Uma plataforma de delivery. Catálogo — itens, categorias, preços, estoque, campanhas — vivendo dentro de um monolito PHP/Laravel que a empresa comprou pronto: sem testes, em produção, expondo API pra outros sistemas da casa. O plano era extrair o domínio via strangler fig, com uma camada de compatibilidade que reproduz os contratos legados byte a byte para os apps não precisarem de release.
Serverless na frente: API Gateway → Lambda. E, como banco, DynamoDB. Serverless com Dynamo é quase reflexo condicionado — pay-per-request, sem gerenciar conexão, escala infinita de escrita. Encaixa na narrativa.
O problema é que a narrativa não olhou pro perfil de carga.
A pista
No desenho original de DynamoDB, tinha uma peça esquisita: uma réplica MySQL ao lado.
Ela existia porque as telas administrativas legadas precisavam de consulta relacional — listagem filtrada, agregação de relatório, busca. Coisa que o Dynamo não faz. Então o plano era: escreve no Dynamo, propaga pro MySQL via Streams, e o que for relacional consulta lá.
Isso não é um detalhe de implementação. É a arquitetura confessando.
Quando o seu desenho precisa de um segundo banco relacional pendurado no primeiro só pra responder as perguntas que o primeiro não responde, a pergunta que você deveria estar fazendo não é "como sincronizo os dois?". É "por que o banco principal não é o relacional?". A gente estava resolvendo, com dual-write e complexidade de propagação, um problema que a gente mesmo tinha criado ao escolher o store errado.
O perfil de carga, olhado de verdade
Quando paramos pra medir em vez de assumir, o desenho ficou óbvio:
Leitura altíssima e constante. O app da loja faz polling a cada 10 segundos. O app do cliente faz track de pedido a cada 10–15 segundos. Tem fan-out de push. A vitrine é lida o tempo todo, por todo mundo.
Escrita modesta. É a taxa real de pedidos e alterações de cardápio. Ninguém cadastra item mil vezes por segundo. Loja mexe no preço algumas vezes por dia.
Muita consulta de formato relacional. Listagem filtrada, agregação de relatório, full-text search.
Agora ponha isso lado a lado com o que o DynamoDB é bom: escala de escrita com acesso chave-valor simples e padrão de acesso conhecido de antemão.
Não tem interseção. O forte dele não ajuda em nada aqui — e as fraquezas aparecem todas:
- GSI/LSI demais. Cada índice secundário é uma cópia da tabela. Você paga escrita e storage em cada um. Um catálogo com muitos padrões de filtro vira uma coleção de cópias que se pagam mutuamente.
- Hot partition na vitrine. Zona geográfica é um péssimo particionamento quando uma zona concentra o tráfego. E concentra — sempre.
- Teto de 10 GB por partição. Chega quando chegar, mas chega.
- Zero consulta ad-hoc. Toda pergunta nova exige um índice novo, ou um scan, ou o MySQL do lado.
E o ponto que fecha o caso: leitura escala com réplica de leitura. Não com a escala de escrita do Dynamo. A gente estava comprando a capacidade errada.
O que entrou no lugar
Aurora PostgreSQL, com:
- Réplicas de leitura — que é onde a carga real está
- I/O-Optimized — dado o volume de leitura, a conta fecha melhor que o modelo padrão
- RDS Proxy — pooling de conexão, porque Lambda + Postgres sem proxy é uma forma criativa de esgotar max_connections
- Redis (ElastiCache) nos caminhos quentes de polling — o polling de 10s não precisa tocar o banco toda vez
O que a mudança simplificou:
- Um store em vez de dois. A réplica MySQL some. O dual-write some junto.
- Migração mais fácil. A origem já é MySQL. Relacional → relacional.
- Busca nativa. Full-text do Postgres resolve, e o OpenSearch que estava no plano saiu.
- Relatório sem ginástica. É SQL. Era isso o tempo todo.
- Metade das "correções" de modelo evaporou. Um monte de coisa que eu tinha resolvido no desenho Dynamo — desnormalização defensiva, índice pra cada pergunta — em Postgres é só normalização e índice comum. Não era complexidade do domínio. Era complexidade do banco.
O que a mudança custou — porque não existe almoço grátis e o post que finge que existe está mentindo:
VPC e RDS Proxy entram na conta. Lambda em VPC, VPC endpoints pra evitar NAT, mais peças de infra pra manter.
O mecanismo de evento mudou. O plano usava DynamoDB Streams → EventBridge, que vem de graça. Em relacional, virou transactional outbox: você grava o evento na mesma transação da mudança de estado, e um poller (ou CDC) publica. Mais código, mais uma tabela, mais um componente rodando. Em troca, você ganha a garantia que o dual-write não dava.
Conexão passa a ser um recurso finito. No Dynamo não era. Agora é.
Na soma, positivo. Mas alguém teve que somar.
E não, não virei anti-DynamoDB
Outro serviço da mesma plataforma — o de controle de acesso — continuou no DynamoDB. Ledger, acesso chave-valor simples, padrão conhecido, escrita como carga dominante. É exatamente o caso de uso dele. Não migrou junto e não deve migrar.
Essa é a parte que quase nunca sobrevive num post de "X vs Y": a resposta certa foi os dois, em serviços diferentes, pelos motivos certos. A conclusão não é "DynamoDB é ruim". É que a escolha de banco é função do perfil de carga, não da stack — e "estamos em serverless" não é perfil de carga.
O que eu levo disso
Decisão fechada não é decisão certa. Aquela estava documentada, replicada em três serviços e tinha o peso social de já ter sido acordada. Reabrir custou uma semana de retrabalho de modelagem e um changelog transversal chato de escrever. Descobrir em produção — com dado migrado, apps apontando, GSI crescendo e a conta chegando — teria custado um trimestre.
A arquitetura te avisa antes. Ela avisou. A réplica MySQL pendurada no Dynamo estava no documento desde o começo, escrita com todas as letras. Ninguém leu como sintoma. Passou como detalhe de implementação.
O sinal que eu procuro hoje: quando o desenho precisa de uma peça auxiliar pra compensar uma limitação da peça principal, a peça principal provavelmente está errada. Cache que existe porque a query é lenta demais pra rodar. Fila que existe porque a chamada síncrona não aguenta. Segundo banco que existe porque o primeiro não responde a pergunta.
Às vezes a peça auxiliar é a resposta certa mesmo. Mas ela merece a pergunta.
E a pergunta é mais barata na modelagem.