Nesta aula os relatos dos caminhantes vão para o MongoDB. Você vai consultar, alterar e agregar documentos, primeiro no mongosh e depois em Java, e vai terminar com critérios honestos para escolher entre o modelo relacional e o de documentos.
Desde "Extensão — Dados que Sobrevivem: SQL Essencial e JDBC com SQLite", tudo nas Trilhas da Ilha é tabela: linhas com as mesmas colunas, chaves estrangeiras, JOIN. Esse modelo é ótimo para a trilha, que tem nome, região, distância e dificuldade, sempre os mesmos campos. Ele funciona pior para o que vem agora: os relatos de quem fez a trilha. Um relato traz um texto e uma nota, e depois varia muito. Um caminhante anota o tempo de cada trecho, outro sobe três fotos, um terceiro registra que "choveu na véspera". Um só descreve a lama, outro recebe comentários. Em tabelas, isso vira cinco tabelas-satélite ou uma coluna JSON. O MongoDB parte da ideia oposta: o registro é um documento, com estrutura aninhada e variável, lido e gravado inteiro.
O modelo de documentos
Um documento do MongoDB é um objeto parecido com JSON, gravado em BSON, que é JSON binário com mais tipos: inteiros de 32 e 64 bits, double, Decimal128, datas, ObjectId e binários. Documentos ficam em coleções, e coleções ficam em bancos. As peças correspondem assim:
| Relacional | MongoDB |
|---|---|
| banco | banco |
| tabela | coleção |
| linha | documento |
| coluna | campo (pode conter objetos e arrays) |
PRIMARY KEY |
_id (obrigatório, único na coleção) |
JOIN |
documento embutido ou $lookup |
A diferença que importa não está na tabela acima. Ela está em como você desenha os dados. No relacional você normaliza: cada fato mora em um só lugar, e a consulta junta as partes. Em documentos você desenha para o jeito que os dados são lidos. O que é lido junto fica junto. A página da trilha mostra o relato com seus trechos e suas fotos, e por isso trechos e fotos ficam dentro do relato.
Duas perguntas decidem entre embutir e referenciar:
- As partes são lidas juntas? Os trechos só aparecem dentro do relato, então ficam embutidos. O perfil do caminhante aparece em muitos lugares e muda sozinho, então vira uma referência (
autorId). - O array cresce sem limite? Um relato tem poucas fotos, e embutir é seguro. Uma trilha popular pode ter dez mil relatos, e embuti-los na trilha seria um erro, porque todo documento tem um teto de 16 MB. Mesmo abaixo desse teto, reescrever um documento gigante a cada relato novo é caro. Relatos, portanto, formam uma coleção própria e apontam para a trilha.
Existe ainda um caminho do meio, a referência estendida. O relato guarda o trilhaId e também uma cópia do nome da trilha. Assim, a lista "meus relatos" não precisa consultar outra fonte só para mostrar o nome. O custo é que, se o nome da trilha mudar, as cópias precisam ser atualizadas. É uma troca deliberada, e é exatamente o que a normalização do relacional evita.
No nosso projeto a trilha continua no PostgreSQL de "Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays", e os relatos vão para o MongoDB. O trilhaId do relato é o id da tabela trilha. Dois bancos, cada um com o que faz melhor: é a chamada persistência poliglota. No fim da aula vamos discutir quando isso vale a pena.
Subindo o MongoDB e o mongosh
O servidor do MongoDB se chama mongod, e o cliente de linha de comando é o mongosh. Há dois caminhos, e os dois terminam num servidor escutando na porta 27017 da sua máquina.
Com o MongoDB instalado. O MongoDB Community Server vem do site oficial, no Windows; do Homebrew, no macOS; e do repositório da própria MongoDB, nas distribuições Linux. O mongosh é um pacote à parte, com os mesmos caminhos. Instalado, o servidor roda como serviço e, em localhost, não pede usuário nem senha:
# Linux (pacote mongodb-org)
sudo systemctl start mongod
# macOS (Homebrew)
brew services start mongodb-community
# Windows: o instalador já registra o serviço; depois, em qualquer sistema
mongosh
Os exemplos desta aula foram executados assim, sem contêiner, com o MongoDB 8.3.1 e o mongosh 2.8.3.
Com Docker (que estudaremos de verdade em "Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy"):
docker run -d --name mongo -p 27017:27017 mongo:8
npm install -g mongosh # ou baixe o mongosh do site oficial
mongosh mongodb://localhost:27017
O mongosh é um shell JavaScript. Tudo o que digitamos nele é JavaScript que conversa com o servidor. Vamos carregar os dados a partir de um arquivo, carga.js:
// carga.js — os relatos dos caminhantes e os perfis de quem escreve
db = db.getSiblingDB("trilhas_da_ilha");
db.relatos.drop();
db.caminhantes.drop();
db.caminhantes.insertMany([
{ _id: "ana", nome: "Ana Souza", cidade: "Florianópolis" },
{ _id: "bruno", nome: "Bruno Lima", cidade: "Palhoça" },
{ _id: "diego", nome: "Diego Ramos", cidade: "São José" }
]);
db.relatos.insertMany([
{ trilhaId: 1, trilha: "Lagoinha do Leste", autorId: "ana",
data: new Date("2026-08-02"), nota: 5, texto: "A vista compensa cada passo.",
etiquetas: ["mirante", "praia"],
condicoes: { lama: "pouca", agua: false },
trechos: [ { de: "Pântano do Sul", ate: "Mirante", minutos: 55 },
{ de: "Mirante", ate: "Lagoa", minutos: 35 } ],
fotos: [ { arquivo: "lagoinha-mirante.jpg", legenda: "Do alto do morro" } ] },
{ trilhaId: 1, trilha: "Lagoinha do Leste", autorId: "bruno",
data: new Date("2026-08-15"), nota: 4, texto: "Fui cedo, pouca gente na trilha.",
etiquetas: ["praia", "camping"],
condicoes: { lama: "muita", agua: false, observacao: "choveu na véspera" },
trechos: [ { de: "Pântano do Sul", ate: "Mirante", minutos: 70 },
{ de: "Mirante", ate: "Lagoa", minutos: 40 } ],
fotos: [] },
{ trilhaId: 3, trilha: "Morro da Coroa", autorId: "diego",
data: new Date("2026-06-11"), nota: 3, texto: "Muito barro.",
etiquetas: ["cachoeira", "mata"],
condicoes: { lama: "muita", agua: true },
trechos: [ { de: "Portão da reserva", ate: "Cachoeira", minutos: 120 } ],
fotos: [ { arquivo: "coroa-cachoeira.jpg" } ] },
{ trilhaId: 2, trilha: "Costão do Santinho", autorId: "ana",
data: new Date("2026-09-06"), nota: 5, texto: "As inscrições rupestres são incríveis.",
etiquetas: ["arqueologia", "praia"],
condicoes: { agua: true } } // sem trechos e sem fotos: e está tudo bem
]);
print("relatos:", db.relatos.countDocuments(), "caminhantes:", db.caminhantes.countDocuments());
$ mongosh --quiet mongodb://localhost:27017 carga.js
relatos: 4 caminhantes: 3
Repare no que não fizemos. Não houve CREATE TABLE: a coleção nasce no primeiro insert. Os quatro documentos não têm a mesma forma: o último não tem trechos nem fotos, e só o de Bruno tem observacao. Os relatos não têm _id explícito, então o driver gerou um ObjectId para cada um. Esse identificador tem 12 bytes, começa com o horário de criação e é único sem precisar consultar o servidor. Nos caminhantes escolhemos um _id legível, o login.
Essa liberdade tem um preço. O banco não impede um relato com nota: "cinco". Quem garante a forma dos dados passa a ser a aplicação, e, se você quiser, um validador de esquema, que veremos adiante.
Consultando: filtros são documentos
No MongoDB, a consulta é ela mesma um documento, e ele descreve o que você procura. Veja consultas.js:
db = db.getSiblingDB("trilhas_da_ilha");
const mostrar = (titulo, cursor) => { print("--- " + titulo); cursor.forEach(d => print(EJSON.stringify(d, { relaxed: true }))); };
mostrar("1. relatos da Lagoinha (trilhaId 1), mais recentes primeiro",
db.relatos.find({ trilhaId: 1 }, { _id: 0, autorId: 1, nota: 1, data: 1 }).sort({ data: -1 }));
mostrar("2. quem pegou muita lama (campo aninhado)",
db.relatos.find({ "condicoes.lama": "muita" }, { _id: 0, trilha: 1, autorId: 1 }).sort({ data: 1 }));
mostrar("3. relatos com pelo menos uma foto",
db.relatos.find({ "fotos.0": { $exists: true } }, { _id: 0, trilha: 1, autorId: 1 }).sort({ data: 1 }));
mostrar("4. algum trecho com mais de uma hora",
db.relatos.find({ trechos: { $elemMatch: { minutos: { $gt: 60 } } } }, { _id: 0, trilha: 1, autorId: 1 }).sort({ data: 1 }));
mostrar("5. etiquetas praia E camping",
db.relatos.find({ etiquetas: { $all: ["praia", "camping"] } }, { _id: 0, autorId: 1 }));
mostrar("6. nota 5 OU trilha 3",
db.relatos.find({ $or: [{ nota: 5 }, { trilhaId: 3 }] }, { _id: 0, trilha: 1, nota: 1 }).sort({ data: 1 }));
--- 1. relatos da Lagoinha (trilhaId 1), mais recentes primeiro
{"autorId":"bruno","data":{"$date":"2026-08-15T00:00:00Z"},"nota":4}
{"autorId":"ana","data":{"$date":"2026-08-02T00:00:00Z"},"nota":5}
--- 2. quem pegou muita lama (campo aninhado)
{"trilha":"Morro da Coroa","autorId":"diego"}
{"trilha":"Lagoinha do Leste","autorId":"bruno"}
--- 3. relatos com pelo menos uma foto
{"trilha":"Morro da Coroa","autorId":"diego"}
{"trilha":"Lagoinha do Leste","autorId":"ana"}
--- 4. algum trecho com mais de uma hora
{"trilha":"Morro da Coroa","autorId":"diego"}
{"trilha":"Lagoinha do Leste","autorId":"bruno"}
--- 5. etiquetas praia E camping
{"autorId":"bruno"}
--- 6. nota 5 OU trilha 3
{"trilha":"Morro da Coroa","nota":3}
{"trilha":"Lagoinha do Leste","nota":5}
{"trilha":"Costão do Santinho","nota":5}
O find recebe dois argumentos: o filtro e a projeção, que diz quais campos voltam (1 inclui, 0 exclui; o _id vem sempre, a menos que você o exclua). Operadores começam com $: $gt, $gte, $lt, $in, $exists, $or, $all. A notação de ponto ("condicoes.lama") entra em objetos aninhados. Com um número, ela entra em uma posição de array: "fotos.0" existe quando o array tem pelo menos um elemento.
Um detalhe elegante: filtrar um array por um valor significa "contém". { etiquetas: "praia" } encontra todo relato cujo array etiquetas inclui "praia". É o = ANY(etiquetas) do PostgreSQL, só que sem precisar pedir.
Agora uma armadilha que derruba muita gente. A notação de ponto aplicada a arrays de objetos testa cada condição separadamente, em qualquer elemento. Veja estas duas consultas:
// "algum trecho começa no Mirante" E "algum trecho (talvez outro!) passa de 60 min"
db.relatos.find({ "trechos.de": "Mirante", "trechos.minutos": { $gt: 60 } })
// → Bruno. O trecho que começa no Mirante levou 40 min; o de 70 min é outro.
// "algum trecho que, ELE MESMO, começa no Mirante e passa de 60 min"
db.relatos.find({ trechos: { $elemMatch: { de: "Mirante", minutos: { $gt: 60 } } } })
// → nenhum
A primeira devolve o relato de Bruno, porque cada condição foi satisfeita por um trecho diferente. Quando as condições precisam valer no mesmo elemento, use $elemMatch. É o mesmo tipo de erro de um JOIN mal escrito: a consulta roda e devolve algo plausível, mas errado.
Alterando: operadores atômicos
No relacional, o UPDATE troca valores de colunas. No MongoDB você manda operadores de atualização, e o servidor aplica todos eles ao documento de forma atômica: ninguém vê o documento pela metade. Veja alteracoes.js:
db = db.getSiblingDB("trilhas_da_ilha");
const filtro = { trilhaId: 3, autorId: "diego" };
// Vários operadores, um documento, uma operação atômica
let r = db.relatos.updateOne(filtro, {
$inc: { curtidas: 1 }, // cria o campo se não existir
$set: { "condicoes.lama": "moderada" }, // altera só o campo aninhado
$push: { comentarios: { autorId: "ana", texto: "Fui semana passada, já secou.",
em: new Date("2026-06-20") } }
});
print("encontrados:", r.matchedCount, "alterados:", r.modifiedCount);
printjson(db.relatos.findOne(filtro, { _id: 0, curtidas: 1, condicoes: 1, comentarios: 1 }));
// $addToSet não duplica; $pull remove do array
db.relatos.updateOne(filtro, { $addToSet: { etiquetas: "mata" } }); // já existe: nada muda
db.relatos.updateOne(filtro, { $addToSet: { etiquetas: "barro" } });
db.relatos.updateOne(filtro, { $pull: { etiquetas: "cachoeira" } });
printjson(db.relatos.findOne(filtro, { _id: 0, etiquetas: 1 }));
// updateMany: um campo novo em todos os relatos que ainda não o têm
r = db.relatos.updateMany({ curtidas: { $exists: false } }, { $set: { curtidas: 0 } });
print("updateMany alterou:", r.modifiedCount);
encontrados: 1 alterados: 1
{
condicoes: {
lama: 'moderada',
agua: true
},
comentarios: [
{
autorId: 'ana',
texto: 'Fui semana passada, já secou.',
em: ISODate('2026-06-20T00:00:00.000Z')
}
],
curtidas: 1
}
{
etiquetas: [
'mata',
'barro'
]
}
updateMany alterou: 3
O $inc merece atenção especial. Em "Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays" medimos a atualização perdida: duas threads leem o contador, somam 1 e gravam, e uma das somas some. Com $inc isso não acontece, porque a soma é feita dentro do servidor, em uma operação atômica sobre o documento. É o equivalente ao UPDATE ... SET visitas = visitas + 1, e é o jeito certo de contar curtidas. Ler o documento em Java, somar e gravar de volta traria o bug de volta.
A atomicidade vale para um documento. Para alterar vários documentos de forma atômica, o MongoDB oferece transações multidocumento, mas elas exigem que o servidor rode como replica set (um conjunto de réplicas, mesmo que de um só nó). Um mongod isolado não aceita transações. Na prática, um bom desenho de documentos faz a maior parte das alterações caber em um documento só. Esse é outro motivo para embutir o que muda junto.
Índices: as mesmas ideias, outra sintaxe
Sem índice, toda consulta é um COLLSCAN, a leitura da coleção inteira, como o Seq Scan do PostgreSQL. O índice é uma árvore B, como nos bancos relacionais. Veja indices.js:
db = db.getSiblingDB("trilhas_da_ilha");
print(db.relatos.createIndex({ trilhaId: 1, data: -1 })); // "relatos da trilha X, mais recentes"
print(db.relatos.createIndex({ etiquetas: 1 })); // multichave: uma entrada por etiqueta
print(db.relatos.createIndex({ trilhaId: 1, autorId: 1, data: 1 }, { unique: true }));
try {
db.relatos.insertOne({ trilhaId: 1, autorId: "ana", data: new Date("2026-08-02"), nota: 1, texto: "duplicado" });
} catch (e) {
print("recusado:", e.code);
}
trilhaId_1_data_-1
etiquetas_1
trilhaId_1_autorId_1_data_1
recusado: 11000
Três ideias aparecem aqui.
- Índice multichave. Quando o campo é um array, o índice ganha uma entrada por elemento. É isso que torna
{ etiquetas: "praia" }rápido, o papel que o GIN tinha no PostgreSQL. - Unicidade. O índice único garante uma regra de negócio: um caminhante escreve um relato por trilha por dia. O código
11000(duplicate key) é o primo do SQLState23505do PostgreSQL. - Ordem dos campos no índice composto. A documentação do MongoDB resume a regra como ESR: primeiro os campos de igualdade, depois os de ordenação (sort), por fim os de intervalo (range). Para "relatos da trilha 1 com nota ≥ 4, mais recentes primeiro", o índice ideal é
{ trilhaId: 1, data: -1, nota: 1 }. A igualdade estreita o trecho da árvore, a ordenação sai de graça da própria ordem da árvore e o intervalo filtra o que sobra.
Para conferir se um índice foi usado, peça o plano:
db.relatos.find({ trilhaId: 1 }).sort({ data: -1 }).explain("executionStats")
Procure IXSCAN (usou índice) ou COLLSCAN (varreu tudo). Compare também totalDocsExamined com nReturned. Se o banco examinou dez mil documentos para devolver dez, o índice está faltando ou está errado. É o EXPLAIN ANALYZE do PostgreSQL com outro formato.
Agregações: uma linha de montagem
Para relatórios, o MongoDB oferece o pipeline de agregação: uma lista de estágios em que cada um recebe os documentos do anterior e passa adiante os seus. Quem fez "Pare de Escrever Laços — a API de Streams" vai se sentir em casa: $match é filter, $project é map, $group é Collectors.groupingBy, $sort e $limit são o que parecem. Veja agregacoes.js:
db = db.getSiblingDB("trilhas_da_ilha");
const mostrar = (titulo, cursor) => { print("--- " + titulo); cursor.forEach(d => print(EJSON.stringify(d))); };
mostrar("A. nota média e quantidade de relatos por trilha", db.relatos.aggregate([
{ $group: { _id: "$trilhaId", relatos: { $sum: 1 }, media: { $avg: "$nota" } } },
{ $sort: { media: -1, _id: 1 } }
]));
mostrar("B. etiquetas mais citadas", db.relatos.aggregate([
{ $unwind: "$etiquetas" },
{ $group: { _id: "$etiquetas", vezes: { $sum: 1 } } },
{ $sort: { vezes: -1, _id: 1 } },
{ $limit: 3 }
]));
mostrar("C. tempo médio de cada trecho", db.relatos.aggregate([
{ $unwind: "$trechos" },
{ $group: { _id: { de: "$trechos.de", ate: "$trechos.ate" },
mediaMin: { $avg: "$trechos.minutos" }, amostras: { $sum: 1 } } },
{ $sort: { "_id.de": 1 } }
]));
mostrar("D. $lookup: o nome de quem escreveu", db.relatos.aggregate([
{ $match: { trilhaId: 1 } },
{ $lookup: { from: "caminhantes", localField: "autorId", foreignField: "_id", as: "autor" } },
{ $unwind: "$autor" },
{ $project: { _id: 0, nota: 1, autor: "$autor.nome", cidade: "$autor.cidade" } },
{ $sort: { autor: 1 } }
]));
--- A. nota média e quantidade de relatos por trilha
{"_id":2,"relatos":1,"media":5}
{"_id":1,"relatos":2,"media":4.5}
{"_id":3,"relatos":1,"media":3}
--- B. etiquetas mais citadas
{"_id":"praia","vezes":3}
{"_id":"arqueologia","vezes":1}
{"_id":"barro","vezes":1}
--- C. tempo médio de cada trecho
{"_id":{"de":"Mirante","ate":"Lagoa"},"mediaMin":37.5,"amostras":2}
{"_id":{"de":"Portão da reserva","ate":"Cachoeira"},"mediaMin":120,"amostras":1}
{"_id":{"de":"Pântano do Sul","ate":"Mirante"},"mediaMin":62.5,"amostras":2}
--- D. $lookup: o nome de quem escreveu
{"nota":5,"autor":"Ana Souza","cidade":"Florianópolis"}
{"nota":4,"autor":"Bruno Lima","cidade":"Palhoça"}
Algumas observações sobre os estágios:
$group: o_iddo grupo é a chave de agrupamento. Pode ser um campo ("$trilhaId", com$, que significa "o valor deste campo") ou um objeto inteiro, como em C, onde o par origem–destino forma a chave. Os acumuladores ($sum,$avg,$min,$max,$push) calculam o resto.$unwind: "desenrola" um array, transformando um documento com três etiquetas em três documentos com uma etiqueta cada. É oflatMapdas Streams. Documentos sem o campo (como o relato da Ana semtrechos) simplesmente não saem do$unwind.- A ordenação de C: "Portão" vem antes de "Pântano" porque, sem uma collation definida, strings são comparadas byte a byte, e o
âem UTF-8 vale mais que oo. É o mesmo problema de collation de "Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam", com a mesma solução: declarar uma collation ({ locale: "pt" }) na consulta, no índice ou na coleção. $lookup: é oLEFT JOINdo MongoDB. Ele existe e funciona bem, mas, se você precisa dele em toda consulta, talvez devesse ter embutido, ou talvez esses dados sejam relacionais. Guarde essa pergunta para o fim da aula.- A ordem dos estágios: ponha o
$matcho mais cedo possível. Um$matchno início do pipeline usa índices; depois de um$group, ele filtra documentos já calculados, em memória.
O driver oficial em Java
Com Maven, a dependência é org.mongodb:mongodb-driver-sync, da linha 5.x. Se você usar o Spring Boot, ele já gerencia a versão. O driver tem duas camadas: Document, que é um Map<String, Object> ordenado e aceita qualquer forma, e classes auxiliares (Filters, Updates, Sorts, Projections, Aggregates, Accumulators) que montam os documentos de filtro sem que você digite $ em strings.
Seguindo o que aprendemos com o JDBC, o resto da aplicação não deve saber que existe MongoDB. Tudo passa por um repositório, RelatosNoMongo.java:
import com.mongodb.ErrorCategory;
import com.mongodb.MongoWriteException;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.IndexOptions;
import com.mongodb.client.model.Indexes;
import org.bson.Document;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.mongodb.client.model.Accumulators.avg;
import static com.mongodb.client.model.Accumulators.sum;
import static com.mongodb.client.model.Aggregates.group;
import static com.mongodb.client.model.Aggregates.sort;
import static com.mongodb.client.model.Filters.and;
import static com.mongodb.client.model.Filters.eq;
import static com.mongodb.client.model.Projections.excludeId;
import static com.mongodb.client.model.Projections.fields;
import static com.mongodb.client.model.Projections.include;
import static com.mongodb.client.model.Sorts.ascending;
import static com.mongodb.client.model.Sorts.descending;
import static com.mongodb.client.model.Sorts.orderBy;
import static com.mongodb.client.model.Updates.combine;
import static com.mongodb.client.model.Updates.inc;
import static com.mongodb.client.model.Updates.push;
/** O que a página da trilha mostra de cada relato. */
record Relato(String autorId, LocalDate data, int nota, String texto) {
/** Document → record: o equivalente ao RowMapper do JDBC. */
static Relato de(Document d) {
return new Relato(
d.getString("autorId"),
d.getDate("data").toInstant().atZone(ZoneOffset.UTC).toLocalDate(),
d.get("nota", Number.class).intValue(), // aceita Int32, Int64 ou Double
d.getString("texto"));
}
}
/** Tudo que a aplicação faz com relatos passa por aqui; o resto do código nem sabe que é MongoDB. */
final class RepositorioDeRelatos {
private final MongoCollection<Document> relatos;
RepositorioDeRelatos(MongoCollection<Document> relatos) {
this.relatos = relatos;
// Idempotente: se o índice já existe com a mesma definição, nada acontece
relatos.createIndex(Indexes.compoundIndex(Indexes.ascending("trilhaId"), Indexes.descending("data")));
relatos.createIndex(Indexes.ascending("trilhaId", "autorId", "data"), new IndexOptions().unique(true));
}
List<Relato> ultimos(long trilhaId, int limite) {
return relatos.find(eq("trilhaId", trilhaId)) // Int64 no filtro casa com Int32 no banco
.projection(fields(include("autorId", "data", "nota", "texto"), excludeId()))
.sort(descending("data"))
.limit(limite)
.map(Relato::de)
.into(new ArrayList<>());
}
/** trilhaId → nota média. A mesma pipeline A do mongosh. */
Map<Long, Double> mediaPorTrilha() {
var medias = new LinkedHashMap<Long, Double>();
for (Document d : relatos.aggregate(List.of(
group("$trilhaId", sum("relatos", 1), avg("media", "$nota")),
sort(orderBy(descending("media"), ascending("_id")))))) {
medias.put(d.get("_id", Number.class).longValue(), d.getDouble("media"));
}
return medias;
}
boolean curtir(long trilhaId, String autorId, LocalDate data, String quemCurtiu) {
var filtro = and(eq("trilhaId", trilhaId), eq("autorId", autorId), eq("data", paraDate(data)));
var r = relatos.updateOne(filtro, combine(inc("curtidas", 1), push("curtiramPor", quemCurtiu)));
return r.getMatchedCount() == 1;
}
/** Grava um relato novo; devolve false se o mesmo autor já relatou essa trilha nesse dia. */
boolean publicar(long trilhaId, String nomeDaTrilha, String autorId, LocalDate data, int nota, String texto) {
try {
relatos.insertOne(new Document("trilhaId", trilhaId)
.append("trilha", nomeDaTrilha)
.append("autorId", autorId)
.append("data", paraDate(data))
.append("nota", nota)
.append("texto", texto)
.append("curtidas", 0));
return true;
} catch (MongoWriteException e) {
if (e.getError().getCategory() == ErrorCategory.DUPLICATE_KEY) return false;
throw e;
}
}
private static Date paraDate(LocalDate d) {
return Date.from(d.atStartOfDay(ZoneOffset.UTC).toInstant());
}
}
void main() {
String uri = System.getenv().getOrDefault("MONGO_URI", "mongodb://localhost:27017");
// MongoClient é caro e thread-safe: um por aplicação, fechado no fim
try (MongoClient cliente = MongoClients.create(uri)) {
var repo = new RepositorioDeRelatos(cliente.getDatabase("trilhas_da_ilha").getCollection("relatos"));
repo.ultimos(1, 10).forEach(r -> IO.println(r.data() + " " + r.autorId() + " " + "★".repeat(r.nota())));
IO.println(repo.mediaPorTrilha());
IO.println("curtiu? " + repo.curtir(1, "ana", LocalDate.of(2026, 8, 2), "diego"));
IO.println("publicou? " + repo.publicar(4, "Praia do Saquinho", "bruno",
LocalDate.of(2026, 9, 20), 5, "Trilha curta, ótima com criança."));
IO.println("publicou de novo? " + repo.publicar(4, "Praia do Saquinho", "bruno",
LocalDate.of(2026, 9, 20), 5, "Trilha curta, ótima com criança."));
}
}
Rode sobre os dados da carga, antes das alterações:
2026-08-15 bruno ★★★★
2026-08-02 ana ★★★★★
{2=5.0, 1=4.5, 3=3.0}
curtiu? true
publicou? true
publicou de novo? false
Há quatro coisas para ler com calma nesse código.
MongoClient é o pool. Ele mantém as conexões, descobre as réplicas e é thread-safe. Crie um por aplicação, como o DataSource do HikariCP, e feche-o no fim. Um cliente por requisição é o erro de desempenho mais comum com o driver.
Tipos numéricos são uma armadilha real. No mongosh, nota: 5 virou Int32, porque o shell grava como inteiro de 32 bits todo número JavaScript sem casas decimais. Isso vale até para 5.0, já que em JavaScript 5.0 e 5 são o mesmo número. Já o trilhaId que o Java grava a partir de um long vira Int64. Se o código Java usasse d.getDouble("distanciaKm") e o documento tivesse sido gravado pelo shell com 5.0, o resultado seria uma ClassCastException. Por isso lemos com d.get(campo, Number.class), que aceita qualquer tipo numérico. Nas comparações o MongoDB é tolerante: eq("trilhaId", 1L) encontra o 1 Int32, porque números são comparados pelo valor. Para gravar um double pelo shell, use Double(5.0).
Datas são instantes. O BSON guarda datas como milissegundos em UTC, sem fuso. Uma LocalDate não tem hora nem fuso, então você escolhe a convenção. Aqui usamos meia-noite UTC, a mesma do new Date("2026-08-02") do mongosh. Se cada parte do sistema adotar uma convenção diferente, o relato "do dia 2" aparece no dia 1 para quem está em Florianópolis. É o mesmo cuidado de "Tempo, Dinheiro e Padrões — java.time, BigDecimal e Expressões Regulares".
O erro de duplicidade é um resultado esperado. O índice único transforma "relato repetido" em uma MongoWriteException com categoria DUPLICATE_KEY, que o repositório converte em false. Consultar antes para ver se o relato já existe e só então inserir teria uma janela de corrida entre as duas operações. O índice fecha essa janela, como o UNIQUE fazia no relacional.
O driver também sabe mapear documentos direto para records e POJOs (getCollection("relatos", Relato.class), com @BsonId e @BsonProperty nos componentes). Preferi a conversão manual porque ela deixa visíveis as armadilhas de tipo e de data. Em código de produção, os dois caminhos são legítimos.
Com Spring Data MongoDB
O Spring Data da aula "Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway" tem um módulo para MongoDB, e a experiência é quase a mesma. Primeiro a dependência:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
A conexão fica em application.properties. No Spring Boot 4 as propriedades de conexão saíram de spring.data.mongodb.* e foram para spring.mongodb.*:
spring.mongodb.uri=mongodb://localhost:27017/trilhas_da_ilha
O documento vira um record, e o repositório vira uma interface:
package br.com.exemplo.trilhas.relatos;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Document("relatos")
public record RelatoDocumento(
@Id String id, // o ObjectId, exposto como String
long trilhaId,
String trilha,
String autorId,
Instant data, // Instant, não LocalDate: evita a conversão pelo fuso da JVM
int nota,
String texto,
List<String> etiquetas,
Map<String, Object> condicoes, // forma livre de propósito
List<Trecho> trechos) {
public record Trecho(String de, String ate, int minutos) {}
/** Records são imutáveis; o Spring Data usa este "wither" para devolver o documento com o id gerado. */
public RelatoDocumento withId(String novoId) {
return new RelatoDocumento(novoId, trilhaId, trilha, autorId, data, nota, texto, etiquetas, condicoes, trechos);
}
}
package br.com.exemplo.trilhas.relatos;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List;
public interface RelatosNoMongo extends MongoRepository<RelatoDocumento, String> {
List<RelatoDocumento> findTop10ByTrilhaIdOrderByDataDesc(long trilhaId);
List<RelatoDocumento> findByEtiquetas(String etiqueta); // em arrays, "igual" já é "contém"
@Query("{ 'condicoes.lama': ?0 }")
List<RelatoDocumento> comLama(String nivel);
}
Para agregações existe o MongoTemplate, com uma API que espelha os estágios do pipeline:
public record MediaDaTrilha(@Id long trilhaId, int relatos, double media) {}
List<MediaDaTrilha> medias = mongoTemplate.aggregate(
Aggregation.newAggregation(
Aggregation.group("trilhaId").count().as("relatos").avg("nota").as("media"),
Aggregation.sort(Sort.Direction.DESC, "media")),
"relatos", MediaDaTrilha.class).getMappedResults();
Duas diferenças em relação ao JPA merecem registro. Não há Flyway: a coleção não tem esquema, e os índices você cria explicitamente. Pode ser com mongoTemplate.indexOps(...) na inicialização ou com @Indexed somado a spring.data.mongodb.auto-index-creation=true, opção que vem desligada por padrão para que um deploy não comece a construir índices enormes sem aviso. O @Transactional só funciona se você declarar um MongoTransactionManager e o servidor for um replica set. Sem isso, cada operação é atômica sozinha, e nada mais.
Um pouco de esquema, afinal
"Sem esquema" significa que o banco não exige um esquema. Mas nada impede você de declarar um. O MongoDB aceita um validador em JSON Schema por coleção:
db.runCommand({
collMod: "relatos",
validator: { $jsonSchema: {
bsonType: "object",
required: ["trilhaId", "autorId", "data", "nota", "texto"],
properties: {
trilhaId: { bsonType: ["int", "long"] },
autorId: { bsonType: "string" },
data: { bsonType: "date" },
nota: { bsonType: ["int", "long"], minimum: 1, maximum: 5 },
texto: { bsonType: "string", maxLength: 5000 },
etiquetas: { bsonType: "array", maxItems: 10, items: { bsonType: "string" } }
}
} },
validationLevel: "moderate" // valida inserções e documentos que já eram válidos
})
A partir daí, um insert com nota: "cinco" é recusado pelo servidor. Continua havendo liberdade para campos opcionais e variáveis, como condicoes e fotos, mas o núcleo do documento passa a ter garantias. Na prática, é o melhor dos dois mundos: um contrato rígido onde ele importa e flexibilidade no resto.
Quando escolher cada modelo
Agora a pergunta do título, com honestidade. As diferenças práticas entre os dois modelos:
| Critério | Relacional (PostgreSQL, MySQL) | Documentos (MongoDB) |
|---|---|---|
| Forma dos dados | regular, repetida | variável, aninhada |
| Como os dados são lidos | por muitos ângulos, com consultas imprevistas | quase sempre como um agregado inteiro |
| Integridade | FK, CHECK, UNIQUE, transações amplas |
por documento, UNIQUE e validador |
| Relações | muitas e centrais (JOIN é barato) |
poucas ($lookup existe, mas não é o forte) |
| Evolução do esquema | migrations (Flyway) | a aplicação convive com versões diferentes |
| Escala horizontal | possível, com mais trabalho | sharding nativo |
Algumas conclusões dessa tabela:
- O padrão, na dúvida, é relacional. Dados de negócio costumam ser relacionais (usuários, trilhas, reservas, pagamentos), e consultas que ninguém previu aparecem todo mês. O SQL responde a elas, e o modelo de documentos, desenhado para as consultas que você previu, sofre com as outras.
- Documentos brilham com agregados autocontidos e heterogêneos: relatos, catálogos de produtos com atributos que variam por categoria, eventos, logs, configurações, conteúdo de CMS. Também brilham quando o volume de escrita pede sharding desde o início.
- O PostgreSQL com JSONB já cobre boa parte disso. Os relatos caberiam em uma tabela com uma coluna
JSONBe um índice GIN, como fizemos com osdetalhesda trilha. Muitas equipes param aí, e estão certas. - Dois bancos custam caro. Persistência poliglota significa dois backups, dois monitoramentos, duas coisas para atualizar e, principalmente, nenhuma transação atravessando os dois. Se a trilha 3 for apagada no PostgreSQL, seus relatos continuam no MongoDB até que alguém os apague. A aplicação precisa tratar disso: apagar os relatos depois do commit no PostgreSQL e ter uma rotina que limpa órfãos se essa segunda etapa falhar. Existe até um meio-termo curioso: o FerretDB, um projeto de código aberto que fala o protocolo do MongoDB e grava no PostgreSQL.
Nas Trilhas da Ilha usamos os dois porque o curso quer que você conheça os dois de verdade, e porque os relatos são, de fato, um bom caso de documento. Em um projeto real, a pergunta a fazer é: "o que eu ganho com o segundo banco compensa o que eu pago por ele?". Às vezes a resposta é sim.
Fontes e leituras recomendadas
- MongoDB Manual — Data Modeling: embutir ou referenciar, padrões de modelagem e o limite de 16 MB.
- MongoDB Manual — Aggregation Operations: todos os estágios e acumuladores do pipeline.
- The ESR (Equality, Sort, Range) Guideline: como ordenar os campos de um índice composto.
- MongoDB Java Sync Driver: o driver usado nesta aula, com builders, POJOs e records.
- Spring Data MongoDB — Reference: repositórios,
MongoTemplate, agregações e transações. - Designing Data-Intensive Applications, de Martin Kleppmann: o capítulo sobre modelos de dados compara os modelos relacional e de documentos melhor do que qualquer documentação de fornecedor.
Exercícios
Exercício 1
A trilha "Lagoinha do Leste" foi renomeada para "Lagoinha do Leste (Pântano do Sul)". O que precisa acontecer na coleção relatos, e com qual comando?
Ver resposta
✓ Resposta: Como cada relato guarda uma cópia do nome (a referência estendida), todas as cópias precisam ser atualizadas: db.relatos.updateMany({ trilhaId: 1 }, { $set: { trilha: "Lagoinha do Leste (Pântano do Sul)" } }). Esse é o custo da cópia. Como a atualização acontece em outro banco, e não na mesma transação do UPDATE no PostgreSQL, a aplicação precisa fazê-la depois do commit e tolerar uma janela curta em que os dois bancos discordam.
Exercício 2
Escreva uma consulta que encontre os relatos em que algum trecho que termina na Lagoa levou menos de 40 minutos. Explique por que { "trechos.ate": "Lagoa", "trechos.minutos": { $lt: 40 } } está errada.
Ver resposta
✓ Resposta: db.relatos.find({ trechos: { $elemMatch: { ate: "Lagoa", minutos: { $lt: 40 } } } }) devolve só o relato da Ana (35 min). A versão com notação de ponto aceita que cada condição seja satisfeita por um elemento diferente do array: "algum trecho termina na Lagoa" e "algum trecho (talvez outro) levou menos de 40". Com mais dados, isso produziria resultados falsos. $elemMatch exige as duas condições no mesmo trecho.
Exercício 3
Monte um pipeline que devolva, para cada caminhante, quantos relatos escreveu e a maior nota que deu, com o nome do caminhante (não o login), em ordem alfabética.
Ver resposta
✓ Resposta:
db.relatos.aggregate([
{ $group: { _id: "$autorId", relatos: { $sum: 1 }, maiorNota: { $max: "$nota" } } },
{ $lookup: { from: "caminhantes", localField: "_id", foreignField: "_id", as: "c" } },
{ $unwind: "$c" },
{ $project: { _id: 0, nome: "$c.nome", relatos: 1, maiorNota: 1 } },
{ $sort: { nome: 1 } }
])
Agrupar antes do $lookup é intencional: assim a junção é feita uma vez por caminhante, e não uma vez por relato.
Exercício 4
A página "minhas trilhas" consulta { autorId: "ana", nota: { $gte: 4 } } ordenado por data decrescente. Qual índice composto você criaria, e por quê?
Ver resposta
✓ Resposta: { autorId: 1, data: -1, nota: 1 }, seguindo a regra ESR. autorId é igualdade e vem primeiro, porque isola o trecho da árvore da Ana. data é a ordenação e vem em seguida, para que os documentos já saiam em ordem, sem ordenação em memória. nota é intervalo e vem por último, filtrando dentro do trecho já ordenado. Com { autorId: 1, nota: 1, data: -1 } o intervalo viria antes da ordenação, e o banco teria de ordenar o resultado depois.
Exercício 5
Um colega propõe guardar os relatos dentro do documento da trilha, num array relatos, "para não precisar de $lookup". Dê dois argumentos contra, e diga em que situação a ideia seria razoável.
Ver resposta
✓ Resposta: Primeiro, o array cresce sem limite, e uma trilha popular se aproxima do teto de 16 MB por documento. Muito antes disso, cada relato novo reescreveria um documento enorme. Segundo, relatos são lidos de formas próprias (por autor, por data, paginados), e dentro do array isso vira $unwind em tudo, sem índices eficientes para paginar. A ideia seria razoável com um conjunto pequeno e limitado de dados lidos sempre com a trilha. Um bom exemplo são os três relatos mais recentes, embutidos como resumo (o chamado "subset pattern"), enquanto a coleção completa continua separada.