Projection Performance: Full Entity vs DTO
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
| Metric | Full entity | DTO projection |
|---|---|---|
| Network per request | 450 KB | 130 KB |
| p50 latency | 42ms | 18ms |
| p95 latency | 85ms | 35ms |
| CPU at 1000 req/s | 72% | 41% |
| Allocation per request | 1.2 MB | 340 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.