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
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.
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
- Acrescente ao
PedidoEmbutidoum campoobservacoesdo tipoList<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. - Escreva um
updateFirstque incremente a quantidade de todos os itens com produtocafeusando 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. - 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.
- 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:
- MongoDB, modelagem de dados, com a tabela de embutir e referenciar e as citações desta aula: https://www.mongodb.com/docs/manual/data-modeling/concepts/embedding-vs-references/
- MongoDB, limites de sistema, com o tamanho máximo do documento BSON em mebibytes e o limite de 100 níveis de aninhamento: https://www.mongodb.com/docs/manual/reference/limits/
- MongoDB, atomicidade, com a citação sobre a atomicidade de uma operação em um único documento, lida na página de transações: https://www.mongodb.com/docs/manual/core/transactions/
- Spring Data MongoDB, “Using DBRefs”, com a regra de resolução eager e o atributo
lazy: https://docs.spring.io/spring-data/mongodb/reference/mongodb/mapping/document-references.html - Spring Data MongoDB, “Type Mapping”, com a condição do
_class(tipo complexo e subtipo do tipo declarado): https://docs.spring.io/spring-data/mongodb/reference/mongodb/converters-type-mapping.html - Spring Data MongoDB, índice da referência: https://docs.spring.io/spring-data/mongodb/reference/index.html
- Driver Java do MongoDB 5.8.1, código-fonte
MessageSettingseBsonWriterHelper(mongodb-driver-core-5.8.1-sources.jar, no Maven Central), para a constante de folga e a mensagem de tamanho: https://repo1.maven.org/maven2/org/mongodb/mongodb-driver-core/5.8.1/mongodb-driver-core-5.8.1-sources.jar
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
_classincluído. - A contagem de comandos
findfoi feita com umCommandListenerdo 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
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- Índices e transações no MongoDB
- data class como entidade
- JPA, Hibernate e N+1
- Trabalhando com JSON