03 - Modelos de Leitura e Escrita distintos
video: https://youtu.be/... no frontmatter da nota.Modelos de Leitura e Escrita Distintos
Quando os lados de comando e consulta se separam, os modelos de dados também precisam se separar, porque servem a fins opostos. O modelo de escrita existe para proteger invariantes: é normalizado, centrado no domínio e tolera joins porque só o lado de comando o consulta. Os modelos de leitura existem para responder perguntas rápido: são desnormalizados, moldados por tela e por relatório, e duplicam dados sem culpa porque nada ali decide comportamento. As projeções são a mecânica que mantém essa família de visões atualizada, transformando eventos de escrita em linhas, documentos e índices de leitura. Em produção, o desenho desses modelos define a experiência do usuário, o custo da infraestrutura e a facilidade de evoluir consultas, e é sobre como projetá-los, materializá-los e reconstruí-los que este arquivo trata.
Por que os dois lados pedem modelos diferentes
O modelo de escrita responde à pergunta: como garantir que uma regra não seja violada? A resposta pede estrutura próxima ao domínio, com agregados coerentes, chaves estáveis e o mínimo de redundância, para que cada mudança toque um único lugar. O modelo de leitura responde à pergunta: como devolver esta resposta em milissegundos? A resposta pede exatamente o oposto, dados pré-combinados na forma em que serão exibidos, sem joins no caminho quente e com índices alinhados aos filtros reais.
Tentar atender às duas perguntas com um único modelo produz os dois piores resultados ao mesmo tempo. A escrita ganha índices que só servem a relatório, transações mais longas e deadlock novo a cada tela nova; a leitura ganha queries com seis joins que degradam em cada pico. A separação existe para que cada pergunta tenha a estrutura que merece, e a duplicação de dados entre os modelos é um custo aceito conscientemente, porque a coerência é garantida pela sincronização, e não pela normalização.
A fronteira também muda o ciclo de vida dos modelos. O modelo de escrita muda devagar, porque toda alteração toca regras, agregados e migração de dados históricos. Os modelos de leitura mudam rápido e morrem com frequência, porque telas e relatórios mudam com o negócio. Reconhecer essa diferença de ritmo é libertador: a equipe pode adicionar, ajustar e aposentar visões sem tocar no coração do sistema, e é justamente essa plasticidade que o acoplamento em modelo único impede.
Projections: transformar eventos em visões
A projeção é um consumidor de eventos que mantém uma visão de leitura. Ela assina o fluxo, mantém um checkpoint da posição global processada e aplica uma função de transformação: cada evento entra, linhas do read model mudam. A visão é, em última instância, um replay materializado e incremental do log, e herda dele a capacidade de ser reconstruída do zero a qualquer momento.
O desenho de uma projeção começa pela pergunta que ela serve, e não pelos eventos. Listar os pedidos do cliente com status e total pergunta por uma tabela plana com uma linha por pedido; rastrear itens pede uma segunda visão com uma linha por item; o painel do backoffice pede agregações por dia. Três perguntas, três visões, uma única sequência de eventos consumida por três projetores independentes, cada um com seu checkpoint e seu ritmo.
Cada projetor é um processo pequeno, com responsabilidade única: consumir, transformar, gravar e avançar o checkpoint. Falha em um projetor não derruba os demais, e a visão afetada simplesmente atrasa, com o lag visível em métrica. Essa isolação é uma das vantagens silenciosas do desenho: o acidente de uma visão tem raio limitado, e a recuperação é reprocessar o log a partir do checkpoint, sem envolver o resto do sistema.
A mesma disciplina do modelo de escrita não se aplica aqui, e isso é deliberado. Visões podem duplicar o nome do cliente em cada linha de pedido, podem somar totais incrementalmente e podem manter contadores separados. O critério de qualidade é outro: a visão responde a pergunta dela com uma leitura, sem joins e sem cálculo em tempo real?
Desnormalização deliberada
A desnormalização no read model é uma decisão de engenharia, e não um descuido. Cada campo duplicado compra uma leitura mais rápida e paga um pequeno custo na projeção. O exemplo típico de e-commerce mostra a diferença na forma do dado.
// Projeção: mantém a visão resumida usada pela tela Meus Pedidos
public class PedidosResumoProjection {
private final PedidosResumoDao dao;
public PedidosResumoProjection(PedidosResumoDao dao) {
this.dao = dao;
}
public void processar(EventoEnvolto evento) {
switch (evento.tipo()) {
case "PedidoCriado" -> dao.inserirResumo(
evento.dados().getString("pedidoId"),
evento.dados().getString("clienteId"),
evento.dados().getString("nomeCliente"), // nome duplicado de propósito
"AGUARDANDO_PAGAMENTO", BigDecimal.ZERO, 0);
case "ItemAdicionado" -> dao.acumularItem(
evento.dados().getString("pedidoId"),
evento.dados().getBigDecimal("valorLinha"),
evento.dados().getInt("quantidade"));
case "PedidoPago" -> dao.atualizarStatus(
evento.dados().getString("pedidoId"), "PAGO");
}
dao.marcarCheckpoint(evento.posicaoGlobal()); // progresso sobrevive a reinícios
}
}
O nome do cliente duplicado evita o join com a tabela de clientes em cada listagem; os totais acumulados evitam somar itens a cada resposta. Quando o cliente troca de nome, um evento de domínio correspondente atualiza as visões que carregam o dado, e o custo é o de atualizar linhas, não o de reescrever regras. A pergunta que orienta cada duplicação é sempre a mesma: quem consulta este campo, com que frequência, e quanto custa mantê-lo atualizado na projeção versus calcular na consulta.
O SQL da visão resultante mostra o ganho na leitura.
-- Tela Meus Pedidos: uma linha por pedido, sem nenhum join
SELECT pedido_id, nome_cliente, status, total, quantidade_itens
FROM pedidos_resumo
WHERE cliente_id = $1
ORDER BY criado_em DESC
LIMIT 20;
Uma consulta de um índice, resposta em milissegundos mesmo com milhões de pedidos. É esse contraste com a listagem de seis joins do modelo único que justifica a duplicação, e a mesma lógica se aplica a cada campo duplicado em cada visão.
Materialized views: quando o banco resolve
Nem toda visão precisa de projetor dedicado. Bancos relacionais oferecem views materializadas, que persistem o resultado de uma query e o refrescam por comando, e esse recurso atende bem visões analíticas de atualização tolerante. Em sistemas com eventos no próprio banco, a view materializada é a forma mais barata de materializar uma agregação, porque o banco cuida da mecânica.
-- View materializada: faturamento por dia a partir dos eventos gravados
CREATE MATERIALIZED VIEW vendas_por_dia AS
SELECT date_trunc('day', gravado_em) AS dia,
count(*) AS pedidos,
sum((dados->>'valor')::numeric) AS faturamento
FROM eventos
WHERE tipo = 'PedidoPago'
GROUP BY 1;
-- Refrescamento concorrente: lê a versão antiga enquanto constrói a nova
REFRESH MATERIALIZED VIEW CONCURRENTLY vendas_por_dia;
O limite dessa abordagem é o refrescamento, que reprocessa a consulta inteira e cresce com o volume. Visões pequenas e refrescamentos periódicos funcionam bem; visões que precisam estar frescas a cada segundo pedem projetor dedicado com checkpoint incremental. A regra prática: view materializada para agregação analítica com tolerância de minutos, projeção dedicada para visão operacional com tolerância de segundos.
Reconstrução do read model do zero
A capacidade de reconstruir qualquer visão a partir do log é a propriedade que dá coragem para desnormalizar. Como a visão é derivada, ela pode ser jogada fora e refeita quando a lógica da projeção muda, quando um bug corrompe linhas ou quando um campo novo precisa preencher o histórico. A operação segue o roteiro de estágio e troca: construir a visão nova em tabelas separadas, validar com contagens e somas comparativas, trocar a referência das consultas em um único deploy e descartar a versão antiga depois da janela de reversão.
Durante a reconstrução, a visão em uso continua servindo, e a nova cresce em paralelo, com seu próprio checkpoint partindo da posição zero. Em logs grandes, o reprocessamento usa réplicas de leitura e paralelismo por stream, e a duração esperada é medida e publicada, transformando a reconstrução em operação de rotina. Times que tratam rebuild como procedimento documentado desfrutam do melhor do padrão: corrigir a lógica de leitura sem medo, porque nada no caminho é irrecuperável.
Onde hospedar cada visão
| Tecnologia | Melhor para | Cuidados |
|---|---|---|
| PostgreSQL | Listagens, telas de operação, visões com filtro e paginação | Índices alinhados aos filtros; reconstrução via estágio |
| Redis | Totais quentes, contadores, sessão de carrinho | Persistência derivada; aceita perda, pois é reconstrutível |
| Elasticsearch | Busca textual, facetamento, catálogo | Mapeamento explícito; sincronização com versionamento de documento |
| Warehouse | Relatórios históricos, BI, agregações longas | Custo de ingestão; dados em formato analítico |
| Em memória no serviço | Visões mínimas de altíssima taxa | Perda em restart; carga inicial a partir do log |
A escolha por pergunta, e não por tecnologia favorita, é o que evita o monocultivo de leitura. Uma aplicação madura costuma manter três ou quatro hospedagens distintas, cada uma servindo a classe de consulta que lhe cabe, todas alimentadas pela mesma sequência de eventos.
O custo operacional desse ecossistema precisa estar no plano desde o início: cada hospedagem traz backup, monitoramento, segurança e ciclo de vida próprios. Times pragmaticos começam com o mínimo que atende as perguntas reais, geralmente um relacional e um cache, e adicionam especializações quando a demanda aparece documentada. O oposto, montar zoo completo antes da primeira consulta real, transforma o read model em um projeto de infraestrutura sem usuário.
Erros comuns e boas práticas
- Desenhar o read model pela forma do banco de escrita, mantendo joins e normalização; a visão deve nascer da tela ou do relatório, não da tabela.
- Colocar regras de negócio na projeção, como decidir aprovação; a projeção materializa efeitos de eventos, e decisão é território do comando.
- Atualizar múltiplas visões dependentes umas das outras; visões devem ser independentes e reconstruíveis sozinhas, sem rede de dependências.
- Esquecer o checkpoint fora da transação do efeito; o crash duplica ou perde eventos, e a visão diverge silenciosamente.
- Criar visões sem dono e sem métrica de uso; cada visão tem custo de operação, e visões mortas devem ser desligadas.
- Assumir que a visão está fresca sem medir lag; publique a defasagem por projeção e alerte quando passar do orçamento.
| Sintoma em produção | Causa provável | Mitigação |
|---|---|---|
| Listagem lenta apesar do read model | Visão com join herdado do modelo de escrita | Redesenhar a visão plana com os campos da tela |
| Dados divergentes entre visões do mesmo fato | Projeções com dependência entre si | Isolar projetores e reconstruir a visão errada do log |
| Visão corrompida sem causa evidente | Checkpoint fora da transação do efeito | Transacionar efeito e checkpoint com idempotência |
| Reconstrução estourando janela de manutenção | Reprocessamento no primário e sem paralelismo | Usar réplica, paralelizar por stream e medir antes |