Quando lavori con MongoDB in Node.js, prima o poi ti trovi davanti a dati che non si lasciano estrarre con un semplice find(). Ordini raggruppati per mese, scontrino medio per categoria, report di magazzino con join tra collezioni. Le query semplici non bastano. Ti serve l'aggregation pipeline. E con Mongoose, gli schemi diventano chiari e manutenibili, ma la pipeline ha le sue regole. Noi, di Meteora Web, abbiamo costruito piattaforme di e-commerce e gestionali proprio su questa coppia: Mongoose per modellare i dati, aggregation per interrogare senza fare il giro del mondo. In questa guida vediamo come usarle insieme, evitando i trabocchetti che rallentano il codice o peggiorano la leggibilità.
Perché usare Mongoose ODM e aggregation pipeline invece di query raw?
Mongoose non è solo un ODM: aggiunge validazione, casting automatico, middleware (pre/post save) e un linguaggio di query fluido. Con l'aggregation pipeline, però, non sei obbligato a scrivere manualmente i BSON lunghi del driver nativo. Puoi usare l'API di Mongoose Model.aggregate() che accetta array di stage identici a quelli del driver, ma benefici comunque della tipizzazione e dei virtuals. Inoltre, Mongoose converte automaticamente i tipi nei risultati (es. _id da ObjectId a stringa) se lo configuri. Per noi è stato fondamentale in progetti dove i dati dovevano passare direttamente a un frontend Vue: niente mappature manuali.
Sponsored Protocol
Come impostare Mongoose per l'aggregation pipeline?
Prima di tutto, definisci uno schema. Non serve sovraingegnerizzare: anche uno schema minimo con pochi campi è meglio di niente, perché Mongoose applica le regole di validazione durante gli update. Ecco un esempio per un modello ordine:
const mongoose = require('mongoose');
const orderSchema = new mongoose.Schema({
customerId: { type: mongoose.Schema.Types.ObjectId, ref: 'Customer' },
items: [{
productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' },
qty: Number,
price: Number
}],
status: { type: String, enum: ['pending', 'shipped', 'cancelled'] },
createdAt: { type: Date, default: Date.now }
}, { timestamps: true });
module.exports = mongoose.model('Order', orderSchema);
Ora puoi usare Order.aggregate(). Nota: Mongoose non applica automaticamente gli indici definiti nello schema quando usi aggregate(); devi crearli manualmente su MongoDB. Questo è un errore comune che causa slow query.
Esempio pratico: report mensile di fatturato con $match, $group, $project
Immagina di dover calcolare il fatturato per mese nell'ultimo anno, con lo scontrino medio e il numero di ordini. Una query find() ti costringerebbe a scaricare tutti i documenti e processarli in Node.js: lento. Con l'aggregation pipeline lo fai lato database.
const result = await Order.aggregate([
{ $match: { createdAt: { $gte: new Date('2025-01-01') }, status: 'shipped' } },
{
$group: {
_id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
totalRevenue: { $sum: { $reduce: { input: '$items', initialValue: 0, in: { $add: ['$$value', { $multiply: ['$$this.qty', '$$this.price'] }] } } } },
avgOrderValue: { $avg: { $reduce: { input: '$items', initialValue: 0, in: { $add: ['$$value', { $multiply: ['$$this.qty', '$$this.price'] }] } } } },
orderCount: { $sum: 1 }
}
},
{ $sort: { _id: 1 } },
{ $project: { month: '$_id', totalRevenue: 1, avgOrderValue: 1, orderCount: 1, _id: 0 } }
]);
Risultato: array di oggetti con month, totalRevenue, avgOrderValue, orderCount. Zero post-processing. Funziona anche su collezioni enormi se gli stage sono indicizzati. Se noti, abbiamo usato $reduce per calcolare il subtotale di ogni ordine ― niente lookup a una collezione separata, tutto inline.
Sponsored Protocol
Come gestire $lookup con Mongoose per unire collezioni?
Uno dei punti di forza di MongoDB è l'embedding, ma a volte servono join. Con Mongoose, puoi usare $lookup direttamente nella pipeline. Il risultato però non segue automaticamente lo schema Mongoose: devi specificare i campi. Esempio: un report che mostra per ogni ordine il nome del cliente e il totale.
const report = await Order.aggregate([
{ $match: { createdAt: { $gte: startDate } } },
{
$lookup: {
from: 'customers', // nome della collezione (di default pluralized del modello)
localField: 'customerId',
foreignField: '_id',
as: 'customer'
}
},
{ $unwind: '$customer' },
{
$addFields: {
totalOrder: { $sum: '$items.price' } // esempio semplificato
}
},
{ $project: { 'customer.name': 1, totalOrder: 1, createdAt: 1 } }
]);
Attenzione: Se la collezione ha molti documenti, $lookup può essere lento. Per ottimizzare, crea un indice su customerId nella collezione orders e su _id in customers (di default già indicizzato). Inoltre, considera che $unwind raddoppia i documenti: se un ordine ha più clienti (caso raro), ne genererebbe duplicati. Usa $unwind solo se sei certo che la relazione sia uno-a-uno.
Sponsored Protocol
Quali sono gli errori più comuni con Mongoose e aggregation pipeline?
1. Dimenticare che Mongoose non converte automaticamente i tipi nei risultati di aggregate(). I campi _id restituiti sono ObjectId, non stringhe. Se li passi a un frontend JavaScript, devi chiamare toString() o usare toJSON() con mongoose.set('toJSON', { getters: true, virtuals: true }) ma non sempre funziona con aggregate. Soluzione: aggiungi uno stage $project o $addFields per convertire esplicitamente _id: { $toString: '$_id' }.
2. Non utilizzare allowDiskUse() per pipeline pesanti. MongoDB di default usa 100 MB di RAM per le operazioni di aggregazione. Se l'aggregazione supera questo limite, restituisce errore. Aggiungi .allowDiskUse(true) alla fine della pipeline se lavori con molti dati.
Sponsored Protocol
const result = await Order.aggregate([...]).allowDiskUse(true);
3. Creare stage non necessari. Ogni stage aggiunge overhead. Se puoi fare tutto in uno $group invece di due, fallo. Il $project finale per eliminare campi è spesso superfluo se lo fai già in $group.
Come ottimizzare la pipeline per performance reali?
Le regole sono poche ma precise:
- Filtra prima di tutto: Metti
$matchil più presto possibile, idealmente come primo stage. Riduce i documenti in input agli stage successivi. - Usa indici per ogni campo in
$matche$sort. Crea un indice composto per i filtri più comuni (es.{ createdAt: -1, status: 1 }). - Evita
$lookupquando puoi usare embedding. Se il dato è letto sempre insieme, valuta di inserirlo direttamente nel documento. - Limita i campi con
$projectpresto, non solo alla fine. Meno campi -> meno memoria. - Usa
$matchdopo$lookupper filtrare su campi della collezione joinata solo se strettamente necessario, perché$lookupgià carica tutti i documenti.
Quale schema di Mongoose scegliere per supportare bene le aggregation?
Più lo schema è piatto e tipizzato, meglio è. Evita campi annidati troppo profondi: dover usare $unwind multipli appesantisce la pipeline. Se hai array di oggetti, assicurati che siano omogenei. Inseriamo sempre il campo createdAt con tipo Date e default Date.now – è il campo più usato nei $match temporali. Per gli ObjectId di riferimento, usa ref per la leggibilità ma ricorda che aggregation non lo usa automaticamente; devi scrivere $lookup manualmente.
Sponsored Protocol
Cosa fare adesso
1. Prendi il modello ordine sopra e testalo in un ambiente locale. Crea 1000 documenti di prova e lancia la pipeline del fatturato mensile. Misura i tempi con explain().
const exp = await Order.aggregate([...]).explain('executionStats');
console.log(exp.executionStats);
2. Aggiungi allowDiskUse(true) alla pipeline se lavori con dataset che superano 100 MB.
3. Controlla gli indici sulla collezione reale: db.orders.getIndexes() e verifica che i campi usati in $match siano indicizzati.
4. Se non hai ancora mai usato $lookup, scrivi una query che unisce ordini e clienti e confronta i tempi con un semplice populate() di Mongoose (che fa query separate). Scoprirai che per join semplici populate() può bastare; per report complessi l'aggregation è vincente.
Noi di Meteora Web usiamo quotidianamente questa combinazione per dashboard di e-commerce e gestionali. Se vuoi approfondire l'uso di Node.js per backend, partiamo dalla nostra pillar. Il resto è pratica, misurazione e ottimizzazione.