skip to content

How do you build an aggregation pipeline with match, group, and project stages in Spring Data MongoDB?

level: seniorimportance: must knowfreq 58%

answer

  1. newAggregation(match, group, project, sort)
  2. match FIRST → uses index, cuts data
  3. group key → _id; accumulators .sum().as()
  4. aggregate(agg, In.class, Out.class).getMappedResults()
  5. $match before group = WHERE, after = HAVING

basics

~10 s

Use Aggregation.newAggregation(...) with static stage helpers: match(Criteria) filters, group(fields).sum/count aggregates, project(...) reshapes output. Run it with mongoTemplate.aggregate(agg, "collection", OutputType.class), then read results via getMappedResults().

solid answer

~40 s

The Aggregation framework runs a multi-stage server-side pipeline where each stage's output feeds the next. In Spring you compose it with org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation(stage1, stage2, ...). Key stages via static imports: match(Criteria) → $match to filter early (put it first, before grouping, to use indexes and cut data); group("field") → $group with accumulators like .sum("amount").as("total"), .count().as("n"), .avg(...), .max(...), .addToSet(...); project(...) → $project to include/exclude fields, rename, and compute expressions. group's _id becomes the grouping key, exposed as "_id" unless you project it to a friendly name. Execute with mongoTemplate.aggregate(agg, Order.class, OrderStats.class) — first class = input collection, second = the mapped output type. Read results from AggregationResults.getMappedResults(). Ordering matters: match→group→sort→limit is the canonical, index-friendly order.

code

java · 21 lines
java
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
import static org.springframework.data.mongodb.core.query.Criteria.where;
import org.springframework.data.domain.Sort;

Aggregation agg = newAggregation(
    match(where("status").is("PAID")),                 // $match: filter early
    group("customerId")                                  // $group by customer
        .sum("amount").as("total")
        .count().as("orders"),
    project("total", "orders")                           // $project reshape
        .and("_id").as("customerId")
        .andExclude("_id"),
    sort(Sort.by(Sort.Direction.DESC, "total")),
    limit(10)
);

AggregationResults<CustomerRevenue> results =
    mongoTemplate.aggregate(agg, Order.class, CustomerRevenue.class);
List<CustomerRevenue> topCustomers = results.getMappedResults();

go deeper

for a junior

Recognizes aggregate as a pipeline of stages and can name match/group/project.

for a middle

Builds a working match→group→project pipeline and reads getMappedResults into a DTO.

for a senior

Explains stage ordering for index use, group _id semantics, and the raw-$ vs typed-helper distinction.

for a principal

Weighs pipeline performance (index-eligible $match, allowDiskUse, 16MB/100MB limits), DTO mapping design, and when aggregation beats application-side computation.

**Aggregation** is MongoDB's data-processing pipeline: documents flow through an ordered list of **stages**, each transforming the stream, similar to SQL's GROUP BY/HAVING/SELECT but composable. Spring Data models this with the `org.springframework.data.mongodb.core.aggregation` package. **Building a pipeline.** `Aggregation.newAggregation(TypedClass, stage1, stage2, ...)` (or the untyped overload) creates an `Aggregation`. Stages come from static factory methods on `Aggregation`: - **`match(Criteria)`** → `$match`. Filters documents, exactly like a find query. **Put it as early as possible** — a `$match` before `$group` can use indexes and drastically reduces the documents flowing downstream. - **`group(fields...)`** → `$group`. Buckets documents by a key and computes accumulators. The grouping key is stored in the output field `_id`. Accumulators: `.sum("amount").as("total")`, `.count().as("orders")`, `.avg("price").as("avgPrice")`, `.max/.min`, `.first/.last`, `.addToSet("tag").as("tags")`, `.push(...)`. Group with no field (`group()`) collapses everything into one bucket — good for grand totals. - **`project(...)`** → `$project`. Reshapes each document: `.and("_id").as("customerId")` renames, `.andInclude("total")`, `.andExclude("_id")`, and computed expressions via `.andExpression("total * 0.9")` (SpEL-like) or `ArithmeticOperators`, `StringOperators`, etc. - Other common stages: **`sort(Sort)`** → `$sort`, **`limit(n)`/`skip(n)`**, **`unwind(field)`** → `$unwind`, **`lookup(...)`** → `$lookup`, **`count().as(...)`**, **`bucket(...)`**, **`facet(...)`**. **Executing.** `mongoTemplate.aggregate(aggregation, inputType, OutputType.class)` or `aggregate(aggregation, "collectionName", OutputType.class)`. The **input** determines the source collection; the **output type** is what each result document maps to (often a dedicated DTO). You get an `AggregationResults<OutputType>`; call `.getMappedResults()` for the list, or `.getUniqueMappedResult()` for a single-row expectation. **Field references and the `$` gotcha.** Inside group/project, referencing an existing field's value in raw Mongo requires a `$` prefix (`"$amount"`), but Spring's typed helpers (`.sum("amount")`) add it for you. Mixing raw `$field` strings with typed helpers is a frequent bug — know which API you're in. **Grouping-key access.** After `group("customerId")`, the key lives under `_id`. To surface it with a clean name, add a `project().and("_id").as("customerId")` (and usually `.andExclude("_id")`) so your DTO field maps cleanly. **Edge cases / gotchas.** - **Stage order is semantic AND performance-critical**: `$match` before `$group` filters early; `$match` after `$group` filters aggregated results (the equivalent of SQL HAVING). Putting `$sort` before `$limit` gives top-N; after, it sorts a truncated set. - **16 MB result limit** for a single returned document; large pipelines may need `allowDiskUse` (see `AggregationOptions`) for big `$group`/`$sort` that exceed the 100 MB in-memory stage limit. - **Type mapping**: computed fields not present on the output DTO are dropped during mapping — make sure DTO field names match the projected names. - Empty pipeline results return an empty list, not null. **When to use.** Aggregation is for analytics/reporting/reshaping that go beyond a simple find: totals per group, joins via `$lookup`, flattening arrays via `$unwind`, computed/derived fields. For simple filtered reads, a plain Query is lighter.

  • Why should $match come before $group rather than after?
    A $match before $group can use collection indexes and shrinks the document stream, so grouping does less work. A $match after $group filters on aggregated results (like SQL HAVING) and cannot use the source indexes — semantically different and usually slower.
  • After group("customerId"), how do you expose the key as a named field in your DTO?
    The key is stored under _id. Add a project stage: .and("_id").as("customerId").andExclude("_id"), so the output maps to a DTO field named customerId instead of _id.

saying these in an interview costs you the question

  • Placing $match after $group when they mean to filter source rows
  • Thinking group's key is a normal field name rather than _id
  • Manually prefixing $ on fields inside typed helpers like .sum("$amount")
  • Expecting getMappedResults() to return null (it returns empty list)
  • Assuming unlimited result size — ignoring the 16 MB doc / 100 MB stage limits

context