Aggregation pipeline no MongoDB: relatório passo a passo
Monte pipelines com match, unwind, group, project, sort e lookup para gerar relatórios de vendas no MongoDB, com saídas e erro reais.
Aggregation pipeline é uma sequência de estágios que filtra, abre, agrupa, calcula e reorganiza documentos. Nesta lição, você vai transformar pedidos da Livraria Horizonte em um ranking de faturamento por livro e num resumo por cliente.
O método técnico é collection.aggregate(). Ele recebe um array de estágios;
cada estágio lê a saída do anterior. O resultado é um cursor, consumido aqui
com toArray() porque o conjunto final é pequeno.
A bancada em que cada pessoa faz uma etapa
Imagine a conferência de vendas numa bancada. A primeira pessoa separa pedidos pagos. A segunda abre a lista de itens e coloca cada item numa ficha própria. A terceira reúne fichas do mesmo livro. A quarta soma unidades e valor. A última ordena o ranking.
Esse mapa corresponde a $match, $unwind, $group, $project e $sort.
Quando um estágio entrega seis fichas, o próximo trabalha sobre essas seis, não
sobre os pedidos originais.
O limite da analogia: MongoDB não precisa materializar uma pilha física entre todos os estágios e pode otimizar a execução. Alguns estágios usam índices, memória ou disco; outros mudam a quantidade e o formato dos documentos. A ordem continua sendo parte do resultado e do custo, então não reorganize a pipeline apenas porque os nomes “parecem equivalentes”.
Para revisar filtros e cursores antes de agregar, passe pelo CRUD MongoDB com Node.js.
Monte o laboratório e conheça a entrada
Com MongoDB 8.0.28 ativo, instale o driver oficial:
npm init -y
npm install mongodb@7.5.0Crie aggregation.mjs, conecte e use um database descartável:
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017";
const client = new MongoClient(uri);
await client.connect();
const db = client.db("devclub_aggregation");
await db.dropDatabase();
const pedidos = db.collection("pedidos");
const clientes = db.collection("clientes");
console.log({ database: db.databaseName, estado: "limpo" });Cadastre dois clientes e quatro pedidos. Três estão pagos; o cancelado contém cinco unidades de Node.js justamente para comprovar que o filtro acontece antes do ranking:
await clientes.insertMany([
{ _id: "cliente-1", nome: "Ana" },
{ _id: "cliente-2", nome: "Bruno" },
]);
await pedidos.insertMany([
{
_id: "p1", clienteId: "cliente-1", status: "pago", total: 134.3,
itens: [
{ titulo: "JavaScript do zero", quantidade: 2, precoUnitario: 39.9 },
{ titulo: "Node.js na prática", quantidade: 1, precoUnitario: 54.5 },
],
},
{
_id: "p2", clienteId: "cliente-2", status: "pago", total: 99.7,
itens: [
{ titulo: "JavaScript do zero", quantidade: 1, precoUnitario: 39.9 },
{ titulo: "CSS sem sustos", quantidade: 2, precoUnitario: 29.9 },
],
},
{
_id: "p3", clienteId: "cliente-2", status: "cancelado", total: 272.5,
itens: [
{ titulo: "Node.js na prática", quantidade: 5, precoUnitario: 54.5 },
],
},
{
_id: "p4", clienteId: "cliente-1", status: "pago", total: 148.9,
itens: [
{ titulo: "Node.js na prática", quantidade: 2, precoUnitario: 54.5 },
{ titulo: "JavaScript do zero", quantidade: 1, precoUnitario: 39.9 },
],
},
]);
console.log({ clientes: 2, pedidos: 4 });Os totais foram escritos com Number para manter o foco no pipeline. Para
dinheiro em produção, defina centavos inteiros ou Decimal128 e uma regra de
arredondamento. Um relatório correto sobre um tipo inadequado continua sendo um
relatório inadequado.
Primeiro estágio: $match reduz o conjunto
Comece respondendo apenas “quantos pedidos pagos seguem adiante?”. $count é
usado temporariamente para enxergar a saída do filtro:
const depoisDoMatch = await pedidos.aggregate([
{ $match: { status: "pago" } },
{ $count: "pedidosPagos" },
]).toArray();
console.log(depoisDoMatch);Colocar $match cedo evita que o pedido cancelado atravesse as contas. Quando o
filtro corresponde a um índice e aparece numa posição aproveitável, ele também
pode reduzir leitura. A lição de
índices MongoDB mede essa diferença com
explain().
$unwind transforma cada item em uma entrada
Cada pedido tem um array itens. Para agrupar por título, precisamos de um
documento de pipeline por item. $unwind abre o array e repete os outros campos
do pedido em cada saída:
const depoisDoUnwind = await pedidos.aggregate([
{ $match: { status: "pago" } },
{ $unwind: "$itens" },
{ $count: "itensNosPedidosPagos" },
]).toArray();
console.log(depoisDoUnwind);Há três pedidos pagos e dois tipos de item em cada um, portanto saem seis
entradas. quantidade: 2 não cria duas entradas: é um valor do item, somado no
estágio seguinte. Confundir “linhas do array” com “unidades vendidas” produz um
ranking errado sem gerar exceção.
$group cria os baldes e faz as contas
_id dentro de $group é a chave de agrupamento, não o _id do pedido. Todos
os itens com o mesmo título entram no mesmo grupo. $sum acumula quantidades;
$multiply calcula o valor de cada item antes da soma:
const grupos = await pedidos.aggregate([
{ $match: { status: "pago" } },
{ $unwind: "$itens" },
{
$group: {
_id: "$itens.titulo",
unidades: { $sum: "$itens.quantidade" },
faturamento: {
$sum: {
$multiply: ["$itens.quantidade", "$itens.precoUnitario"],
},
},
},
},
{ $sort: { _id: 1 } },
]).toArray();
console.log(grupos);O pedido cancelado tinha cinco unidades de Node.js, mas o grupo mostra três.
Essa diferença é a evidência de que $match protegeu o cálculo.
$project entrega o formato da resposta
O consumidor do relatório não precisa saber que $group chamou o título de
_id. $project renomeia o campo, omite o id interno e arredonda o total; o
$sort final ordena pelo faturamento:
const ranking = await pedidos.aggregate([
{ $match: { status: "pago" } },
{ $unwind: "$itens" },
{
$group: {
_id: "$itens.titulo",
unidades: { $sum: "$itens.quantidade" },
faturamento: {
$sum: { $multiply: ["$itens.quantidade", "$itens.precoUnitario"] },
},
},
},
{
$project: {
_id: 0,
livro: "$_id",
unidades: 1,
faturamento: { $round: ["$faturamento", 2] },
},
},
{ $sort: { faturamento: -1 } },
]).toArray();
console.log(ranking);O formato final é uma decisão de contrato. Uma pipeline não precisa devolver documentos com a mesma forma da coleção de origem. Isso a torna útil para dashboards e respostas de API, mas também exige testes: mudar um estágio pode alterar nomes ou tipos consumidos pela interface.
$lookup liga o agrupamento ao cadastro atual
Para o resumo por cliente, agrupe primeiro por clienteId; depois busque o nome
na coleção clientes. $lookup devolve um array chamado cliente, mesmo quando
há uma única combinação:
const porCliente = await pedidos.aggregate([
{ $match: { status: "pago" } },
{
$group: {
_id: "$clienteId",
pedidos: { $sum: 1 },
gasto: { $sum: "$total" },
},
},
{
$lookup: {
from: "clientes",
localField: "_id",
foreignField: "_id",
as: "cliente",
},
},
{
$project: {
_id: 0,
cliente: { $first: "$cliente.nome" },
pedidos: 1,
gasto: { $round: ["$gasto", 2] },
},
},
{ $sort: { gasto: -1 } },
]).toArray();
console.log(porCliente);Se um clienteId não encontrar cadastro, $first devolve ausência de valor.
Defina o que o relatório faz com referência órfã; esconder silenciosamente pode
mascarar defeito de dados. A decisão entre combinar e incorporar começa na
modelagem MongoDB, não no estágio
$lookup isolado.
Um erro de expressão também interrompe a pipeline
MongoDB valida e executa expressões no servidor. Dividir cada total por zero
faz a operação falhar com MongoServerError e código 2:
try {
await pedidos.aggregate([
{ $project: { taxa: { $divide: ["$total", 0] } } },
]).toArray();
} catch (erro) {
console.log({
erro: "divisão por zero no pipeline",
codigo: erro.code,
nome: erro.name,
});
}Num cálculo real, trate denominador zero com regra explícita, por exemplo
$cond, quando zero tiver significado válido. Capturar a exceção e devolver
zero para qualquer falha esconderia problemas diferentes sob o mesmo valor.
Missão: calcule o ticket médio sem dividir no escuro
Amplie o pipeline por cliente com ticketMedio, dividindo gasto por
pedidos. Como cada grupo tem ao menos um pedido, o denominador é conhecido:
const ticket = await pedidos.aggregate([
{ $match: { status: "pago" } },
{
$group: {
_id: "$clienteId",
quantidade: { $sum: 1 },
gasto: { $sum: "$total" },
},
},
{
$project: {
_id: 0,
clienteId: "$_id",
ticketMedio: { $round: [{ $divide: ["$gasto", "$quantidade"] }, 2] },
},
},
{ $sort: { clienteId: 1 } },
]).toArray();
console.log(ticket);
await client.close();A missão termina quando Ana aparece com 141.6, resultado de 283.2 dividido por
dois pedidos, e Bruno com 99.7. Depois remova $match de propósito: o valor de
Bruno muda porque a venda cancelada entra. Essa comparação mostra que um
relatório pode rodar sem erro e ainda responder à pergunta errada.
Perguntas frequentes
O que é aggregation pipeline no MongoDB?
aggregate altera os documentos originais?
Por que colocar match no começo?
lookup é igual a JOIN em SQL?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em MongoDB 8.0.28, MongoDB Node.js Driver 7.5.0, Node 26.3.0 e Docker 29.5.3, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MongoDB Manual — Aggregation Operations — mongodb.com
- MongoDB Manual — Aggregation Pipeline — mongodb.com
- MongoDB Manual — Aggregation Stages — mongodb.com
- MongoDB Node.js Driver — Aggregation — mongodb.com


