MongoDB: o Modelo de Documentos e o Driver Oficial

MongoDB: o Modelo de Documentos e o Driver Oficial

O modelo de documentos do MongoDB com o driver oficial para C#: incorporar ou referenciar, mapeamento com Decimal128 e convenções, hierarquias com discriminador, contadores atômicos, builders e LINQ, injeção de operadores e a venda à prova de corridas num único documento.
Linguagem C#

• • 21 min de leitura

Em vez de espalhar um pedido por várias tabelas e remontá-lo com junções, um banco de documentos o guarda inteiro, do jeito que o programa o enxerga.

"MySQL: MySqlConnector, o Provider do EF e as Diferenças Que Importam" terminou com uma ideia em aberto. Todos os bancos que vimos até aqui (SQLite, PostgreSQL e MySQL) compartilham o modelo relacional: tabelas com colunas fixas, relacionamentos por chaves e consultas em SQL. Para gravar um pedido, o EF Core distribuiu os dados entre a tabela de pedidos e a de itens e, para lê-lo de volta, precisou juntá-las. Existe outra família de bancos de dados, construída sobre uma premissa diferente: guardar cada entidade como um documento completo, com seus dados aninhados. O representante mais conhecido dessa família é o MongoDB. Nesta aula conhecemos o modelo de documentos e o driver oficial para C#, e levamos a Livraria para um banco sem tabelas e sem SQL.

Um pouco de história

O MongoDB foi criado em 2007 pela empresa 10gen, fundada por Dwight Merriman, Eliot Horowitz e Kevin Ryan, que vinham da DoubleClick, uma empresa de publicidade on-line que lidava com volumes enormes de dados. A primeira versão pública saiu em 2009, e o nome vem de humongous, "gigantesco" em inglês coloquial. Em 2013 a empresa passou a se chamar MongoDB Inc.

O MongoDB surgiu na onda dos bancos NoSQL do fim dos anos 2000, uma reação às dificuldades de escalar bancos relacionais para aplicações web gigantescas e de acomodar dados cuja estrutura muda o tempo todo. Uma nota honesta sobre licenciamento: desde 2018, o servidor do MongoDB é distribuído sob a SSPL, uma licença criada pela própria empresa, que não é reconhecida como licença de código aberto pela Open Source Initiative. O uso em aplicações próprias é livre, mas a licença impõe condições a quem oferece o MongoDB como serviço para terceiros. Antes de adotá-lo num produto, vale entender essas condições. O driver para C#, que usaremos, tem licença Apache 2.0.

O modelo de documentos

No MongoDB, os dados são organizados em:

  • Documentos: registros no formato JSON, com campos, valores, listas e documentos aninhados. Internamente são gravados em BSON, um JSON binário com mais tipos, como datas, inteiros de tamanhos diferentes e decimais exatos.
  • Coleções: conjuntos de documentos, o equivalente aproximado de uma tabela. Mas sem colunas fixas: cada documento de uma coleção pode ter campos diferentes.
  • _id: todo documento tem um campo _id único, a chave primária. Se nenhum for informado, o banco gera um ObjectId, um identificador de 12 bytes que inclui um carimbo de tempo.

Um pedido da Livraria, como documento, tem esta forma:

{
  "_id": 1001,
  "status": "Pago",
  "criadoEm": { "$date": "2026-09-24T18:30:00Z" },
  "itens": [
    { "produtoId": 1, "titulo": "Dom Casmurro", "autor": "Machado de Assis",
      "precoUnitario": { "$numberDecimal": "59.90" }, "quantidade": 2 },
    { "produtoId": 4, "titulo": "Vidas Secas", "autor": "Graciliano Ramos",
      "precoUnitario": { "$numberDecimal": "24.90" }, "quantidade": 1 }
  ],
  "total": { "$numberDecimal": "144.70" }
}

Os itens estão dentro do pedido. Não há tabela de itens, nem chave estrangeira, nem junção: ler o pedido é ler um único documento, e gravá-lo é gravar um único documento. É exatamente a estrutura do objeto Pedido do nosso domínio, com sua lista de itens. Essa correspondência direta entre o documento e o objeto é o principal atrativo do modelo.

Incorporar ou referenciar

A decisão central de modelagem num banco de documentos é o que vai dentro de quê. Incorporar (embed) um dado no documento torna a leitura rápida e a gravação atômica. Referenciar outro documento pelo _id evita duplicação, mas exige uma segunda busca, ou uma junção com o operador $lookup, que existe, mas não é o forte do modelo.

O critério prático:

  • Incorpore o que pertence ao documento e é lido junto com ele. Os itens de um pedido são o caso típico: não existem fora do pedido, como vimos nos tipos próprios de "EF Core: Relacionamentos e Migrations".
  • Referencie o que tem vida própria, é compartilhado por muitos documentos, muda com frequência ou pode crescer sem limite. O estoque de um produto muda a cada venda e não pode ficar copiado dentro de cada pedido.

Repare que os itens do pedido acima guardam o título, o autor e o preço copiados do produto. É a fotografia do momento da compra, a mesma decisão da aula de relacionamentos, e aqui ela tem um efeito extra: os relatórios de vendas não precisam consultar a coleção de produtos. A duplicação deliberada de dados, chamada desnormalização, é uma ferramenta comum em bancos de documentos. Ela é aceitável quando os dados copiados são uma fotografia que não deve mudar, ou quando mudam raramente e você sabe atualizar as cópias.

Um limite técnico ajuda a decidir: um documento BSON tem no máximo 16 MB. Uma lista que pode crescer sem fim, como todas as vendas de um produto ao longo dos anos, não deve ser incorporada.

Executando o MongoDB

O Docker de sempre:

docker run --name livraria-mongo -p 27017:27017 -v livraria-mongo-dados:/data/db -d mongo:8

O cliente de linha de comando, mongosh, vem na imagem (docker exec -it livraria-mongo mongosh), e o MongoDB Compass é a ferramenta gráfica oficial. Para desenvolvimento, o servidor sobe sem autenticação. Em produção, usuários e senhas são obrigatórios, e a cadeia de conexão segue as regras de segredos de "PostgreSQL".

O driver oficial

dotnet add src/Livraria.Dados.Mongo package MongoDB.Driver

O ponto de entrada é o MongoClient:

using MongoDB.Driver;

var cliente = new MongoClient("mongodb://localhost:27017");
IMongoDatabase banco = cliente.GetDatabase("livraria");
IMongoCollection<PedidoDocumento> pedidos = banco.GetCollection<PedidoDocumento>("pedidos");

O MongoClient é seguro entre threads e mantém internamente o pool de conexões. A recomendação oficial é criar um único cliente para toda a aplicação, como o HttpClient de "async e await" e o NpgsqlDataSource do PostgreSQL. Bancos e coleções não precisam ser criados antes: passam a existir quando o primeiro documento é gravado.

Mapeando classes para documentos

O driver converte classes C# em documentos BSON e vice-versa, com base em convenções e atributos. Seguindo a lição de "Arquivos, Streams e JSON", em que objetos de domínio não são formatos de arquivo, criamos classes de documento separadas do domínio, com a forma que queremos no banco:

using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;

public sealed class PedidoDocumento
{
    [BsonId] public int Numero { get; set; }
    public string Status { get; set; } = "Aberto";
    public DateTime CriadoEm { get; set; }
    public List<ItemDocumento> Itens { get; set; } = [];

    [BsonRepresentation(BsonType.Decimal128)]
    public decimal Total { get; set; }
}

public sealed class ItemDocumento
{
    public int ProdutoId { get; set; }
    public string Titulo { get; set; } = "";
    public string Autor { get; set; } = "";

    [BsonRepresentation(BsonType.Decimal128)]
    public decimal PrecoUnitario { get; set; }

    public int Quantidade { get; set; }
}

[BsonId] indica qual propriedade vira o _id. E o [BsonRepresentation(BsonType.Decimal128)] deixa explícita uma decisão que já foi uma armadilha famosa: até a série 2.x, o driver gravava decimal como texto por padrão. Desde a 3.0 o padrão é Decimal128 (medido na 3.12: um decimal sem atributo nenhum chega ao banco como Decimal128('59.90')), mas muito código e muitos bancos em produção ainda vêm da época antiga, e vale saber o que acontecia. Um preço gravado como a string "59.90" até volta corretamente para o C#, mas o banco não sabe tratá-lo como número. Ordenar por preço compara textos, e "100.00" vem antes de "59.90", porque "1" vem antes de "5". Somas e comparações numéricas no banco simplesmente não funcionam. Decimal128 é o decimal exato de 128 bits do BSON, com as mesmas garantias do numeric do PostgreSQL. Para não depender nem do atributo em cada propriedade nem do padrão da versão do driver, registre a representação uma vez, na inicialização:

using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Conventions;
using MongoDB.Bson.Serialization.Serializers;

BsonSerializer.RegisterSerializer(new DecimalSerializer(BsonType.Decimal128));

var convencoes = new ConventionPack
{
    new CamelCaseElementNameConvention(),       // Titulo → "titulo"
    new IgnoreExtraElementsConvention(true),    // tolera campos que a classe não conhece
};
ConventionRegistry.Register("Livraria", convencoes, _ => true);

A segunda convenção merece explicação. Sem ela, ler um documento com um campo que a classe não tem, acrescentado por uma versão mais nova do programa, por exemplo, lança exceção. Com ela, o campo é ignorado. Num banco sem esquema fixo, em que documentos de épocas diferentes convivem na mesma coleção, essa tolerância é essencial.

Datas também merecem cuidado. O BSON guarda instantes em UTC, e o driver converte um DateTime local para UTC ao gravar e devolve UTC ao ler. É a mesma disciplina de "PostgreSQL": grave DateTime.UtcNow e converta para o fuso do usuário só na exibição.

Para hierarquias, como a de produtos, o driver grava um discriminador, um campo _t com o nome do tipo, e usa esse campo para criar a classe certa ao ler, como o tipo do EF Core:

[BsonDiscriminator(RootClass = true)]
[BsonKnownTypes(typeof(LivroImpressoDocumento), typeof(EbookDocumento))]
public abstract class ProdutoDocumento
{
    [BsonId] public int Id { get; set; }
    public string Titulo { get; set; } = "";
    public string Autor { get; set; } = "";
    public decimal Preco { get; set; }
    public string[] Generos { get; set; } = [];
}

public sealed class LivroImpressoDocumento : ProdutoDocumento
{
    public int PesoGramas { get; set; }
    public int Estoque { get; set; }
}

public sealed class EbookDocumento : ProdutoDocumento
{
    public double TamanhoMb { get; set; }
}

Os gêneros ficam como uma lista dentro do documento, com a mesma naturalidade dos arrays do PostgreSQL. E o autor fica incorporado como texto, porque no modelo de documentos o nome do autor é um dado do produto, e não uma entidade separada. Voltaremos a essa decisão no exercício 5.

Identificadores sem autoincremento

O MongoDB não tem autoincremento: o _id padrão é um ObjectId. Para manter os números sequenciais que o domínio usa desde a Fase 2, uma técnica comum é uma coleção de contadores, incrementada de forma atômica:

IMongoCollection<BsonDocument> contadores = banco.GetCollection<BsonDocument>("contadores");

static async Task<int> ProximoNumeroAsync(IMongoCollection<BsonDocument> contadores, string nome)
{
    BsonDocument contador = await contadores.FindOneAndUpdateAsync(
        Builders<BsonDocument>.Filter.Eq("_id", nome),
        Builders<BsonDocument>.Update.Inc("valor", 1),
        new FindOneAndUpdateOptions<BsonDocument> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
    return contador["valor"].AsInt32;
}

FindOneAndUpdate encontra o documento, incrementa o valor e devolve o resultado numa operação atômica. Dois pedidos simultâneos nunca recebem o mesmo número. IsUpsert cria o contador na primeira chamada. É o INSERT ... ON CONFLICT do exercício 5 de "MySQL", na linguagem do MongoDB.

Gravando e consultando

As operações seguem nomes previsíveis:

await pedidos.InsertOneAsync(documento);
await produtos.InsertManyAsync(listaDeProdutos);

Para consultar, o driver oferece duas formas. A primeira são os builders, que montam filtros de forma tipada:

var filtro = Builders<ProdutoDocumento>.Filter.And(
    Builders<ProdutoDocumento>.Filter.Lt(p => p.Preco, 40m),
    Builders<ProdutoDocumento>.Filter.AnyEq(p => p.Generos, "romance"));

List<ProdutoDocumento> baratos = await produtos.Find(filtro)
    .SortBy(p => p.Preco)
    .ToListAsync();

A segunda é o LINQ. O driver traduz consultas LINQ para a linguagem de consultas do MongoDB, como o EF Core as traduz para SQL:

using MongoDB.Driver.Linq;

List<ProdutoDocumento> baratos = await produtos.AsQueryable()
    .Where(p => p.Preco < 40m && p.Generos.Contains("romance"))
    .OrderBy(p => p.Preco)
    .ToListAsync();

Para consultar só um tipo da hierarquia, produtos.OfType<LivroImpressoDocumento>() devolve uma coleção filtrada pelo discriminador.

Nas duas formas, os valores viajam como dados tipados, e não como texto montado. Isso importa, porque o MongoDB tem a sua própria versão do problema de injeção. Se um programa montasse um filtro a partir de um JSON recebido do usuário, uma entrada como { "$ne": null } no lugar de uma senha viraria "senha diferente de nulo", e qualquer senha passaria. É a injeção de operadores, prima do SQL injection de "ADO.NET". A defesa é a mesma: nunca montar consultas a partir de texto externo, e sim com builders ou LINQ, que tratam cada valor como dado.

Atualizações atômicas: a venda, versão documento

Em vez de ler, alterar e regravar um documento inteiro, o MongoDB oferece operadores de atualização que modificam campos específicos no próprio servidor. E, como nos bancos relacionais, a condição pode fazer parte do filtro. A venda à prova de corridas de "ADO.NET" fica assim:

var impressos = produtos.OfType<LivroImpressoDocumento>();

UpdateResult resultado = await impressos.UpdateOneAsync(
    Builders<LivroImpressoDocumento>.Filter.And(
        Builders<LivroImpressoDocumento>.Filter.Eq(l => l.Id, produtoId),
        Builders<LivroImpressoDocumento>.Filter.Gte(l => l.Estoque, quantidade)),
    Builders<LivroImpressoDocumento>.Update.Inc(l => l.Estoque, -quantidade));

if (resultado.ModifiedCount == 0)
    throw new InvalidOperationException($"Produto {produtoId} inexistente ou sem estoque suficiente.");

O filtro "este produto, com estoque maior ou igual à quantidade" e a alteração "diminua o estoque" são aplicados numa única operação atômica sobre o documento. Se duas vendas disputarem o último exemplar, a segunda não encontra nenhum documento que satisfaça o filtro, e ModifiedCount volta zero. É a mesma técnica, a terceira vez que ela aparece no curso, e agora você sabe reconhecê-la em qualquer banco. Outros operadores úteis: Set (define um campo), Push e AddToSet (acrescentam a listas), Pull (remove de listas) e Unset (remove um campo).

Transações: por que o documento resolve quase tudo

O MongoDB garante que toda operação sobre um único documento é atômica. Por isso, gravar um pedido com todos os seus itens, que num banco relacional exigia uma transação sobre duas tabelas, aqui é uma operação indivisível por natureza. Essa é a razão profunda do conselho "incorpore o que é gravado junto": o modelo de documentos troca transações por documentos bem desenhados.

Operações que envolvem vários documentos, como gravar o pedido e baixar o estoque dos produtos, exigem transações de múltiplos documentos, disponíveis desde a versão 4.0. Mas há uma condição que costuma surpreender: elas só funcionam quando o servidor roda como um conjunto de réplicas (replica set), a configuração de produção com cópias redundantes. O contêiner que subimos, com um único servidor independente, recusa transações. Veremos como configurá-lo e usá-las na próxima aula.

Juntando tudo: a Livraria no MongoDB

using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.Linq;

var cliente = new MongoClient("mongodb://localhost:27017");
IMongoDatabase banco = cliente.GetDatabase("livraria");
var produtos = banco.GetCollection<ProdutoDocumento>("produtos");
var pedidos = banco.GetCollection<PedidoDocumento>("pedidos");
var contadores = banco.GetCollection<BsonDocument>("contadores");

if (await produtos.CountDocumentsAsync(FilterDefinition<ProdutoDocumento>.Empty) == 0)
{
    await produtos.InsertManyAsync(
    [
        new LivroImpressoDocumento { Id = 1, Titulo = "Dom Casmurro", Autor = "Machado de Assis",
            Preco = 59.90m, PesoGramas = 350, Estoque = 10, Generos = ["romance", "realismo"] },
        new EbookDocumento { Id = 2, Titulo = "Vidas Secas", Autor = "Graciliano Ramos",
            Preco = 24.90m, TamanhoMb = 1.8, Generos = ["romance", "regionalismo"] },
        new LivroImpressoDocumento { Id = 3, Titulo = "Grande Sertão: Veredas", Autor = "Guimarães Rosa",
            Preco = 89.00m, PesoGramas = 1200, Estoque = 2, Generos = ["romance", "regionalismo"] },
    ]);
}

// Um pedido inteiro, gravado como um único documento
var dom = (LivroImpressoDocumento)await produtos.Find(p => p.Id == 1).SingleAsync();
var vidas = await produtos.Find(p => p.Id == 2).SingleAsync();

var pedido = new PedidoDocumento
{
    Numero = await ProximoNumeroAsync(contadores, "pedidos"),
    Status = "Pago",
    CriadoEm = DateTime.UtcNow,
    Itens =
    [
        new() { ProdutoId = dom.Id, Titulo = dom.Titulo, Autor = dom.Autor, PrecoUnitario = dom.Preco, Quantidade = 2 },
        new() { ProdutoId = vidas.Id, Titulo = vidas.Titulo, Autor = vidas.Autor, PrecoUnitario = vidas.Preco, Quantidade = 1 },
    ],
};
pedido.Total = pedido.Itens.Sum(i => i.PrecoUnitario * i.Quantidade);
await pedidos.InsertOneAsync(pedido);

// Baixa de estoque atômica e condicional
UpdateResult baixa = await produtos.OfType<LivroImpressoDocumento>().UpdateOneAsync(
    l => l.Id == dom.Id && l.Estoque >= 2,
    Builders<LivroImpressoDocumento>.Update.Inc(l => l.Estoque, -2));
Console.WriteLine(baixa.ModifiedCount == 1 ? "Estoque baixado." : "Estoque insuficiente.");

// Consultas
var regionalistas = await produtos.AsQueryable()
    .Where(p => p.Generos.Contains("regionalismo"))
    .OrderBy(p => p.Preco)
    .Select(p => new { p.Titulo, p.Preco })
    .ToListAsync();
foreach (var r in regionalistas)
    Console.WriteLine($"{r.Titulo,-25}{r.Preco,10:C}");

PedidoDocumento lido = await pedidos.Find(p => p.Numero == pedido.Numero).SingleAsync();
Console.WriteLine($"Pedido {lido.Numero}: {lido.Itens.Count} itens, total {lido.Total:C}");
Estoque baixado.
Vidas Secas                R$ 24,90
Grande Sertão: Veredas     R$ 89,00
Pedido 1: 2 itens, total R$ 144,70

(2 × 59,90 + 24,90 = 144,70.) O pedido foi gravado e lido como uma unidade, sem junções, e com os itens exatamente na forma do objeto. O UpdateOneAsync aceita também uma lambda como filtro, traduzida da mesma forma que o LINQ. E os decimais, graças ao Decimal128, são números de verdade no banco.

Esta aula mostrou o lado confortável dos documentos: gravar e ler entidades inteiras. Mas um negócio também faz perguntas que atravessam muitos documentos. Quanto cada autor faturou no mês? Quais os livros mais vendidos por gênero? No SQL, respondíamos com GROUP BY e JOIN. O MongoDB tem uma ferramenta própria para isso, o pipeline de agregação, além de índices que tornam as consultas rápidas, busca textual em português e, com a configuração certa, transações que envolvem vários documentos. E fica pendente a pergunta mais importante de todas: quando escolher documentos, e quando manter o relacional?

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva, de duas formas (com builders e com LINQ), uma consulta que devolva os e-books com preço até R$ 25, ordenados por título.

Ver resposta

✓ Resposta:

// Builders
var filtro = Builders<EbookDocumento>.Filter.Lte(e => e.Preco, 25m);
List<EbookDocumento> porBuilder = await produtos.OfType<EbookDocumento>()
    .Find(filtro)
    .SortBy(e => e.Titulo)
    .ToListAsync();

// LINQ
List<EbookDocumento> porLinq = await produtos.OfType<EbookDocumento>().AsQueryable()
    .Where(e => e.Preco <= 25m)
    .OrderBy(e => e.Titulo)
    .ToListAsync();

As duas formas produzem a mesma consulta no servidor: um filtro pelo discriminador _t, que o OfType acrescenta, combinado com preco <= 25, e uma ordenação por titulo. Os builders dão acesso direto a todos os operadores do MongoDB, inclusive os que o LINQ não expressa bem. O LINQ é mais familiar e se compõe com o resto do código. Com os dados da aula, o resultado é Vidas Secas, de R$ 24,90. A comparação <= 25m só funciona corretamente no servidor porque o preço está gravado como Decimal128. Com a representação em texto dos drivers 2.x, a comparação seria entre strings, e os resultados, imprevisíveis.

Exercício 2

Explique, com um exemplo concreto, por que gravar preços como texto, a representação padrão dos drivers 2.x para decimal, quebra as consultas por faixa de preço e as ordenações.

Ver resposta

✓ Resposta: Gravados como texto, os preços viram strings como "100.00", "59.90" e "8.50", e o banco passa a compará-las como textos, caractere por caractere, na ordem alfabética. Nessa ordem, "100.00" vem antes de "59.90", porque o primeiro caractere, "1", vem antes de "5", e "8.50" vem depois de "59.90", porque "8" vem depois de "5". Uma ordenação por preço devolveria 100,00, 59,90 e 8,50, uma ordem absurda. Um filtro preco < "40" compararia textos: "100.00" < "40" seria verdadeiro, e um livro de cem reais apareceria na faixa "até R$ 40". Somas e médias nem seriam possíveis no servidor, porque não se somam strings. O erro é traiçoeiro porque ler e gravar funcionam perfeitamente: o decimal volta intacto para o C#, e só as operações feitas no banco quebram. Decimal128 guarda o valor como número decimal exato, e todas essas operações passam a funcionar como esperado. É o mesmo tipo de problema da ordem alfabética contra a cronológica das datas em texto, em "SQL Essencial com SQLite", e é por isso que o formato ISO 8601 foi escolhido lá.

Exercício 3

Acrescente o gênero "clássico" a Dom Casmurro de forma atômica, sem ler o documento e sem criar duplicatas se o gênero já existir.

Ver resposta

✓ Resposta:

UpdateResult resultado = await produtos.UpdateOneAsync(
    p => p.Id == 1,
    Builders<ProdutoDocumento>.Update.AddToSet(p => p.Generos, "clássico"));

Console.WriteLine(resultado.ModifiedCount == 1
    ? "Gênero acrescentado."
    : "Nada mudou: o gênero já existia (ou o produto não foi encontrado).");

AddToSet acrescenta o valor à lista só se ele ainda não estiver lá, no próprio servidor e de forma atômica. Duas chamadas simultâneas com o mesmo gênero não produzem duplicatas, e nenhuma atualização é perdida. A alternativa ingênua, ler o documento, acrescentar à lista em C# e regravar, teria o problema da atualização perdida de "EF Core com Desempenho": se outra pessoa acrescentasse "romance" ao mesmo tempo, uma das duas gravações apagaria a outra. Push também acrescenta, mas sem verificar duplicatas, e é adequado quando repetições são permitidas, como num histórico. MatchedCount e ModifiedCount permitem distinguir "não encontrei o produto" de "encontrei, mas não havia o que mudar".

Exercício 4

O código abaixo monta o filtro de login a partir de um JSON enviado pelo cliente. Mostre um ataque e corrija o código.

string corpo = await request.ReadAsStringAsync();   // {"email":"...","senha":"..."}
var filtro = BsonDocument.Parse(corpo);
var usuario = await usuarios.Find(filtro).FirstOrDefaultAsync();
Ver resposta

✓ Resposta: O corpo da requisição vira diretamente o filtro da consulta, então o cliente controla a consulta inteira, e não só os valores. Um atacante envia {"email": "admin@livraria.com", "senha": {"$ne": null}}. O filtro passa a significar "e-mail igual ao do administrador e senha diferente de nulo", condição verdadeira para qualquer senha cadastrada. O atacante entra como administrador sem saber a senha. É a injeção de operadores, a versão MongoDB do SQL injection. A correção desserializa o corpo num tipo com campos de texto e monta o filtro com builders, que tratam cada valor como dado:

public sealed record Credenciais(string Email, string Senha);

Credenciais credenciais = JsonSerializer.Deserialize<Credenciais>(corpo, JsonSerializerOptions.Web)
    ?? throw new ArgumentException("Requisição inválida.");

var filtro = Builders<UsuarioDocumento>.Filter.Eq(u => u.Email, credenciais.Email);
UsuarioDocumento? usuario = await usuarios.Find(filtro).FirstOrDefaultAsync();
bool senhaCorreta = usuario is not null && VerificarHash(credenciais.Senha, usuario.HashDaSenha);

Se o atacante enviar um objeto no lugar da senha, a desserialização para string falha com JsonException, e o valor nunca vira operador. O JsonSerializerOptions.Web não é detalhe: sem ele, o System.Text.Json diferencia maiúsculas, não reconhece "email" e "senha" como Email e Senha, e devolve os dois nulos, sem erro nenhum (medido). E há uma segunda correção, independente da injeção: senhas nunca são comparadas no banco nem guardadas em texto. Guarda-se um hash lento e com sal, como o PBKDF2 ou o Argon2, e a verificação acontece no programa. Esse é um tema da fase de API, quando construirmos a autenticação da Livraria.

Exercício 5

No modelo de documentos desta aula, o autor foi incorporado como texto em cada produto. Analise as consequências dessa decisão e descreva quando seria melhor ter uma coleção de autores referenciada pelo _id.

Ver resposta

✓ Resposta: Incorporar o nome do autor tem vantagens claras. Ler um produto já traz o autor, sem uma segunda busca. Os relatórios agrupam por autor diretamente, como faremos na próxima aula. E, como autores raramente mudam de nome, a duplicação quase nunca causa trabalho. As consequências negativas aparecem em três situações. Se um nome estiver errado ("Machado de Asis"), corrigi-lo exige atualizar todos os produtos daquele autor (um UpdateMany resolve, mas é preciso lembrar). Se o autor tiver dados próprios, como biografia, foto, datas e redes sociais, repeti-los em cada produto seria desperdício e fonte de inconsistência. E não há integridade: nada impede "Machado de Assis" num produto e "Machado De Assis" em outro, e para o banco são dois autores. Uma coleção autores, com os produtos guardando o _id do autor (e, se útil, também uma cópia do nome para exibição rápida, uma desnormalização parcial), é a escolha certa quando o autor tem vida própria no sistema: uma página de autor no site, um cadastro editável, dados biográficos. A regra de "EF Core: Relacionamentos e Migrations" vale também aqui: entidades com identidade e ciclo de vida próprios merecem existir separadamente. Dados que são só um atributo do documento podem ser incorporados.

Comentários

Mais em Linguagem C#

Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable
Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable

Memória no .NET: o que fica na pilha e no heap, por que alocar é barato e…

Arquitetura: Clean Architecture, DDD com moderação e os padrões que já usamos
Arquitetura: Clean Architecture, DDD com moderação e os padrões que já usamos

A Livraria olhada como um todo: a regra de dependência de Clean Architecture e…

Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis
Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis

O que o foreach faz por baixo com IEnumerable e IEnumerator, iteradores com…