Pular para o conteúdo

Trilha de Kotlin · parte 52 de 59

Spring Data MongoDB: documentos e modelagem

Aula da fase 7 da trilha de Kotlin: Quando embutir um documento e quando referenciá-lo, e como `MongoTemplate` atende consultas que o repositório não cobre?

intermediárioPublicada em · 14 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Pense em uma pasta de pedido de restaurante. Ela pode guardar, dentro de si, a comanda com o nome do cliente, a lista de pratos e a observação escrita a caneta. Ou pode guardar só um número, e o nome do cliente fica em outra pasta, num arquivo separado. As duas formas funcionam. A diferença aparece quando alguém precisa abrir o pedido: na primeira, basta uma pasta; na segunda, é preciso abrir o arquivo de clientes também. No MongoDB, essa escolha tem nome. Embutir é colocar os dados relacionados dentro do mesmo documento. Referenciar é guardar só um identificador e buscar o restante em outro documento.

Esta aula trata dessa escolha, que é a decisão de modelagem mais importante do MongoDB, e de como o Spring Data MongoDB lida com ela. Ela também mostra o MongoTemplate, a peça que o repositório usa por baixo e que aparece quando a consulta precisa ser montada à mão. A aula usa um MongoDB 8 standalone em container e o Spring Data MongoDB 5.1.1 sem o contexto do Spring Boot, para que a saída dos documentos apareça sem intermediários.

o mapa da aula

A primeira parte mostra como o Spring Data mapeia classes para documentos, com o BSON que de fato foi gravado. A segunda compara as três formas de guardar o relacionamento entre pedido e cliente, com a contagem real de comandos de leitura para cada uma. A terceira parte mostra o MongoTemplate fazendo uma consulta e uma atualização parcial que um repositório não expressa de forma direta. Depois vem o limite de 16 MiB por documento, com a exceção real. Por fim, as boas práticas e as lacunas.

Versões desta aula: Spring Data MongoDB 5.1.1, driver Java do MongoDB 5.8.1, Spring Framework 7.0.9 e Kotlin 2.3.21 vindo do BOM do Spring Boot 4.1.1. O servidor usado foi o MongoDB 8.3.13 da imagem mongo:8, em um container com a porta 27020. O experimento não subiu o contexto do Spring Boot. Ele criou o MongoTemplate direto, com o cliente do driver, o que é suficiente para mostrar o mapeamento e os comandos.

1. documentos e o que o Spring grava

Um documento no MongoDB é um objeto BSON, que é um formato binário parecido com JSON, com tipos a mais, como datas e identificadores. Uma coleção é um conjunto de documentos, sem um esquema fixo: dois documentos da mesma coleção podem ter campos diferentes. O Spring Data mapeia uma classe Kotlin para uma coleção com a anotação @Document, e o campo marcado com @Id vira o _id do documento.

O código abaixo define as classes do experimento. Repare que todas são data class. Na aula data class como entidade, uma data class como entidade JPA criou problemas de igualdade e de recursão. Aqui, as classes são usadas como documentos, sem relacionamento bidirecional, e a igualdade por valor é adequada para o que o exemplo quer mostrar:

data class ClienteSnapshot(val nome: String, val email: String)
data class Item(val produto: String, val quantidade: Int)

@Document("clientes")
data class Cliente(@Id val id: String, val nome: String, val email: String)

@Document("pedidos_embutidos")
data class PedidoEmbutido(@Id val id: String, val cliente: ClienteSnapshot, val itens: List<Item>)

@Document("pedidos_referenciados")
data class PedidoReferenciado(@Id val id: String, val clienteId: String, val itens: List<Item>)

@Document("pedidos_dbref")
data class PedidoDbRef(@Id val id: String, @DBRef val cliente: Cliente, val itens: List<Item>)

Depois de salvar um pedido embutido, o documento gravado no banco ficou assim, lido direto da coleção:

{"_id": "p1", "cliente": {"nome": "Ana", "email": "ana@exemplo.com"}, "itens": [{"produto": "cafe", "quantidade": 2}, {"produto": "pao", "quantidade": 1}], "_class": "aula65.PedidoEmbutido"}

Dois detalhes aparecem nessa saída. O campo _class foi acrescentado pelo Spring Data para que ele saiba qual classe Kotlin criar ao ler o documento. Isso é útil quando a coleção guarda tipos diferentes, e vale a pena saber que ele existe, porque aparece em todo documento. O outro detalhe é que o ClienteSnapshot foi gravado como um objeto aninhado, sem _class, porque ele é o tipo declarado do campo. A documentação do Spring Data MongoDB diz que o type hint é gravado “for top-level documents as well as for every value (if it is a complex type and a subtype of the declared property type)”. O ClienteSnapshot é exatamente o tipo declarado do campo, e não um subtipo dele, então a regra explica o caso observado. A página não trata do caso em que o tipo real é igual ao declarado, e a aula não testou outros casos, como subtipos ou coleções.

2. embutir, referenciar e contar as leituras

A escolha entre embutir e referenciar é a parte da modelagem que mais pesa. A documentação oficial do MongoDB apresenta a decisão com uma tabela de cenários. Entre os casos para embutir, ela cita: “Keeping related data together will lead to a simpler data model and code.” e “Your application queries pieces of information together.” Entre os casos para referenciar, ela cita: “Your embedded data grows without bounds.” e “The child side of the relationship has high cardinality.”

O experimento compara três formas do mesmo pedido, todas com o mesmo cliente. A primeira embute um snapshot do cliente, com nome e e-mail, dentro do pedido. A segunda guarda só o clienteId e exige uma leitura extra para trazer o cliente. A terceira usa @DBRef, que guarda uma referência no formato {"$ref", "$id"} e pede que o Spring a resolva. A figura mostra as três leituras lado a lado.

Quantas leituras cada modelo exige. findById p1 → pedidos_embutidos (1 documento); findById p1 → pedidos_referenciados (1º find)…
figura 1 · Quantas leituras cada modelo exige

A contagem de comandos de leitura foi feita com um CommandListener do driver, que conta cada comando find que sai para o servidor. Os resultados reais foram:

embutido: Ana, comandos find=1
referenciado: Ana, comandos find=2
dbref: Ana, comandos find=2

No modelo embutido, o pedido inteiro veio em uma leitura, e o nome do cliente estava ali. No modelo referenciado, o código precisou de uma segunda leitura para trazer o cliente, porque o pedido só tem o clienteId. No @DBRef, o Spring fez a segunda leitura por conta própria, quando o campo do cliente foi preenchido. Os três casos mostram o mesmo custo de forma diferente: o embutido paga em tamanho do documento, e os outros pagam em viagens ao servidor.

A escolha depende de como os dados são lidos e escritos. Um snapshot de nome e e-mail embutido no pedido é uma foto do cliente naquele momento, e isso pode ser exatamente o que o negócio quer, porque uma nota fiscal não deve mudar quando o cliente troca de e-mail. Se o cliente precisa estar sempre atualizado, a referência é mais honesta. A documentação resume a dúvida com a pergunta de se os dados filhos existem por conta própria, e a resposta costuma apontar a decisão.

A regra de carregamento está na página “Using DBRefs” da documentação: “When the object is loaded from MongoDB, those references are eagerly resolved so that you get back a mapped object that looks the same as if it had been stored embedded within your top-level document.” Ou seja, por padrão o Spring resolve a referência ao carregar o pedido. O atributo lazy de @DBRef adia essa resolução para o primeiro acesso à propriedade. O experimento confirmou o padrão, porque o carregamento do pedido com @DBRef gerou a segunda leitura sem nenhuma chamada extra do código. A forma {"$ref", "$id"} aparece na saída BSON acima; a página não a descreve pelo nome.

3. MongoTemplate para o que o repositório não expressa bem

O repositório do Spring Data cria consultas a partir de nomes de métodos e de anotações. Para consultas simples, isso basta. Quando a consulta precisa de um filtro em um campo de um array, ou de uma atualização parcial de um elemento, o MongoTemplate deixa a montagem explícita, com os objetos Query, Criteria e Update.

O código a seguir faz duas coisas: busca os pedidos que têm cafe em algum item e incrementa a quantidade do primeiro item do pedido p1, sem reescrever o documento inteiro:

val comCafe = template.find(Query(Criteria.where("itens.produto").`is`("cafe")), PedidoEmbutido::class.java)

val resultado = template.updateFirst(
    Query(Criteria.where("_id").`is`("p1")),
    Update().inc("itens.0.quantidade", 1),
    PedidoEmbutido::class.java
)

O resultado real foi pedidos com cafe: [p1] e modificados=1, e o documento depois da atualização mostrou o item de café com quantidade 3, e o de pão intacto. A atualização com $inc acontece no servidor, sobre o campo, e não depende de o código ler o documento, somar e gravar de novo. A intenção dessa escrita é que duas requisições que incrementam ao mesmo tempo não se atropelem, porque o incremento acontece no servidor. Com leitura, soma e gravação no cliente, uma atualização pode sobrescrever a outra, como o teste abaixo mostra.

Para medir a diferença, duas conexões concorrentes incrementaram o mesmo documento no container, cada uma com 2000 operações. No primeiro método, cada conexão lê o valor, soma 1 no cliente e grava o resultado. No segundo, cada conexão envia $inc ao servidor. Com leitura, soma e gravação, a soma final ficou em 2102, 2089 e 2087 em três rodadas, em vez dos 4000 esperados. Com $inc, o resultado foi 4000 em todas as rodadas. Cada incremento perdido corresponde a uma gravação que sobrescreveu outra feita com o mesmo valor antigo. A garantia de atomicidade vale para o documento: a documentação de transações diz que “In MongoDB, an operation on a single document is atomic.”

4. o limite de 16 MiB

Um documento no MongoDB tem um tamanho máximo, e a documentação o define assim: “The maximum BSON document size is 16 mebibytes.” A consequência de modelagem é que um campo embutido que cresce sem limite, como uma lista de itens de um pedido que nunca fecha, acaba batendo nesse teto. O experimento montou um pedido com 400 mil itens pequenos e tentou salvar:

BsonMaximumSizeExceededException: Payload document size is larger than maximum of 16793600.

O número 16793600 é o limite que o driver usou para recusar a gravação antes de enviar ao servidor. Ele é 16 MiB (16777216 bytes) mais 16 KiB (16384 bytes) de folga. No código-fonte do driver Java 5.8.1, a constante DOCUMENT_HEADROOM_SIZE, em MessageSettings, vale 16 * (1 << 10), e o driver soma essa folga ao limite do documento ao montar as configurações de escrita do comando. A folga é uma margem interna do driver para o envelope do comando. A regra do documento em si é a da documentação, de 16 MiB. O que importa é que a gravação foi recusada e o documento não foi criado, e que a recusa veio do driver, antes do envio.

Esse teste explica por que o embutir tem um limite de crescimento. Um pedido com dez itens é pequeno. Um histórico de eventos de um cliente, embutido no cliente, cresce a cada compra, e um dia atinge o teto. A regra da documentação para esse caso é referenciar, porque o filho cresce sem limite.

5. erros comuns e boas práticas

O primeiro erro é embutir por hábito, copiando o que se faz no relacional, sem perguntar como os dados são lidos. Se a tela mostra o pedido e o nome do cliente juntos, embutir economiza uma viagem ao banco. Se a tela só mostra os pedidos de um cliente, o snapshot embutido vira duplicação que precisa ser atualizada.

O segundo erro é deixar uma lista embutida crescer sem limite. A documentação trata isso como sinal para referenciar, e o teste de 16 MiB mostra o custo de não fazer isso a tempo. A pergunta a fazer é se há um teto natural para o número de filhos, e se não há, a lista não deve morar dentro do documento pai.

O terceiro erro é usar @DBRef sem saber o que ele custa. O experimento mostrou duas leituras onde o modelo embutido teve uma. Quando a leitura é frequente, a referência por identificador, explícita no código, costuma ser mais previsível, porque o custo fica visível na função que a faz.

O quarto erro é usar o MongoTemplate para tudo. Ele é poderoso, mas a consulta fica no código e não no nome do método. A regra prática é usar o repositório para o caso simples e o template para a consulta que exige filtro de array, atualização parcial ou montagem dinâmica.

A boa prática, em resumo, é descrever as leituras antes de desenhar o documento, embutir o que é lido e atualizado junto, referenciar o que cresce sem limite ou é compartilhado por muitos pais, e conferir o tamanho esperado dos documentos com uma conta simples. A próxima aula mostra como índices e transações mudam essa conta, porque o banco também gasta tempo fora do documento, nos índices e nas transações.

6. exercícios

  1. Acrescente ao PedidoEmbutido um campo observacoes do tipo List<String> e salve um pedido com dez observações. Depois descreva em que situação esse campo se torna um problema, em termos da seção 4.
  2. Escreva um updateFirst que incremente a quantidade de todos os itens com produto cafe usando o operador posicional do MongoDB, e confira o resultado no banco. A aula não verificou essa sintaxe, então consulte a documentação antes de escrevê-la.
  3. Rode o experimento da seção 2 com um cliente que tem três pedidos. Conte as leituras para listar os três pedidos com o nome do cliente, nos dois modelos, e explique a diferença de custo entre o caso de um pedido e o de três.
  4. Explique com as suas palavras por que o snapshot do cliente embutido no pedido pode ser exatamente o que um sistema de notas fiscais precisa.

7. glossário

Documento: objeto BSON guardado em uma coleção. Pode ter campos diferentes de outros documentos da mesma coleção.

BSON: formato binário com tipos adicionais, usado pelo MongoDB para guardar documentos.

Embutir: guardar dados relacionados dentro do mesmo documento.

Referenciar: guardar um identificador e buscar o documento relacionado em outra consulta.

@DBRef: anotação do Spring Data que guarda a referência no formato $ref e $id, e que o Spring resolve ao ler o documento.

MongoTemplate: classe do Spring Data que executa operações no MongoDB com objetos de consulta e de atualização montados no código.

_class: campo que o Spring Data grava para indicar a classe Kotlin do documento, usado na leitura.

fontes

Consultadas em 20/10/2026:

Consultadas em 20/10/2026. As citações foram lidas com WebFetch nas URLs acima.

Verificações locais, feitas em 20/10/2026:

  • O experimento foi executado contra um MongoDB 8.3.13 em container, com o Spring Data MongoDB 5.1.1 e o driver 5.8.1. A saída de cada documento foi copiada do terminal, com _class incluído.
  • A contagem de comandos find foi feita com um CommandListener do driver.
  • O erro de tamanho foi o lançado pelo driver, com a mensagem copiada.

Código do experimento (arquivo aula65/Main.kt):

package aula65

import com.mongodb.ConnectionString
import com.mongodb.MongoClientSettings
import com.mongodb.client.MongoClients
import com.mongodb.event.CommandListener
import com.mongodb.event.CommandStartedEvent
import java.util.concurrent.atomic.AtomicInteger
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.mapping.DBRef
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.Update

data class ClienteSnapshot(val nome: String, val email: String)
data class Item(val produto: String, val quantidade: Int)

@Document("clientes")
data class Cliente(@Id val id: String, val nome: String, val email: String)

@Document("pedidos_embutidos")
data class PedidoEmbutido(@Id val id: String, val cliente: ClienteSnapshot, val itens: List<Item>)

@Document("pedidos_referenciados")
data class PedidoReferenciado(@Id val id: String, val clienteId: String, val itens: List<Item>)

@Document("pedidos_dbref")
data class PedidoDbRef(@Id val id: String, @DBRef val cliente: Cliente, val itens: List<Item>)

fun main() {
    val finds = AtomicInteger()
    val settings = MongoClientSettings.builder()
        .applyConnectionString(ConnectionString("mongodb://localhost:27020"))
        .addCommandListener(object : CommandListener {
            override fun commandStarted(event: CommandStartedEvent) {
                if (event.commandName == "find") finds.incrementAndGet()
            }
        })
        .build()
    val client = MongoClients.create(settings)
    val template = MongoTemplate(client, "aula65")
    template.getCollectionNames().forEach { template.getCollection(it).drop() }

    val ana = Cliente("c1", "Ana", "ana@exemplo.com")
    template.save(ana)
    template.save(ClienteSnapshot("Ana", "ana@exemplo.com").let { PedidoEmbutido("p1", it, listOf(Item("cafe", 2), Item("pao", 1))) })
    template.save(PedidoReferenciado("r1", "c1", listOf(Item("cafe", 2), Item("pao", 1))))
    template.save(PedidoDbRef("d1", ana, listOf(Item("cafe", 2), Item("pao", 1))))

    println("== documento embutido (BSON salvo)")
    println(template.getCollection("pedidos_embutidos").find().first()?.toJson())
    println("== documento com referencia por id (BSON salvo)")
    println(template.getCollection("pedidos_referenciados").find().first()?.toJson())
    println("== documento com @DBRef (BSON salvo)")
    println(template.db.getCollection("pedidos_dbref", org.bson.BsonDocument::class.java).find().first()?.toJson())

    println("== leitura de um pedido")
    finds.set(0)
    val embutido = template.findById("p1", PedidoEmbutido::class.java)
    println("embutido: ${embutido?.cliente?.nome}, comandos find=${finds.get()}")
    finds.set(0)
    val referenciado = template.findById("r1", PedidoReferenciado::class.java)
    val cliente = referenciado?.let { template.findById(it.clienteId, Cliente::class.java) }
    println("referenciado: ${cliente?.nome}, comandos find=${finds.get()}")
    finds.set(0)
    val dbref = template.findById("d1", PedidoDbRef::class.java)
    println("dbref: ${dbref?.cliente?.nome}, comandos find=${finds.get()}")

    println("== consulta com Criteria sobre item do array")
    val comCafe = template.find(Query(Criteria.where("itens.produto").`is`("cafe")), PedidoEmbutido::class.java)
    println("pedidos com cafe: ${comCafe.map { it.id }}")

    println("== atualizacao parcial com \$inc")
    val resultado = template.updateFirst(
        Query(Criteria.where("_id").`is`("p1")),
        Update().inc("itens.0.quantidade", 1),
        PedidoEmbutido::class.java
    )
    println("modificados=${resultado.modifiedCount}")
    println(template.getCollection("pedidos_embutidos").find().first()?.get("itens"))

    println("== documento acima de 16 MiB")
    val grande = PedidoEmbutido("g1", ClienteSnapshot("Ana", "ana@exemplo.com"), List(400_000) { Item("cafe", 1) })
    try {
        template.save(grande)
        println("salvo")
    } catch (e: Exception) {
        println("${e::class.simpleName}: ${e.message?.lineSequence()?.first()}")
    }
    client.close()
}

Não verificado nesta aula: o comportamento de @DBRef em outros casos (listas, nulos, lazy = true), a sintaxe do operador posicional (exercício 2), a regra de _class para tipos iguais ao declarado, e o uso do Spring Boot para criar o MongoTemplate no lugar da construção manual.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?