← Blog
·12 blog.minutes

Padrões de Design de Banco de Dados para Aplicações Modernas

Desde escolher entre relacional e NoSQL até gerenciar migrações em CI/CD, estes são os padrões de design de banco de dados que todo desenvolvedor precisa conhecer.

Design de banco de dados é uma daquelas habilidades raras que só se torna mais importante à medida que sua aplicação cresce. Um esquema bem projetado no início de um projeto pode sustentar anos de desenvolvimento de funcionalidades sem grandes reescritas. Um mal projetado tornará cada nova funcionalidade dolorosa, cada consulta mais lenta e cada deploy mais arriscado. As decisões que você toma sobre sua camada de dados ecoam através de cada camada da sua aplicação, e mudá-las depois é caro.

Este artigo aborda os padrões que importam para aplicações modernas. Estes não são exercícios acadêmicos — são estratégias práticas que times usam em produção para manter seus bancos de dados rápidos, sustentáveis e seguros de alterar. Vamos cobrir escolhas de mecanismo de armazenamento, princípios de design de esquema, otimização de consultas, padrões arquiteturais como CQRS e event sourcing, e as práticas operacionais que mantêm os bancos de dados funcionando sem problemas à medida que seu time e seus dados crescem.

Escolhendo um Banco de Dados: Relacional vs. NoSQL

A primeira e mais consequente decisão que você tomará é qual categoria de banco de dados usar. A boa notícia é que as antigas guerras tribais acabaram. Poucos times sérios são puramente relacionais ou puramente NoSQL atualmente. A abordagem pragmática é escolher a ferramenta certa para cada carga de trabalho, às vezes executando múltiplos mecanismos de armazenamento lado a lado.

Bancos de dados relacionais como PostgreSQL e SQLite se destacam quando seus dados têm relacionamentos claros, a integridade referencial importa e você precisa consultar entre entidades de maneiras flexíveis. Se você está construindo um sistema de faturamento, uma ferramenta de gerenciamento de inventário ou qualquer aplicação onde uma transação ou completa totalmente ou não completa, você quer garantias ACID. Bancos de dados relacionais oferecem essas garantias com décadas de teste em batalha.

Bancos de dados documentais como MongoDB são mais adequados quando seus dados são hierárquicos, seus padrões de acesso são conhecidos antecipadamente e você está disposto a trocar garantias de consistência por throughput de escrita ou flexibilidade de esquema. Eles brilham em sistemas de gerenciamento de conteúdo, pipelines de log de eventos e aplicações onde a forma dos dados muda frequentemente.

Aqui está um framework prático de decisão para escolher seu banco de dados principal:

  • Use PostgreSQL por padrão. Ele lida bem com 95% dos casos de uso, suporta colunas JSON para dados no estilo documental, tem excelente indexação e um ecossistema maduro. Comece aqui e só se desvie quando tiver um motivo específico.
  • Recorra ao SQLite quando sua aplicação roda no dispositivo, em um navegador via WASM, ou como um banco de dados embutido para ferramentas de servidor único. É de configuração zero, incrivelmente rápido para leituras e surpreendentemente capaz com extensões recentes.
  • Considere MongoDB ou Firestore quando você tem documentos profundamente aninhados que sempre lê e escreve como uma unidade, e seus requisitos de consistência são flexíveis o suficiente para tolerar leituras eventualmente consistentes.
  • Evite a armadilha do multi-banco em produção. Executar dois bancos de dados dobra sua complexidade operacional. Só adicione um segundo mecanismo de armazenamento quando tiver medido que seu banco de dados principal não consegue lidar com a carga de trabalho.

O arrependimento mais comum que vejo em bases de código de produção é escolher um banco de dados NoSQL para dados relacionais. Se suas entidades referenciam umas às outras e você precisa juntá-las, você quer um banco de dados relacional. O desencontro de impedância entre objetos de aplicação e tabelas relacionais é real, mas é muito menor que o desencontro de impedância entre um modelo inerentemente relacional e um armazenamento documental que nunca foi projetado para isso.

Normalização, Desnormalização e o Meio-Termo Real

Todo desenvolvedor aprende normalização na escola. Primeira forma normal, segunda forma normal, terceira forma normal — a progressão promete um esquema limpo e não redundante sem anomalias de atualização. A realidade da produção é mais sutil. Esquemas totalmente normalizados frequentemente produzem consultas que juntam dez tabelas para renderizar uma única página, o que é lento e complexo. Esquemas completamente desnormalizados tornam as escritas rápidas e as leituras triviais, mas introduzem problemas de consistência e tornam cada escrita mais propensa a erros.

O meio-termo prático é normalizar para integridade e desnormalizar para desempenho, mas faça isso deliberadamente e documente seu raciocínio. Comece com um esquema normalizado que capture os verdadeiros relacionamentos em seus dados. Então, à medida que você mede padrões reais de consulta, adicione campos desnormalizados ou tabelas de resumo onde o benefício de desempenho justifique a complexidade adicional.

-- Comece normalizado
CREATE TABLE orders (
  id        UUID PRIMARY KEY,
  user_id   UUID NOT NULL REFERENCES users(id),
  status    TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id         UUID PRIMARY KEY,
  order_id   UUID NOT NULL REFERENCES orders(id),
  product_id UUID NOT NULL REFERENCES products(id),
  quantity   INT NOT NULL,
  unit_price NUMERIC(10,2) NOT NULL
);

-- Desnormalize só quando medido: adicione total a orders
ALTER TABLE orders ADD COLUMN total NUMERIC(10,2);

-- Mantenha consistente com uma trigger ou lógica de aplicação
CREATE OR REPLACE FUNCTION update_order_total()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE orders SET total = (
    SELECT SUM(quantity * unit_price)
    FROM order_items WHERE order_id = NEW.order_id
  ) WHERE id = NEW.order_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_total_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW EXECUTE FUNCTION update_order_total();

A regra prática é simples: nunca desnormalize até que tenha medido uma consulta que importa. A desnormalização prematura introduz toda a complexidade de dados redundantes sem a evidência de que resolve um problema real. Quando você desnormalizar, documente a decisão, adicione testes que verifiquem a consistência e construa um job de reconciliação que possa detectar e corrigir desvios. Dados redundantes sempre se desviam com o tempo. Planejar a detecção de desvios não é pessimismo — é maturidade de engenharia.

Estratégias de Indexação Que Realmente Funcionam

Índices são a otimização de desempenho de maior impacto disponível para qualquer usuário de banco de dados. Um único índice bem posicionado pode transformar uma varredura sequencial de milhões de linhas em um punhado de leituras de página. Mas índices não são gratuitos. Cada índice adiciona overhead em escritas, consome armazenamento e pode confundir o planejador de consultas se houver muitos candidatos para uma determinada consulta.

A estratégia que consistentemente produz bons resultados em produção segue três princípios. Primeiro, indexe suas chaves estrangeiras. Toda coluna que referencia outra tabela deve ser indexada por padrão. O desempenho de joins depende de buscas em índices em ambos os lados do join, e esquecer de indexar chaves estrangeiras é o erro de desempenho mais comum em bancos de dados relacionais.

Segundo, indexe seus padrões de consulta, não suas colunas. Olhe para as cláusulas WHERE e ORDER BY nas suas consultas mais lentas e crie índices compostos que correspondam exatamente a esses padrões. Um índice composto em (status, created_at) é inútil para uma consulta filtrando apenas por created_at, mas um índice composto em (created_at, status) pode servir ambas as consultas se created_at for seletivo o suficiente. A ordem das colunas em um índice composto importa enormemente.

-- Em vez de índices separados
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at);

-- Crie índices compostos que correspondam a padrões reais de consulta
-- Consulta: SELECT * FROM orders WHERE status = 'active' ORDER BY created_at DESC;
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);

-- Consulta: SELECT * FROM orders WHERE user_id = $1 AND status = 'active';
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

Terceiro, meça antes e depois. A view pg_stat_user_indexes do PostgreSQL mostra quais índices são realmente usados e quais são peso morto. Execute uma carga de trabalho, verifique as estatísticas de uso de índice e descarte índices que nunca são varridos. Índices não utilizados não são inofensivos — eles lentificam toda operação de escrita e consomem memória de cache que poderia armazenar páginas de dados úteis.

Índices parciais são uma ferramenta subutilizada. Se você consulta frequentemente apenas um subconjunto de linhas — pedidos ativos, eventos não processados, usuários não deletados — crie um índice parcial que cubra apenas essas linhas. Ele será uma fração do tamanho de um índice completo e significativamente mais rápido de varrer.

-- Índice parcial: indexe apenas pedidos ativos
CREATE INDEX idx_orders_active ON orders(created_at DESC)
WHERE status = 'active';

-- Este índice é minúsculo comparado a um índice completo e atende perfeitamente à consulta

O Padrão Repository e Abstração de Acesso a Dados

O padrão Repository faz a mediação entre sua lógica de domínio e seu código de acesso a dados. Ele define uma interface similar a uma coleção para recuperar e persistir agregados, escondendo os detalhes do mecanismo de armazenamento subjacente. Na prática, isso significa que seu código de aplicação chama métodos como userRepository.findById(id) ou orderRepository.save(order) sem saber se os dados vêm do PostgreSQL, de uma camada de cache ou de uma API externa.

O valor deste padrão fica claro quando você precisa mudar seu banco de dados ou introduzir uma camada de cache. Um time que tem consultas espalhadas por controllers, serviços e funções utilitárias enfrenta uma reescrita de centenas de arquivos quando muda de MongoDB para PostgreSQL. Um time que usa repositories muda um punhado de arquivos de implementação e a interface permanece a mesma.

No entanto, o padrão Repository tem uma tensão bem conhecida com bancos de dados relacionais. Se sua interface de repository é muito genérica — findAll, findById, save, delete — ela não pode expressar as ricas capacidades de consulta que bancos de dados relacionais oferecem. Times frequentemente acabam adicionando métodos de consulta especializados aos repositories de qualquer forma, o que gradualmente transforma a abstração em uma abstração vazada. A solução é aceitar que repositories para bancos de dados relacionais terão mais métodos que repositories para armazenamentos chave-valor. Um repository de usuários com métodos findActiveByRole, searchByName e countByStatus não é uma falha de abstração — é ser honesto sobre as capacidades do mecanismo subjacente.

Um repository que esconde capacidades de consulta não é uma abstração — é uma deficiência. O objetivo é isolar seu domínio dos detalhes de armazenamento, não reduzir todo banco de dados ao mínimo denominador comum.

CQRS e Event Sourcing: Quando Recorrer a Padrões Avançados

Command Query Responsibility Segregation separa o caminho de mutação de dados do caminho de leitura de dados. Em sua forma mais simples, uma arquitetura CQRS usa o mesmo banco de dados, mas modelos diferentes para escritas e leituras. O modelo de escrita impõe invariantes e produz eventos. O modelo de leitura consome esses eventos e constrói visões desnormalizadas otimizadas para consultas específicas. Essa separação permite que cada lado seja escalado e otimizado independentemente.

Event sourcing vai um passo além. Em vez de armazenar o estado atual de uma entidade, você armazena todo evento que a modificou. O estado atual é derivado reproduzindo esses eventos. Isso fornece uma trilha de auditoria completa, a capacidade de reconstruir o estado em qualquer ponto no tempo e uma fonte natural de eventos para consumidores downstream. O trade-off é uma complexidade operacional significativa — infraestrutura de event store, gerenciamento de projeções, consistência eventual entre modelos de escrita e leitura, e o overhead cognitivo de pensar em eventos em vez de estado.

Esta é a avaliação honesta do padrão: a maioria das aplicações não precisa de CQRS ou event sourcing. Eles adicionam complexidade que só se justifica quando você tem requisitos específicos que arquiteturas mais simples não conseguem atender. Considere CQRS quando suas cargas de trabalho de leitura e escrita têm características fundamentalmente diferentes — alto throughput de escrita com projeções de leitura complexas, ou requisitos de consistência diferentes por operação. Considere event sourcing quando você precisa de uma trilha de auditoria imutável por lei ou por requisito do produto, ou quando toda mudança de estado precisa ser reconstruível e analisável.

Se você adotar esses padrões, comece apenas com CQRS e só adicione event sourcing se o requisito de trilha de auditoria for explícito. Implementar CQRS mantendo modelos de leitura separados no PostgreSQL é gerenciável. Adicionar um event store por cima é um salto significativo em complexidade que deve ser uma decisão deliberada e bem financiada.

Migrações, Pool de Conexões e Sanidade Operacional

O lado operacional do design de banco de dados é onde bons padrões separam aplicações que evoluem suavemente daquelas que precisam de um incidente de produção para mudar um tipo de coluna. Três práticas consistentemente distinguem times que lidam bem com mudanças de banco de dados daqueles que sofrem.

Primeiro, toda mudança de esquema deve ser uma migração reversível armazenada no controle de versão. Ferramentas como Flyway, Liquibase ou Alembic aplicam migrações em ordem e rastreiam quais foram aplicadas. O arquivo de migração é código — ele é revisado, testado e implantado através do mesmo pipeline que o código da aplicação. Cada migração deve ser pequena e focada. Uma migração que adiciona uma coluna, faz backfill de dados e renomeia uma tabela em um único arquivo é arriscada. Divida-a em etapas separadas que podem ser revertidas independentemente.

Segundo, o pool de conexões não é opcional. Abrir uma conexão de banco de dados é caro — envolve um handshake TCP, negociação SSL e autenticação. Um pool de conexões mantém um conjunto de conexões persistentes que threads pegam emprestado e devolvem. O tamanho do pool importa: poucas conexões e as requisições ficam em fila, muitas e o banco de dados gasta todo seu tempo alternando contexto. Um bom ponto de partida para PostgreSQL é pool_size = 2 * CPU_cores, depois monitore e ajuste com base na latência de consulta e tempos de espera de conexão.

Terceiro, integre migrações ao seu pipeline CI/CD com cuidado. O padrão mais seguro é aplicar migrações antes de implantar o novo código da aplicação. Desta forma, o novo código vê o esquema que espera, e o código antigo ainda é compatível com o novo esquema porque alterações compatíveis com versões anteriores foram aplicadas primeiro. Isso requer que toda migração seja compatível com versões anteriores com o código atual — sem remover colunas que o código em execução ainda referencia, sem renomear tabelas sem um período de transição.

  • Expandir: adicione a nova coluna ou tabela enquanto o código antigo ainda executa.
  • Migrar: faça backfill de dados e transicione escritas para a nova estrutura.
  • Contrair: remova a coluna ou tabela antiga após confirmar que o código antigo não está mais em execução.

Este padrão expandir-migrar-contrair para mudanças de esquema com zero downtime funciona porque nunca deixa o banco de dados em um estado que o código em execução não possa manipular. Requer disciplina — você precisa manter caminhos de código antigos vivos por um ciclo de deploy — mas elimina a causa mais comum de falhas de deploy relacionadas a mudanças de banco de dados.

SQL vs. ORM: Encontrando o Equilíbrio Certo

O debate entre escrever SQL puro e usar um Mapeador Objeto-Relacional é uma das discussões mais persistentes no desenvolvimento de software. Ambos os lados têm pontos válidos, e a resposta certa depende do contexto do seu projeto.

ORMs como Prisma, TypeORM ou SQLAlchemy fornecem mapeamento automático entre seus objetos de aplicação e tabelas de banco de dados, gerenciamento de migrações e construção de consultas na linguagem da sua aplicação. Eles eliminam categorias inteiras de código boilerplate e facilitam o início. O custo é que eles abstraem o SQL, o que significa que quando algo dá errado — uma consulta lenta, um join inesperado, uma escalada de lock — você precisa entender tanto o comportamento do ORM quanto o SQL subjacente para depurar. ORMs também tendem a gerar consultas subótimas para padrões de acesso complexos, e o problema de consulta N+1 é um rito de passagem para todo time que usa um ORM.

SQL puro lhe dá controle completo sobre o que executa no servidor de banco de dados. Você pode escrever exatamente a consulta que precisa, ajustada ao seu esquema e às capacidades do seu motor de banco de dados. O custo é que você perde o mapeamento automático, tem que gerenciar migrações por conta própria, e sua base de código acaba com strings SQL espalhadas por toda parte que são difíceis de testar e ainda mais difíceis de refatorar.

O meio-termo pragmático é usar um ORM para os 80% das consultas que são operações CRUD diretas e recorrer ao SQL puro para os 20% que precisam de ajuste de desempenho ou relatórios complexos. A maioria dos bons ORMs fornece uma maneira de executar consultas SQL puras e mapear resultados de volta para objetos tipados. Use o query builder do ORM para operações simples, escreva SQL puro para as complexas, e teste ambos contra seu banco de dados real com volumes de dados realistas.

PostgreSQL é a escolha padrão para a maioria das aplicações modernas porque equilibra capacidade, confiabilidade e ecossistema melhor que qualquer outro banco de dados. SQLite domina o espaço embutido e local-first. MySQL continua comum em ecossistemas legados e WordPress. O banco de dados que você escolhe importa menos que os padrões que você aplica para usá-lo bem — design de esquema, indexação, gerenciamento de migrações e práticas operacionais determinam seu sucesso muito mais que o logo na embalagem.

O banco de dados que você escolhe importa menos que os padrões que você aplica para usá-lo bem. Design de esquema, indexação e práticas operacionais determinam seu sucesso muito mais que o logo na embalagem.

Design de banco de dados não é uma atividade única. É uma prática contínua de medir, ajustar e aprender. Os padrões neste artigo servirão bem como ponto de partida, mas a verdadeira expertise vem de observar como seu banco de dados se comporta sob cargas de trabalho reais e responder com mudanças direcionadas e deliberadas. Comece simples. Meça tudo. E nunca tenha medo de criar um arquivo de migração.