Skip to main content
unbound mongodb at scale

Projection Performance: Full Entity vs DTO

4 min read Chapter 15 of 72

Projection Performance: Full Entity vs DTO

The Symptom

The activity feed endpoint returns a list of 50 recent activities for a user. Each activity document in MongoDB has 22 fields, including embedded arrays for tags, reactions, and metadata. The API response only uses 6 of those fields: activityId, userId, type, title, timestamp, and thumbnailUrl. The endpoint has a p95 latency of 85ms, and network monitoring shows 450 KB transferred from MongoDB to the application per request.

The Cause

The application fetches the full document and then maps it to the response DTO in the service layer.

// SLOW: Fetches all 22 fields, maps all 22 fields, uses 6
public List<ActivityFeedItem> getActivityFeed(String userId) {
    List<Activity> activities = activityRepository
        .findByUserIdOrderByTimestampDesc(userId, PageRequest.of(0, 50));

    return activities.stream()
        .map(a -> new ActivityFeedItem(
            a.getActivityId(), a.getUserId(), a.getType(),
            a.getTitle(), a.getTimestamp(), a.getThumbnailUrl()
        ))
        .toList();
}

Each activity document is 9 KB on the wire (BSON encoded). 50 documents: 450 KB. After mapping, the application discards 16 fields per document. That is 73% wasted network transfer and 73% wasted mapping effort.

The Benchmark

@BenchmarkMode({Mode.Throughput, Mode.AverageTime})
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 5)
@Measurement(iterations = 5, time = 10)
@Fork(1)
@State(Scope.Benchmark)
public class ProjectionBenchmark {

    private MongoTemplate mongoTemplate;
    private MongoCollection<Document> rawCollection;

    @Setup
    public void setup() {
        ApplicationContext ctx = new AnnotationConfigApplicationContext(MongoConfig.class);
        mongoTemplate = ctx.getBean(MongoTemplate.class);
        MongoClient client = MongoClients.create("mongodb://localhost:27017");
        rawCollection = client.getDatabase("telemetry").getCollection("activities");
    }

    @Benchmark
    public List<Activity> fullEntity() {
        Query query = new Query(Criteria.where("userId").is("user-00001"))
            .with(Sort.by(Sort.Direction.DESC, "timestamp"))
            .limit(50);
        return mongoTemplate.find(query, Activity.class);
    }

    @Benchmark
    public List<ActivityFeedProjection> interfaceProjection() {
        Query query = new Query(Criteria.where("userId").is("user-00001"))
            .with(Sort.by(Sort.Direction.DESC, "timestamp"))
            .limit(50);
        query.fields()
            .include("activityId", "userId", "type", "title", "timestamp", "thumbnailUrl");
        return mongoTemplate.find(query, ActivityFeedProjection.class);
    }

    @Benchmark
    public List<Document> rawProjection() {
        return rawCollection.find(Filters.eq("userId", "user-00001"))
            .projection(Projections.include(
                "activityId", "userId", "type", "title", "timestamp", "thumbnailUrl"
            ))
            .sort(Sorts.descending("timestamp"))
            .limit(50)
            .into(new ArrayList<>());
    }
}

Where ActivityFeedProjection is a closed interface:

public interface ActivityFeedProjection {
    String getActivityId();
    String getUserId();
    String getType();
    String getTitle();
    Instant getTimestamp();
    String getThumbnailUrl();
}

Results:

Benchmark                              Mode  Cnt     Score     Error   Units
ProjectionBenchmark.fullEntity        thrpt    5  1200.000 ±  45.000  ops/s
ProjectionBenchmark.interfaceProjection thrpt  5  2800.000 ±  60.000  ops/s
ProjectionBenchmark.rawProjection     thrpt    5  3800.000 ±  55.000  ops/s

ProjectionBenchmark.fullEntity         avgt    5   830.000 ±  25.000  us/op
ProjectionBenchmark.interfaceProjection avgt   5   355.000 ±  12.000  us/op
ProjectionBenchmark.rawProjection      avgt    5   262.000 ±   9.000  us/op

Interface projection is 2.3x faster than full entity. The gains come from two sources: less data transferred from MongoDB (130 KB vs 450 KB for 50 documents) and fewer fields to map (6 vs 22).

The Fix

// FAST: Server-side projection reduces both network and mapping cost
public List<ActivityFeedItem> getActivityFeed(String userId) {
    Query query = new Query(Criteria.where("userId").is(userId))
        .with(Sort.by(Sort.Direction.DESC, "timestamp"))
        .limit(50);
    query.fields()
        .include("activityId", "userId", "type", "title", "timestamp", "thumbnailUrl");

    return mongoTemplate.find(query, ActivityFeedItem.class);
}
// DTO class (not a Spring Data entity)
public record ActivityFeedItem(
    String activityId,
    String userId,
    String type,
    String title,
    Instant timestamp,
    String thumbnailUrl
) {}

The fields().include() call adds a $project to the MongoDB query. The server returns only the requested fields. Spring Data maps only the 6 fields present in the response. The record class is lightweight with no JPA or Spring Data annotations.

The Proof

MetricFull entityDTO projection
Network per request450 KB130 KB
p50 latency42ms18ms
p95 latency85ms35ms
CPU at 1000 req/s72%41%
Allocation per request1.2 MB340 KB

The Trade-off

Projections prevent MongoDB from using covered queries in some cases. If a covering index includes all queried and projected fields, MongoDB serves the query entirely from the index without touching the documents. But if your projection requests a field not in the index, MongoDB must FETCH the document anyway, and the projection savings come only from network transfer reduction, not from I/O elimination. Design your projections to align with your indexes (covered in CH11).

Interface projections in Spring Data create proxy objects at runtime, which adds allocation overhead compared to record-based DTOs. For hot paths, prefer record classes with explicit field mapping over interface projections.