Skip to content
Spring Boot sb mongodb 2 min read

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:

StageOperatorPurpose
match$matchFilter documents (push it early)
group$groupAggregate by a key (sum, avg, count)
sort$sortOrder results
project$projectReshape / select fields
unwind$unwindFlatten an array into one doc per element
limit / skip$limit / $skipPaginate

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 $match as 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: AggregationResults carries both getMappedResults() (your typed DTOs) and getRawResults() (the raw Documents). 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.

Last updated June 13, 2026
Was this helpful?