Java Records Deserve a Mapper Built for Them (And We’re Still Using Tools From 2015)
It was a quiet refactoring session in 2022. I replaced a bulky immutable DTO class — complete with private final fields, all-args…
Java Records Deserve a Mapper Built for Them (And We’re Still Using Tools From 2015)

It was a quiet refactoring session in 2022. I replaced a bulky immutable DTO class — complete with private final fields, all-args constructor, getters, equals, hashCode, and toString — with a single-line record UserResponse(String id, String name, String email). The code became dramatically cleaner. My IDE was happy. My soul felt lighter.
Then came the mapping layer.
Suddenly, MapStruct started complaining. ModelMapper behaved strangely with immutability. I found myself writing manual mapping methods again — the very thing Records were supposed to save me from.
After three years of working with Records in production, I’ve reached a simple conclusion: Java Records are beautiful, but our mapping tools haven’t caught up. They deserve better.
This article is my love letter to Records — and a practical guide to mapping them properly in 2026.
The Promise vs Reality
Records solved so many problems elegantly:
- True immutability by default
- Built-in equals, hashCode, and toString
- Compact syntax that reduces boilerplate dramatically
But mapping libraries were built for the old JavaBean world — mutable objects with setters, no-args constructors, and getters. When you feed them Records, things get awkward fast.
I’ve seen teams do one of three things:
- Force Records into old mappers (painful, hacky)
- Write manual mapping code (verbose, error-prone)
- Avoid Records for DTOs (the saddest option)
None of these feel right.
A Better Way: Mapping Designed for Records
Here’s what actually works well in 2026:
Option 1: Constructor-based Mapping with Records
Records shine when you lean into their canonical constructor:
record UserResponse(String id, String fullName, String email) {
UserResponse map(User user) {
return new UserResponse(
user.getId(),
user.getFirstName() + " " + user.getLastName(),
user.getEmail()
);
}}
Option 2: MapStruct 1.6+ with Record Support
The latest MapStruct versions understand Records much better. You can now do:
@Mapper
public interface UserMapper {
UserResponse toResponse(User user);
@Mapping(target = "fullName", expression = "java(user.getFirstName() + \" \" + user.getLastName())")
UserResponse toResponseWithFullName(User user);
}
Option 3: My Favorite — Small Custom Mappers
For complex domains, I’ve started building tiny, focused mapper classes that feel natural with Records:
@Component
public class OrderMapper {
public OrderResponse toResponse(Order order) {
return new OrderResponse(
order.id(),
order.items().stream().map(this::toItemResponse).toList(),
order.totalAmount(),
order.status()
);
}
private OrderItemResponse toItemResponse(OrderItem item) {
return new OrderItemResponse(item.productId(), item.quantity(), item.price());
}
}
The Deeper Lesson
Records aren’t just a syntax improvement. They represent a philosophical shift toward immutability and simplicity in our domain models.
When we try to force them into tools designed for mutable JavaBeans, we lose the magic. The solution isn’t to abandon Records — it’s to evolve our mapping strategies to honor their strengths.
The best code I write in 2026 feels calm. Records help create that calmness. When mapping respects that calmness, the entire system feels better.
메타데이터
- post_id
- 274a25b07ff3
- slug
- java-records-deserve-a-mapper-built-for-them-and-were-still-using-tools-from-2015-274a25b07ff3
- url
- https://medium.com/@ntiinsd/java-records-deserve-a-mapper-built-for-them-and-were-still-using-tools-from-2015-274a25b07ff3
- canonical_url
- https://medium.com/@ntiinsd/java-records-deserve-a-mapper-built-for-them-and-were-still-using-tools-from-2015-274a25b07ff3
- author_url
- https://medium.com/@ntiinsd
- status
- ok
- fetched_at
- 2026-06-15 20:49:13