trilha

Tecnologias/02 - Bancos de Dados/02 - Não Relacionais/01 - MongoDB6 min

Modelagem de Documentos

Conceito

Modelar em MongoDB é responder uma pergunta, repetidamente:

Este dado é lido junto com aquele?

Se sim, embuta. Se não, referencie. Todo o resto é refinamento.

Isso inverte o método relacional. Em SQL você modela as entidades, normaliza até a 3FN e deixa o otimizador montar as consultas (4. Modelagem e Normalização). Aqui você lista as consultas da aplicação e desenha o documento para servi-las — aceitando duplicação como parte do projeto, não como falha.

Embutir ou referenciar

Relação Padrão Por quê
1 : poucos (endereços de um cliente, itens de um pedido) embutir sempre lidos juntos, e o array não cresce
1 : muitos (produtos de uma categoria) referenciar do lado "muitos" array cresceria demais
1 : milhões (eventos de um sensor, logs) referenciar + bucket nem array nem documento por evento aguentam
muitos : muitos (alunos ↔ turmas) referenciar dos dois lados, ou coleção de ligação duplicar seria insustentável

As três perguntas que decidem, na ordem:

  1. O array tem limite conhecido? Se pode crescer sem teto → não embuta. Os 16 MB chegam.
  2. Eu leio o filho sem o pai? Se sim, o filho quer coleção própria.
  3. O filho muda com frequência? Dado embutido e duplicado precisa de atualização em N lugares.

Os padrões que resolvem os casos difíceis

Extended reference — duplique só os campos que a tela mostra:

// pedido: referência + os 2 campos que a listagem exibe
cliente: { _id: ObjectId("..."), nome: "Ana Souza" }

Evita o $lookup na consulta mais frequente. Se o nome mudar, você atualiza os pedidos em lote (ou não atualiza — num pedido, o nome de então é o correto).

Subset — embuta os N primeiros, referencie o resto:

// produto: as 5 avaliações que aparecem na página
avaliacoes_recentes: [ {...}, {...}, {...}, {...}, {...} ],
total_avaliacoes: 3742
// as outras 3737 ficam na coleção `avaliacoes`

Computed — grave o resultado, não recalcule:

total: Decimal128("429.70"),  // em vez de somar itens a cada leitura
media_nota: 4.6, qtd_notas: 3742

Bucket — agrupe muitos eventos pequenos em documentos por janela de tempo. É o padrão para série temporal (e a base das time series collections nativas, desde a 5.0):

{
  sensor_id: "s-42",
  dia: ISODate("2026-09-08"),
  leituras: [ { t: ISODate("...T00:00:00Z"), v: 21.4 }, /* ~1440 no dia */ ],
  min: 19.8, max: 26.1, media: 22.3   // computed junto
}

1440 documentos por dia por sensor viram 1. Menos overhead de índice, menos I/O, e o array tem teto conhecido — o que resolve o problema dos 16 MB por construção.

Schema versioning — carregue a versão no documento:

{ _schema: 3, nome: "Ana", contatos: [{ tipo: "email", valor: "..." }] }

Sem migração global: o app lê versões antigas e escreve na nova. É o superpoder real do schema flexível, e quase ninguém usa.

Trade-offs

Duplicar acelera leitura e cobra na escrita. O nome do cliente em 40 mil pedidos é uma leitura instantânea e uma atualização de 40 mil documentos. A pergunta certa não é "posso duplicar?", é "esse campo muda, e se mudar eu preciso propagar?". Para dado histórico (pedido, nota fiscal, log), duplicar é mais correto — o valor de então é o valor certo.

Embutir tem teto. 16 MB por documento, e o custo aparece antes disso: o MongoDB lê e grava o documento inteiro. Um documento de 8 MB do qual você só precisa de 3 campos é 8 MB de I/O por leitura.

Modelar por consulta assume que você conhece as consultas. Se em seis meses o produto pedir uma pergunta nova, o schema não vai ajudar — e a resposta será um $lookup lento ou uma migração. Em SQL normalizado, a pergunta nova é só um JOIN novo. É aqui que a troca aparece de verdade.

O trade-off central: o schema flexível não elimina o schema — ele move a decisão para o momento da escrita e a responsabilidade para o código. O ganho é evoluir sem ALTER TABLE; o custo é que nada garante o formato além do seu próprio cuidado.

Exemplo prático

O mesmo domínio, modelado errado e certo.

// ❌ Relacional disfarçado: 3 coleções, 2 $lookup na consulta da tela
// pedidos:      { _id, cliente_id, criado_em }
// itens:        { _id, pedido_id, sku, qtd, preco }
// clientes:     { _id, nome, email }
db.pedidos.aggregate([
  { $match: { _id: id } },
  { $lookup: { from: "itens",    localField: "_id",        foreignField: "pedido_id", as: "itens" } },
  { $lookup: { from: "clientes", localField: "cliente_id", foreignField: "_id",       as: "cliente" } }
])
// Sem FK, sem transação por padrão, e agora sem performance. Você pagou por nada.
// ✅ Um agregado, uma leitura
{
  _id: ObjectId("..."),
  cliente: { _id: ObjectId("..."), nome: "Ana Souza" },   // extended reference
  itens: [ { sku: "A1", qtd: 1, preco: Decimal128("249.90") } ],  // 1:poucos
  total: Decimal128("249.90"),                            // computed
  status: "pago",
  criado_em: ISODate("2026-09-08T14:12:00Z"),
  _schema: 2
}
db.pedidos.findOne({ _id: id })   // acabou

E o schema de volta ao banco, quando você quer garantias:

db.createCollection("pedidos", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["cliente", "itens", "total", "status", "criado_em"],
    properties: {
      status: { enum: ["novo", "pago", "enviado", "cancelado"] },
      total:  { bsonType: "decimal", minimum: 0 },
      itens:  { bsonType: "array", minItems: 1, maxItems: 200 }  // teto explícito!
    }
  }},
  validationLevel: "strict", validationAction: "error"
})
O critério que resolve a dúvida na maioria dos casos

O documento deve corresponder ao aggregate root do seu domínio — a unidade que muda junto, é consultada junto e precisa ser consistente junto. Um pedido com seus itens é um agregado. Um cliente com todos os seus pedidos não é: você lê os pedidos sem o cliente, e a lista cresce para sempre. Ver 2. Abstração.

Como isso falha em produção

  • Array sem teto. comentarios: [] num post viral → BSONObjectTooLarge, e a correção é remodelar com o sistema no ar.
  • $push em documento grande. Cada inserção reescreve o documento inteiro; a escrita fica mais lenta conforme cresce.
  • Duplicação sem plano de propagação. Nome do cliente em 5 coleções, e a de faturamento nunca foi atualizada.
  • $lookup em cascata. Pipeline com 3 estágios de lookup: o tempo multiplica, não soma.
  • Nenhuma validação. Três formatos de telefone convivendo, descobertos no relatório.
  • Índice para cada consulta nova. Índice custa escrita e RAM; 15 índices numa coleção de escrita intensa é o gargalo.

Aprofundamento aplicado

A fronteira do agregado precisa de limite de crescimento

Embutir itens de um pedido combina dados lidos juntos e com ciclo de vida comum. Embutir todo o histórico de acesso de um usuário cria crescimento contínuo, mesmo quando a tela só precisa das últimas dez ocorrências.

Pergunta Efeito no desenho
Os dados são alterados juntos? Pode favorecer documento único
A coleção aninhada cresce sem limite? Pode exigir referência ou divisão em blocos
O mesmo dado aparece em milhares de documentos? A atualização passa a exigir propagação
O valor é um retrato histórico? Duplicar pode representar corretamente o domínio

O endereço de entrega de um pedido concluído pode ser uma fotografia da compra. Atualizar esse endereço toda vez que o cliente muda de casa destruiria informação histórica. Já uma cópia do nome atual para busca precisa de política de atualização.

Exercício: modele perfil, últimos acessos e histórico completo. Resultado esperado: o perfil permanece limitado e a leitura do histórico é paginada; explique quais cópias são históricas e quais devem convergir ao valor atual.

Referência: modelagem de documentos.

Relacionado


Parte de MOC MongoDB · Data Modeling (MongoDB)

Buscar

Busca por título, seção e texto das notas