Aggregation Framework
MongoDB’s aggregation framework processes documents through a pipeline of stages — filtering, grouping, reshaping, and computing along the way. Spring Data exposes it through Aggregation.newAggregation(...) and runs it with MongoTemplate, mapping the output into a DTO.
The pipeline model
A pipeline is an ordered list of stages. Each stage takes the documents emitted by the previous one and transforms them. The most common stages:
| Stage | Operator | Purpose |
|---|---|---|
match | $match | Filter documents (push it early) |
group | $group | Aggregate by a key (sum, avg, count) |
sort | $sort | Order results |
project | $project | Reshape / select fields |
unwind | $unwind | Flatten an array into one doc per element |
limit / skip | $limit / $skip | Paginate |
A grouping example
Suppose products documents carry a category and price, and you want the count and average price per category, sorted by count.
@Document("products")
public record Product(@Id String id, String name, String category, BigDecimal price) { }
Define a DTO for the result rows. The _id produced by $group maps to whatever field you name with Fields.field.
public record CategoryStats(String category, long count, double averagePrice) { }
Build and run the pipeline:
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
@Service
@RequiredArgsConstructor
public class ProductStatsService {
private final MongoTemplate mongoTemplate;
public List<CategoryStats> statsByCategory() {
Aggregation aggregation = newAggregation(
match(Criteria.where("price").gt(0)),
group("category")
.count().as("count")
.avg("price").as("averagePrice"),
project("count", "averagePrice")
.and("_id").as("category"),
sort(Sort.Direction.DESC, "count")
);
AggregationResults<CategoryStats> results =
mongoTemplate.aggregate(aggregation, "products", CategoryStats.class);
return results.getMappedResults();
}
}
The group("category") stage sets _id to the category value; project then renames _id to category so it maps onto the DTO field.
Output:
[
{ "category": "peripherals", "count": 12, "averagePrice": 54.30 },
{ "category": "monitors", "count": 5, "averagePrice": 219.99 },
{ "category": "cables", "count": 3, "averagePrice": 9.50 }
]
Tip: Always place
$matchas early as possible and back its filter with an index. Filtering first means later stages process far fewer documents.
Unwinding arrays
When a document holds an array, unwind emits one document per element so you can group across them. Imagine each product carries a tags array, and you want the most popular tags.
Aggregation tagPopularity = newAggregation(
unwind("tags"),
group("tags").count().as("uses"),
project("uses").and("_id").as("tag"),
sort(Sort.Direction.DESC, "uses"),
limit(5)
);
List<TagCount> top = mongoTemplate
.aggregate(tagPopularity, "products", TagCount.class)
.getMappedResults();
public record TagCount(String tag, long uses) { }
A product with tags: ["spring", "java"] becomes two documents after $unwind, so each tag is counted independently.
Projecting computed fields
project can compute new fields, not just select existing ones. Here we compute a discounted price inline.
Aggregation discounted = newAggregation(
project("name", "price")
.and("price").multiply(0.9).as("salePrice")
);
Note:
AggregationResultscarries bothgetMappedResults()(your typed DTOs) andgetRawResults()(the rawDocuments). Use the raw results when a stage produces a shape your DTO does not cover.
Typed aggregation
Instead of passing a collection name string, pass the input class and Spring derives the collection and applies field-name mapping (@Field) from your @Document.
mongoTemplate.aggregate(aggregation, Product.class, CategoryStats.class);
This keeps the pipeline aligned with your mapped field names. See @Document Mapping.