Java Records + MapStruct: Lean DTOs for Throughput
How immutable DTOs and compile-time mappers cut GC, reduce boilerplate, and keep p95 honest.
Use Java records with MapStruct to build lean, immutable DTOs and compile-time mappers. Less GC, fewer bugs, faster endpoints — here’s the playbook.
Let’s be real: most “performance problems” in APIs are death by a thousand DTOs. Mutable beans everywhere. Reflection everywhere. Tiny allocations that quietly dominate p95. Java records and MapStruct let you erase a big chunk of that overhead — without turning your codebase into a science project.
Why Records + MapStruct?
- Records = compact, immutable carriers with canonical constructors,
equals/hashCode/toStringfor free. Less surface area, fewer bugs, better inlining. - MapStruct = compile-time mapping (no reflection), generating tiny, direct assignments. It’s fast, predictable, and IDE-navigable.
Together they create DTO layers that are boringly efficient: minimal allocation, no setter storms, and clear ownership.
Architecture (Where It Fits)
[DB/Entities] --> [MapStruct Mappers] --> [DTO Records] --> [JSON/HTTP]
^ | ^ |
| | | |
(optional updates) @MappingTarget Jackson/Micronaut Controllers- Entities remain mutable (JPA likes that).
- The boundary to the network is immutable records.
- MapStruct lives in the middle, generating bytecode you can read.
The Minimal Setup
Gradle (Kotlin DSL):
dependencies {
implementation("org.mapstruct:mapstruct:1.6.0")
annotationProcessor("org.mapstruct:mapstruct-processor:1.6.0")
// JSON (pick one)
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2")
implementation("com.fasterxml.jackson.module:jackson-module-parameter-names:2.17.2")
}Enable parameter names in Jackson so it can call the canonical record constructor.
Define Lean DTOs with Records
// src/main/java/app/dto/OrderDto.java
package app.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record OrderDto(
String id,
String customerId,
List<OrderLineDto> lines,
BigDecimal total,
Instant createdAt
) {}
public record OrderLineDto(
String sku,
int quantity,
BigDecimal unitPrice
) {}No setters. No builder. No surprise nulls at 2 a.m.
MapStruct: Compile-Time Mappers
// src/main/java/app/mapper/OrderMapper.java
package app.mapper;
import app.dto.*;
import app.model.*;
import org.mapstruct.*;
@Mapper(
componentModel = "spring", // or "default", "jakarta", "cdi"
unmappedTargetPolicy = ReportingPolicy.ERROR,
injectionStrategy = InjectionStrategy.CONSTRUCTOR
)
public interface OrderMapper {
OrderDto toDto(Order order);
@Mapping(target = "id", ignore = true) // let DB assign
Order toEntity(OrderDto dto);
// Derived fields
@AfterMapping
default void computeTotal(@MappingTarget OrderDto.Builder? ignored, Order source) { /* not needed for records */ }
// Update-in-place mapping (e.g., PATCH)
void updateEntity(OrderDto dto, @MappingTarget Order entity);
}For nested mapping, MapStruct will generate calls to other mappers if you declare them in uses = { ... }.
Tip: Set
unmappedTargetPolicy = ERROR. It catches drift early, during CI, not in production.
Mapping to/from Records (the tiny gotcha)
MapStruct understands records. It will call the canonical constructor using component names. If your record component name doesn’t match the entity field, use @Mapping:
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerMapper {
@Mappings({
@Mapping(target = "id", source = "customerId"),
@Mapping(target = "createdAt", source = "registered")
})
CustomerDto toDto(Customer entity);
}No reflection. No runtime surprises.
JSON: Records Play Nicely
With Jackson 2.12+ and jackson-module-parameter-names, records serialize/deserialize out of the box.
@Configuration
class JacksonConfig {
@Bean ObjectMapper objectMapper() {
return JsonMapper.builder()
.addModule(new ParameterNamesModule())
.build();
}
}Prefer constructor-based deserialization for records. It guarantees full initialization and keeps your DTOs truly immutable.
Real-World Example: Hot Path Order Ingestion
The Problem
An ingestion API receives batches of orders. The old stack used Lombok beans + reflection mappers. Under bursty load, p95 spiked from 78 ms → 140 ms with GC churn.
The Change
- Switched network DTOs to records (
OrderDto,OrderLineDto). - Introduced MapStruct mappers for entity<->DTO.
- Moved derived field
totalcomputation to the entity service layer; DTO carries the already computed total. - Added
@MappingTargetupdate mapping for PATCH semantics to avoid reallocation
The Result
- Allocation down ~18% (measured via JFR allocation profile).
- p95 back to ~82 ms at the same RPS.
- CPU dropped ~8–10% on the API nodes.
(Numbers are illustrative; measure in your environment.)
The headline: cutting reflection and setters on a hot path is boring — and that’s exactly why it works.
Patterns That Pay Off
1) “Narrow DTOs” with View-Specific Records
Create different records per view, not one mega-DTO.
public record OrderSummaryDto(String id, BigDecimal total, Instant createdAt) {}
public record OrderDetailDto(String id, String customerId, List<OrderLineDto> lines, BigDecimal total, Instant createdAt) {}MapStruct generates two mapping methods. Network pays for what it uses.
2) Update-In-Place with @MappingTarget
Keeps entity identity stable (good for JPA and caches).
@Mapper(componentModel = "spring")
public interface ProductMapper {
void update(@MappingTarget Product target, ProductPatchDto patch);
}3) Enum, Value Objects, and Nulls
- Use
@EnumMappingfor name/ordinal conversions. - For value objects (e.g.,
Money), add a tiny helper mapper. - Configure null behavior explicitly:
@Mapper(nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
interface PatchMapper { /* ... */ }4) Error Surfacing Early
Fail CI on unmapped properties; it’s your best defense against schema drift.
Micro-Benchmarking the Mapper (Sanity Only)
Don’t overfit a micro-bench, but do a quick pulse check to catch regressions.
// Pseudocode JMH snippet
@Benchmark
public OrderDto map100Lines() {
return mapper.toDto(fixtures.largeOrder());
}Watch: ops/sec, alloc/op, p99. If a “refactor” doubles allocations, stop it before it merges.
Common Questions (and Straight Answers)
“Are records faster than Lombok?”
Records aren’t magic; they remove mutability and setters, making objects smaller and easier for the JIT to inline. You also avoid builder allocations. In practice, it helps.
Get Modexa’s stories in your inbox
Join Medium for free to get updates from this writer.
“MapStruct vs. ModelMapper/Dozer?”
MapStruct wins for hot paths because it generates direct code. Reflection mappers are convenient, until the bill shows up at p95.
“But I need custom logic.”
Great — MapStruct supports expression, qualifiedByName, @AfterMapping, and custom mappers. Keep business logic out of mappers; use hooks only for glue.
“Can I keep JPA entities as records?”
No. JPA wants proxies and mutability. Keep entities as classes; use records at the edge.
Code You Can Paste (End-to-End)
Entity (simplified):
@Entity
public class Order {
@Id String id;
String customerId;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
List<OrderLine> lines = new ArrayList<>();
BigDecimal total;
Instant createdAt;
// getters/setters omitted
}DTO Records:
public record OrderDto(
String id, String customerId, List<OrderLineDto> lines, BigDecimal total, Instant createdAt
) {}
public record OrderLineDto(String sku, int quantity, BigDecimal unitPrice) {}Mapper:
@Mapper(componentModel = "spring", uses = { OrderLineMapper.class },
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface OrderMapper {
OrderDto toDto(Order entity);
@Mapping(target = "id", ignore = true)
Order toEntity(OrderDto dto);
void updateFromDto(OrderDto dto, @MappingTarget Order entity);
}
@Mapper(componentModel = "spring")
interface OrderLineMapper {
OrderLineDto toDto(OrderLine e);
OrderLine toEntity(OrderLineDto d);
}Controller (Spring):
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
class OrderController {
private final OrderService service;
private final OrderMapper mapper;
@PostMapping
OrderDto create(@RequestBody OrderDto dto) {
var entity = mapper.toEntity(dto);
return mapper.toDto(service.save(entity));
}
@PatchMapping("/{id}")
OrderDto patch(@PathVariable String id, @RequestBody OrderDto dto) {
var entity = service.find(id);
mapper.updateFromDto(dto, entity);
return mapper.toDto(service.save(entity));
}
}When Not to Use Records
- You genuinely need mutable DTOs for streaming builders or incremental rendering.
- Your framework lacks good record support for binding (rare in 2025, but check your stack).
- You’re mapping gigantic graphs where mapping cost is dwarfed by I/O — optimize the I/O first.
Conclusion
Records give you clarity and immutability. MapStruct gives you speed and safety. Together they compress your DTO layer into something the JIT likes and your team can reason about. Start with the hot endpoints, flip DTOs to records, replace reflection with MapStruct, and make your p95 boring again.
If this was useful, drop a comment with your stack (Spring, Quarkus, Micronaut?), follow for more performance-first Java guides, and tell me which deep-dive you want next — JIT warm-up tactics, GC tuning, or HTTP/JSON micro-optimizations.










