MongoDB Avançado: Agregações, Índices e a Escolha Entre Documento e Relacional

MongoDB Avançado: Agregações, Índices e a Escolha Entre Documento e Relacional

O MongoDB além do básico: pipeline de agregação em LINQ, $lookup, índices compostos pela regra ESR, multikey, de texto em português e TTL, validação de esquema, transações em replica set e critérios honestos para escolher entre documentos e o modelo relacional.
Linguagem C#

• • 20 min de leitura

Guardar documentos é a parte fácil; responder perguntas sobre milhões deles, depressa e com garantias, exige o pipeline de agregação, índices bem escolhidos e a honestidade de saber quando o relacional é a escolha certa.

"MongoDB: o Modelo de Documentos e o Driver Oficial" mostrou o lado confortável dos documentos: gravar e ler um pedido inteiro como uma unidade, sem junções. Terminamos com as perguntas que um negócio faz sobre muitos documentos, como o faturamento por autor e os mais vendidos por gênero, e com duas promessas: transações entre vários documentos e uma resposta à pergunta mais importante, quando escolher documentos e quando manter o modelo relacional. Nesta aula cumprimos as duas, com as ferramentas que transformam o MongoDB de um depósito de documentos num banco de dados capaz de sustentar relatórios, buscas e operações consistentes.

O pipeline de agregação

No SQL, respondíamos a perguntas analíticas com GROUP BY, JOIN e funções de agregação. O MongoDB usa um conceito diferente, e muito familiar para quem estudou "LINQ, Parte II": o pipeline de agregação, uma sequência de estágios, cada um recebendo os documentos do anterior e entregando documentos transformados ao seguinte. Os estágios mais usados:

  • $match: filtra documentos, como o Where;
  • $project: escolhe e calcula campos, como o Select;
  • $unwind: "desenrola" uma lista, produzindo um documento por elemento, como o SelectMany;
  • $group: agrupa e agrega, como o GroupBy;
  • $sort, $limit e $skip: ordenam e paginam;
  • $lookup: junta com outra coleção, como o Join.

O faturamento por autor, a partir dos pedidos pagos, na linguagem do próprio MongoDB, como seria escrito no mongosh:

db.pedidos.aggregate([
  { $match: { status: "Pago" } },
  { $unwind: "$itens" },
  { $group: {
      _id: "$itens.autor",
      total: { $sum: { $multiply: ["$itens.precoUnitario", "$itens.quantidade"] } },
      unidades: { $sum: "$itens.quantidade" }
  } },
  { $sort: { total: -1 } }
])

Leia de cima para baixo: dos pedidos pagos, desenrole os itens (um documento por item, cada um carregando os dados do pedido), agrupe pelo autor do item somando o valor e as unidades, e ordene pelo total. É o relatório de "LINQ, Parte II" e de "SQL Essencial com SQLite", agora sobre documentos. E ele só é possível sem junções graças à fotografia do autor guardada em cada item, a desnormalização deliberada da aula anterior.

Em C#, o driver traduz consultas LINQ para pipelines de agregação, e o mesmo relatório se escreve com os operadores que você já conhece:

using MongoDB.Driver.Linq;

var faturamento = await pedidos.AsQueryable()
    .Where(p => p.Status == "Pago")
    .SelectMany(p => p.Itens)
    .GroupBy(i => i.Autor)
    .Select(g => new
    {
        Autor = g.Key,
        Total = g.Sum(i => i.PrecoUnitario * i.Quantidade),
        Unidades = g.Sum(i => i.Quantidade)
    })
    .OrderByDescending(x => x.Total)
    .ToListAsync();

O driver gera essencialmente os estágios acima (com um $project a mais antes do $unwind e outro depois do $group, para dar nome aos campos; o ToString() da consulta mostra o pipeline): Where vira $match, SelectMany vira $unwind, GroupBy com Select vira $group, e OrderByDescending vira $sort. Para estágios que o LINQ não expressa bem, o driver oferece também uma API fluente, pedidos.Aggregate(), e a possibilidade de escrever estágios diretamente como documentos BSON.

Uma recomendação de desempenho que vale para qualquer pipeline: filtre o mais cedo possível. Um $match no início reduz os documentos que passam pelos estágios seguintes e pode usar índices. O mesmo $match depois de um $unwind ou de um $group já não pode.

Juntando coleções com $lookup

Às vezes é preciso cruzar coleções. Um relatório de reposição, por exemplo, precisa dos livros vendidos e do estoque atual de cada um, que mora na coleção de produtos. O estágio $lookup faz uma junção externa, o LEFT JOIN de "SQL Essencial com SQLite", e anexa os documentos correspondentes como uma lista:

db.pedidos.aggregate([
  { $match: { status: "Pago" } },
  { $unwind: "$itens" },
  { $group: { _id: "$itens.produtoId", vendidos: { $sum: "$itens.quantidade" } } },
  { $lookup: { from: "produtos", localField: "_id", foreignField: "_id", as: "produto" } },
  { $unwind: "$produto" },
  { $project: { titulo: "$produto.titulo", vendidos: 1, estoque: "$produto.estoque" } },
  { $sort: { vendidos: -1 } }
])

O $lookup funciona, mas é mais caro que uma junção num banco relacional, e um modelo que dependa dele em quase toda consulta é sinal de que os dados talvez fossem mais bem servidos por tabelas. Guarde essa observação para o fim da aula.

Índices

Sem índices, toda consulta percorre a coleção inteira, a mesma varredura completa de "SQL Essencial com SQLite". Os índices do MongoDB também são árvores B, e são criados pelo driver:

await pedidos.Indexes.CreateManyAsync(
[
    new CreateIndexModel<PedidoDocumento>(
        Builders<PedidoDocumento>.IndexKeys.Ascending(p => p.Status).Descending(p => p.CriadoEm)),
    new CreateIndexModel<PedidoDocumento>(
        Builders<PedidoDocumento>.IndexKeys.Ascending("itens.produtoId")),
]);

O primeiro é um índice composto, sobre dois campos, e a ordem dos campos importa. A recomendação do MongoDB para escolhê-la tem nome, a regra ESR: primeiro os campos testados por igualdade (Equality), depois os usados na ordenação (Sort) e por último os filtrados por faixa (Range). Para a consulta "pedidos pagos, dos mais recentes aos mais antigos", status (igualdade) vem antes de criadoEm (ordenação), e o banco encontra os pedidos pagos já na ordem certa, sem ordenar nada.

O segundo índice aponta para um campo dentro de uma lista de documentos incorporados. Índices sobre listas são chamados de multikey: o MongoDB indexa cada elemento, e a consulta "pedidos que contêm o produto 3" usa o índice. O mesmo vale para a lista de gêneros dos produtos.

Para confirmar que uma consulta usa o índice, explain() no mongosh mostra o plano. Um estágio IXSCAN indica uso de índice, e um COLLSCAN, uma varredura completa. É o EXPLAIN QUERY PLAN do SQLite em outra forma.

Dois tipos especiais de índice resolvem problemas que em outros bancos exigiriam código.

Busca textual. Um índice de texto, com o idioma português, permite buscar palavras em títulos e nomes, ignorando maiúsculas e acentos e reconhecendo variações de palavras (singular e plural, por exemplo):

await produtos.Indexes.CreateOneAsync(new CreateIndexModel<ProdutoDocumento>(
    Builders<ProdutoDocumento>.IndexKeys.Text(p => p.Titulo).Text(p => p.Autor),
    new CreateIndexOptions { DefaultLanguage = "portuguese" }));

List<ProdutoDocumento> encontrados = await produtos
    .Find(Builders<ProdutoDocumento>.Filter.Text("sertao"))
    .ToListAsync();   // encontra "Grande Sertão: Veredas"

É a busca que exigiu a extensão unaccent no PostgreSQL, pronta no próprio índice. Os resultados podem ainda ser ordenados por relevância, com a pontuação que o banco atribui a cada documento.

Expiração automática. Um índice TTL (time to live) apaga documentos sozinho depois de um tempo. Carrinhos de compra abandonados são o exemplo clássico:

await carrinhos.Indexes.CreateOneAsync(new CreateIndexModel<CarrinhoDocumento>(
    Builders<CarrinhoDocumento>.IndexKeys.Ascending(c => c.AtualizadoEm),
    new CreateIndexOptions { ExpireAfter = TimeSpan.FromDays(7) }));

Um processo interno do servidor verifica o índice periodicamente, mais ou menos a cada minuto, e remove os carrinhos sem atualização há mais de sete dias. É uma rotina de limpeza que, num banco relacional, seria um serviço agendado escrito à mão.

Esquema flexível não é ausência de esquema

"O MongoDB não tem esquema" é uma meia-verdade perigosa. Os documentos de uma coleção podem ter estruturas diferentes, mas o programa sempre tem um esquema: as classes de documento. Documentos que fogem dele, como um preço gravado como texto por um script antigo ou um campo com o nome errado, causam erros na leitura ou, pior, dados silenciosamente ignorados. O MongoDB permite declarar regras de validação por coleção, com um esquema JSON, e o servidor passa a recusar documentos inválidos, como as restrições CHECK e NOT NULL de um banco relacional:

db.createCollection("produtos", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["titulo", "autor", "preco"],
    properties: {
      titulo: { bsonType: "string", minLength: 1 },
      preco:  { bsonType: "decimal", minimum: 0.01 }
    }
  } }
})

A flexibilidade é útil para evoluir, porque documentos novos podem ganhar campos sem migrar os antigos. Mas a integridade continua sendo necessária, e continua sendo responsabilidade de alguém: das validações do banco, do código ou, idealmente, dos dois.

Transações de múltiplos documentos

Na aula anterior, gravar o pedido e baixar o estoque foram duas operações independentes. Se o programa caísse entre elas, o pedido existiria sem a baixa. Para torná-las uma unidade, precisamos de uma transação, e, como vimos, elas exigem um conjunto de réplicas. Para desenvolvimento, um conjunto de réplicas de um único membro basta:

docker run --name livraria-mongo-rs -p 27017:27017 -d mongo:8 --replSet rs0
docker exec -it livraria-mongo-rs mongosh --eval "rs.initiate()"

A cadeia de conexão passa a indicar o conjunto, mongodb://localhost:27017/?replicaSet=rs0&directConnection=true, e o driver oferece um método que cuida de tudo, inclusive de repetir a transação em caso de conflitos temporários:

using IClientSessionHandle sessao = await cliente.StartSessionAsync();

await sessao.WithTransactionAsync(async (s, ct) =>
{
    UpdateResult baixa = await produtos.OfType<LivroImpressoDocumento>().UpdateOneAsync(
        s,
        l => l.Id == produtoId && l.Estoque >= quantidade,
        Builders<LivroImpressoDocumento>.Update.Inc(l => l.Estoque, -quantidade),
        cancellationToken: ct);

    if (baixa.ModifiedCount == 0)
        throw new InvalidOperationException("Estoque insuficiente.");   // desfaz a transação

    await pedidos.InsertOneAsync(s, pedido, cancellationToken: ct);
    return true;
});

Cada operação recebe a sessão s como primeiro argumento, o equivalente de atribuir a Transaction a cada comando em "ADO.NET". Uma exceção dentro da função desfaz tudo, e o retorno normal confirma. É o tudo ou nada do ACID, agora entre documentos de coleções diferentes.

Uma observação de desenho: transações de múltiplos documentos no MongoDB têm custo maior que as operações sobre um único documento, e a documentação oficial recomenda não depender delas como mecanismo principal. Se quase toda operação importante precisa de uma, o modelo provavelmente está lutando contra o banco.

Documento ou relacional?

Chegamos à pergunta que atravessou as duas aulas. Não existe resposta universal, mas existem critérios.

O modelo de documentos brilha quando:

  • as entidades são lidas e gravadas como unidades, e seus dados aninhados pertencem a elas (pedidos com itens, perfis com preferências, conteúdos com seus blocos);
  • a estrutura varia muito de um registro para outro ou muda com frequência (catálogos com atributos diferentes por categoria, formulários configuráveis, eventos de sistemas diversos);
  • o volume de escrita é muito alto e as consultas são majoritariamente por chave ou por poucos campos (registros de atividade, telemetria, sessões);
  • a escala horizontal, com dados distribuídos em muitos servidores, é um requisito real. O MongoDB foi desenhado para isso, com o sharding.

O modelo relacional brilha quando:

  • os dados são fortemente interligados, e as consultas cruzam as entidades de muitas formas diferentes, muitas delas imprevistas quando o sistema foi desenhado (relatórios gerenciais, análises);
  • a integridade entre entidades é crítica: dinheiro, estoque, contabilidade, dados regulados;
  • as operações envolvem várias entidades ao mesmo tempo e precisam de transações rotineiramente;
  • a equipe já domina SQL, que é um padrão, com ferramentas de relatório e análise em todo lugar.

E há duas observações que tornam a escolha menos dramática do que parece. A primeira: os bancos relacionais modernos incorporaram o melhor dos documentos. O jsonb de "PostgreSQL" cobre com folga a maioria dos casos de "alguns dados variáveis dentro de um modelo relacional". A segunda: um mesmo sistema pode usar os dois. Essa prática tem nome, persistência poliglota: o financeiro e o estoque num banco relacional, o catálogo com atributos variáveis ou o histórico de navegação dos clientes num banco de documentos. Cada dado no banco que melhor o serve, com o custo de operar e integrar dois sistemas.

Para a Livraria, com vendas, estoque, pedidos e relatórios cruzando tudo isso, o PostgreSQL é a escolha mais natural. O MongoDB faria sentido para uma parte específica, como o registro de eventos de navegação da loja virtual, lido quase sempre em bloco e com volume muito maior que o das vendas.

Juntando tudo: os relatórios da Livraria no MongoDB

// Índices que sustentam os relatórios e a busca
await pedidos.Indexes.CreateOneAsync(new CreateIndexModel<PedidoDocumento>(
    Builders<PedidoDocumento>.IndexKeys.Ascending(p => p.Status).Descending(p => p.CriadoEm)));
await produtos.Indexes.CreateOneAsync(new CreateIndexModel<ProdutoDocumento>(
    Builders<ProdutoDocumento>.IndexKeys.Text(p => p.Titulo).Text(p => p.Autor),
    new CreateIndexOptions { DefaultLanguage = "portuguese" }));

// Faturamento por autor no mês
var inicioDoMes = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
var porAutor = await pedidos.AsQueryable()
    .Where(p => p.Status == "Pago" && p.CriadoEm >= inicioDoMes)
    .SelectMany(p => p.Itens)
    .GroupBy(i => i.Autor)
    .Select(g => new { Autor = g.Key, Total = g.Sum(i => i.PrecoUnitario * i.Quantidade) })
    .OrderByDescending(x => x.Total)
    .ToListAsync();

foreach (var linha in porAutor)
    Console.WriteLine($"{linha.Autor,-22}{linha.Total,12:C}");

// Os três títulos mais vendidos
var maisVendidos = await pedidos.AsQueryable()
    .Where(p => p.Status == "Pago")
    .SelectMany(p => p.Itens)
    .GroupBy(i => i.Titulo)
    .Select(g => new { Titulo = g.Key, Unidades = g.Sum(i => i.Quantidade) })
    .OrderByDescending(x => x.Unidades)
    .Take(3)
    .ToListAsync();

// Busca textual, sem acento e sem distinção de caixa
var busca = await produtos.Find(Builders<ProdutoDocumento>.Filter.Text("machado"))
    .Project(p => p.Titulo)
    .ToListAsync();
Console.WriteLine($"Busca por \"machado\": {string.Join(", ", busca)}");

O filtro de status e data acontece no primeiro estágio e usa o índice composto. A ordem dos campos, igualdade antes de faixa, segue a regra ESR. Os relatórios se escrevem com o mesmo LINQ que usamos sobre listas, sobre o EF Core e agora sobre o MongoDB. É a promessa de "LINQ, Parte I" cumprida em toda a sua extensão: a mesma linguagem de consulta sobre quatro fontes de dados diferentes. E a busca por "machado" encontra os livros de Machado de Assis pelo índice de texto, sem nenhuma função especial.

A Livraria agora sabe persistir seus dados em cinco lugares diferentes ao longo da fase: um arquivo JSON, o SQLite, o PostgreSQL, o MySQL e o MongoDB. Mas, em cada aula, o código de acesso a dados se misturou ao programa principal, e trocar de banco significou reescrever partes dele. O domínio sobreviveu intacto a todas as trocas, e isso não foi por acaso: foi a arquitetura em camadas de "Do Arquivo Único à Solução" funcionando. O passo que fecha a fase é levar essa separação até o fim: um único contrato de acesso a dados, três implementações independentes e a escolha do banco feita por configuração, sem tocar em nenhuma linha do resto do sistema.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva, com LINQ, um relatório que devolva, para cada gênero, o total de unidades vendidas nos pedidos pagos. Os itens dos pedidos não guardam os gêneros. Que opções de modelagem existem, e qual você escolheria?

Ver resposta

✓ Resposta: Há duas opções. A primeira é cruzar com a coleção de produtos no momento do relatório, com um $lookup. Em LINQ, isso pode ser feito com um Join entre as duas coleções, que o driver traduz para esse estágio:

var porGenero = await pedidos.AsQueryable()
    .Where(p => p.Status == "Pago")
    .SelectMany(p => p.Itens)
    .Join(produtos.AsQueryable(), i => i.ProdutoId, p => p.Id,
          (i, p) => new { i.Quantidade, p.Generos })
    .SelectMany(x => x.Generos, (x, genero) => new { genero, x.Quantidade })
    .GroupBy(x => x.genero)
    .Select(g => new { Genero = g.Key, Unidades = g.Sum(x => x.Quantidade) })
    .OrderByDescending(x => x.Unidades)
    .ToListAsync();

A segunda é copiar os gêneros para o item no momento da venda, ampliando a fotografia, e o relatório dispensa a junção. A escolha depende da pergunta de negócio. Se o relatório deve refletir a classificação atual dos livros (um livro reclassificado hoje conta no novo gênero, inclusive nas vendas antigas), a junção é a certa. Se deve refletir a classificação do momento da venda, a cópia é a certa, e ainda é mais barata. Para um relatório gerencial frequente, a cópia costuma vencer. Para uma análise pontual, a junção basta. É o mesmo raciocínio do preço no item em "EF Core: Relacionamentos e Migrations": copiar é uma decisão sobre o significado do dado, e não só sobre desempenho.

Exercício 2

A tela de acompanhamento mostra "os pedidos de um cliente com um status, dos mais recentes aos mais antigos, a partir de uma data". Proponha o índice composto, justificando a ordem dos campos.

Ver resposta

✓ Resposta: A consulta tem dois campos testados por igualdade (clienteId e status), uma ordenação (criadoEm, decrescente) e um filtro de faixa sobre o mesmo campo da ordenação (criadoEm >= data). Pela regra ESR, os campos de igualdade vêm primeiro, e em seguida o campo de ordenação e faixa:

Builders<PedidoDocumento>.IndexKeys
    .Ascending(p => p.ClienteId)
    .Ascending(p => p.Status)
    .Descending(p => p.CriadoEm)

Com essa ordem, o banco desce na árvore até o bloco de "este cliente, este status", e ali os pedidos já estão ordenados por data. Ele percorre a partir da data pedida e pode parar assim que tiver os documentos necessários, sem ordenar nada em memória. Se criadoEm viesse primeiro, o banco teria de percorrer pedidos de todos os clientes e status dentro da faixa de datas e filtrar depois. A ordem entre os dois campos de igualdade importa menos para essa consulta, mas faz diferença para o reaproveitamento do índice: com clienteId primeiro, o mesmo índice também atende a consulta "todos os pedidos de um cliente", em qualquer status, porque o índice pode ser usado por qualquer prefixo dos seus campos.

Exercício 3

Ordene os resultados da busca textual por relevância, mostrando a pontuação de cada produto encontrado.

Ver resposta

✓ Resposta:

var filtro = Builders<ProdutoDocumento>.Filter.Text("romance sertão");

var resultados = await produtos.Find(filtro)
    .Project(Builders<ProdutoDocumento>.Projection
        .Include(p => p.Titulo)
        .MetaTextScore("pontuacao"))
    .Sort(Builders<ProdutoDocumento>.Sort.MetaTextScore("pontuacao"))
    .ToListAsync();

foreach (BsonDocument r in resultados)
    Console.WriteLine($"{r["titulo"].AsString,-30} {r["pontuacao"].ToDouble():F2}");

A busca textual atribui a cada documento uma pontuação de relevância, maior quando os termos aparecem mais vezes ou em campos mais curtos. MetaTextScore inclui essa pontuação na projeção, com o nome indicado, e a usa na ordenação. Como a projeção devolve só alguns campos e um valor calculado, o resultado vem como BsonDocument, e não como a classe de produto. Com vários termos, a busca devolve os documentos que contêm qualquer um deles, e os que contêm mais termos tendem a pontuar mais. Mas repare no que o índice cobre: título e autor. "romance" é um gênero, e não aparece em nenhum dos dois, então com os dados da aula a busca devolve só Grande Sertão (pontuação 0,67, medida), e não os demais romances. Para buscar também pelos gêneros, o campo Generos precisaria entrar no índice de texto. Para exigir uma expressão exata, os termos vão entre aspas dentro da busca.

Exercício 4

Explique por que a gravação do pedido e a baixa de estoque, feitas como duas operações separadas na aula anterior, podem deixar os dados inconsistentes, e compare as duas formas de resolver: a transação desta aula e uma alternativa sem transação.

Ver resposta

✓ Resposta: São duas operações sobre coleções diferentes, e cada uma é atômica sozinha, mas não em conjunto. Se o programa cair depois de gravar o pedido e antes de baixar o estoque, existe um pedido pago sem a baixa correspondente, e o estoque fica maior do que deveria. Na ordem inversa, o estoque baixa sem que exista o pedido. A transação de múltiplos documentos resolve de forma direta: as duas operações são confirmadas juntas ou desfeitas juntas. O custo é exigir um conjunto de réplicas e ter desempenho menor que o das operações simples. A alternativa sem transação reorganiza o processo para que cada etapa seja segura e retomável. Primeiro, baixa-se o estoque de forma atômica e condicional, registrando no documento do produto uma "reserva" com o número do pedido. Depois, grava-se o pedido. Um processo de reconciliação, executado periodicamente, desfaz reservas cujo pedido nunca foi gravado. É mais trabalhoso, mas escala melhor e não depende de transações. Essa família de técnicas, com etapas compensáveis em vez de uma transação única, é comum em sistemas distribuídos e aparece com o nome de padrão Saga. Para a Livraria, com volume moderado, a transação é a escolha simples e correta. A necessidade de pensar nisso, porém, é mais um sinal de que dados de estoque e pedidos se sentem mais em casa num banco relacional.

Exercício 5

Para cada cenário, escolha entre um banco relacional (como o PostgreSQL) e um de documentos (como o MongoDB), justificando: (a) a contabilidade da Livraria, com lançamentos de débito e crédito; (b) o registro de cada clique e página visitada na loja virtual, com milhões de eventos por dia; (c) um catálogo de produtos de um marketplace, em que cada categoria tem atributos diferentes (livros têm ISBN, fones têm impedância, roupas têm tamanho).

Ver resposta

✓ Resposta: (a) Relacional. Contabilidade é o exemplo por excelência de dados em que a integridade é inegociável: cada lançamento tem uma contrapartida, as somas precisam fechar, e as operações envolvem várias contas ao mesmo tempo, dentro de transações rotineiras. As consultas cruzam contas, períodos e centros de custo de formas que mudam a cada pedido do contador. Restrições, chaves estrangeiras e SQL são exatamente o que esse domínio pede. (b) Documentos, ou um banco especializado em séries temporais ou eventos. Cada evento é gravado uma única vez e nunca alterado, tem estrutura variável conforme o tipo de interação e é consultado quase sempre em bloco, por período ou por sessão. O volume de escrita é altíssimo, não há relacionamentos a manter, e um índice TTL pode descartar eventos antigos automaticamente. (c) Os dois são defensáveis, e esta é a resposta honesta. O modelo de documentos acomoda naturalmente atributos diferentes por categoria e é uma escolha clássica para catálogos. Um banco relacional com uma coluna jsonb para os atributos variáveis, e colunas normais para o que é comum e filtrado (preço, estoque, vendedor), também resolve bem e mantém a integridade das partes críticas. A decisão final dependeria do resto do sistema: se pedidos, pagamentos e estoque do marketplace já estão num banco relacional, o jsonb evita operar um segundo banco. Se o catálogo é um serviço independente, com equipe e escala próprias, o banco de documentos ganha força.

Comentários

Mais em Linguagem C#

Métodos: Dividindo o Problema em Partes Que Conversam
Métodos: Dividindo o Problema em Partes Que Conversam

Métodos em C#: declaração, retorno e corpo de expressão, funções locais nas…

Input, física e colisões: fazendo o jogo parecer justo
Input, física e colisões: fazendo o jogo parecer justo

O que faz um jogo parecer justo: ações no lugar de teclas, pulo com buffer…

PostgreSQL: o Banco de Dados Servidor, com Npgsql, JSONB e Arrays
PostgreSQL: o Banco de Dados Servidor, com Npgsql, JSONB e Arrays

A Livraria muda para o PostgreSQL: Npgsql e NpgsqlDataSource, EF Core com…