MapStruct ile DTO Mapping
Manuel DTO mapping ilk birkaç entity'de sorun olmaz. Ama 20 entity, 40 DTO, 60 mapping metodu yazıldığında durum değişir: bir alan eklediğinizde 5 farklı mapper'ı güncellemeniz, alanların doğru eşleştiğini doğrulamanız, null check'leri unutmamanız gerekir. Bu noktada MapStruct devreye girer.
MapStruct, compile-time'da tip güvenli mapping kodu üreten bir Java annotation processor'dır. Siz sadece bir interface yazarsınız — MapStruct, derleme sırasında tam çalışır implementasyon sınıfını otomatik üretir. Runtime'da reflection yok, performans kaybı yok, tip güvenliği tam.
Neden MapStruct?
Manuel Mapping'in Sorunu
// 20 alanlı bir entity...
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getFirstName(), // firstName mi name mi? Karıştırma riski
user.getLastName(),
user.getEmail(),
user.getPhone(),
user.getAvatarUrl(),
user.getDepartment() != null ? user.getDepartment().getName() : null,
user.getCreatedAt(),
user.getUpdatedAt()
// ... 11 alan daha
// Yeni alan eklenince buraya ekle — unutulması kaçınılmaz
);
}MapStruct ile Aynı İş
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "departmentName", source = "department.name")
UserResponse toResponse(User user);
// MapStruct, eşleşen alan adlarını OTOMATİK map eder
// Yeni alan eklenince compile-time'da uyarı verir
}Karşılaştırma Tablosu
| Özellik | Manuel Mapping | ModelMapper | MapStruct |
|---|---|---|---|
| Mekanizma | Elle yazılmış kod | Runtime reflection | Compile-time code gen |
| Performance | Hızlı (elle yazılmış) | Yavaş (reflection) | Hızlı (generated code) |
| Tip güvenliği | Kısmi (compiler kontrol etmez) | Yok (runtime hatalar) | Tam (compile-time) |
| Yeni alan ekleme | Unutulabilir | Otomatik (riskli) | Uyarı verir |
| Öğrenme eğrisi | Yok | Düşük | Orta |
| Debug | Kolay | Zor (reflection) | Kolay (generated code) |
| Spring entegrasyonu | Elle | Konfigürasyon | @Mapper(componentModel="spring") |
Kurulum
Maven (pom.xml)
<properties>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<!-- MapStruct processor -->
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<!-- Lombok kullanıyorsanız (sıralama önemli!) -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>⚠️ Dikkat: Lombok kullanıyorsanız lombok-mapstruct-binding dependency'si zorunludur. Ayrıca annotation processor sıralamasında önce Lombok, sonra MapStruct gelmelidir. Aksi halde MapStruct, Lombok'un ürettiği getter/setter'ları göremez.
Gradle (build.gradle)
dependencies {
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
// Lombok kullanıyorsanız
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
}Temel Kullanım
Entity ve DTO'lar
// Entity
@Entity
@Getter @Setter
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String email;
private String passwordHash;
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
@ManyToMany(fetch = FetchType.LAZY)
private Set<Role> roles;
}
// Response DTO
public record UserResponse(
Long id,
String firstName,
String lastName,
String fullName,
String email,
String departmentName,
List<String> roleNames,
String memberSince
) {}
// Request DTO
public record CreateUserRequest(
@NotBlank String firstName,
@NotBlank String lastName,
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@NotNull Long departmentId
) {}Mapper Interface
@Mapper(componentModel = "spring")
public interface UserMapper {
// ─── Entity → Response DTO ───
@Mapping(target = "fullName",
expression = "java(user.getFirstName() + \" \" + user.getLastName())")
@Mapping(target = "departmentName", source = "department.name")
@Mapping(target = "roleNames", expression = "java(mapRoleNames(user.getRoles()))")
@Mapping(target = "memberSince", source = "createdAt",
dateFormat = "dd.MM.yyyy")
UserResponse toResponse(User user);
// Liste mapping — otomatik! Her elemanı toResponse() ile map eder
List<UserResponse> toResponseList(List<User> users);
// ─── Request DTO → Entity ───
@Mapping(target = "id", ignore = true) // Auto-generated
@Mapping(target = "passwordHash", ignore = true) // Service'te set edilecek
@Mapping(target = "createdAt", ignore = true) // @CreationTimestamp
@Mapping(target = "department", ignore = true) // Service'te set edilecek
@Mapping(target = "roles", ignore = true) // Service'te set edilecek
User toEntity(CreateUserRequest request);
// ─── Update (mevcut entity'ye DTO'dan değerleri kopyala) ───
@Mapping(target = "id", ignore = true)
@Mapping(target = "passwordHash", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "department", ignore = true)
@Mapping(target = "roles", ignore = true)
void updateEntity(UpdateUserRequest request, @MappingTarget User user);
// ─── Helper Method ───
default List<String> mapRoleNames(Set<Role> roles) {
if (roles == null) return List.of();
return roles.stream()
.map(Role::getName)
.sorted()
.toList();
}
}Üretilen Kod (Generated)
MapStruct, compile-time'da target/generated-sources dizinine UserMapperImpl sınıfını üretir:
// MapStruct tarafından OTOMATİK üretilir — elle yazmayın
@Component
public class UserMapperImpl implements UserMapper {
@Override
public UserResponse toResponse(User user) {
if (user == null) return null;
return new UserResponse(
user.getId(),
user.getFirstName(),
user.getLastName(),
user.getFirstName() + " " + user.getLastName(), // expression
user.getEmail(),
user.getDepartment() != null ? user.getDepartment().getName() : null,
mapRoleNames(user.getRoles()),
new SimpleDateFormat("dd.MM.yyyy").format(user.getCreatedAt()),
// ...
);
}
// ... diğer metotlar
}Bu kod:
Reflection kullanmaz — saf Java kodu
Null check'ler otomatik
Debug edilebilir (IDE'de step into yapabilirsiniz)
@Component→ Spring bean olarak kullanılabilir
@Mapping Detayları
Alan Adı Eşleştirme
// 1. Otomatik eşleştirme — aynı isimli alanlar
// user.email → UserResponse.email (otomatik)
UserResponse toResponse(User user);
// 2. Farklı isimli alanlar
@Mapping(target = "userName", source = "name")
UserResponse toResponse(User user);
// 3. İç içe erişim (dot notation)
@Mapping(target = "departmentName", source = "department.name")
@Mapping(target = "cityName", source = "address.city.name")
UserResponse toResponse(User user);
// 4. Sabit değer
@Mapping(target = "status", constant = "ACTIVE")
@Mapping(target = "version", constant = "1")
UserResponse toResponse(User user);
// 5. Java expression
@Mapping(target = "fullName",
expression = "java(user.getFirstName() + \" \" + user.getLastName())")
@Mapping(target = "age",
expression = "java(java.time.Period.between(user.getBirthDate(), java.time.LocalDate.now()).getYears())")
UserResponse toResponse(User user);
// 6. ignore — hedef alanı map etme
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
User toEntity(CreateUserRequest request);
// 7. Tarih formatı
@Mapping(target = "createdDate", source = "createdAt", dateFormat = "dd/MM/yyyy")
UserResponse toResponse(User user);
// 8. Sayı formatı
@Mapping(target = "priceFormatted", source = "price", numberFormat = "#,###.00")
ProductResponse toResponse(Product product);@MappingTarget — Mevcut Nesneyi Güncelleme
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "passwordHash", ignore = true)
@Mapping(target = "createdAt", ignore = true)
void updateEntity(UpdateUserRequest request, @MappingTarget User user);
}
// Service'te:
@Transactional
public UserResponse update(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
userMapper.updateEntity(request, user); // Mevcut entity güncellenir
// JPA dirty checking → otomatik save
return userMapper.toResponse(user);
}Null Value Stratejisi
@Mapper(
componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
// null alanlar → mevcut değeri koru (PATCH için ideal)
)
public interface UserMapper {
@Mapping(target = "id", ignore = true)
void patchEntity(PatchUserRequest request, @MappingTarget User user);
// request.name = null → user.name DEĞİŞMEZ
// request.name = "Ahmet" → user.name = "Ahmet"
}Spring Entegrasyonu
componentModel = "spring" ile MapStruct mapper'ı Spring bean olarak kaydedilir:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper; // Spring @Autowired ile inject
public UserResponse findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
return userMapper.toResponse(user);
}
@Transactional
public UserResponse create(CreateUserRequest request) {
User user = userMapper.toEntity(request);
user.setPasswordHash(passwordEncoder.encode(request.password()));
return userMapper.toResponse(userRepository.save(user));
}
public Page<UserResponse> findAll(Pageable pageable) {
return userRepository.findAll(pageable)
.map(userMapper::toResponse); // Method reference ile
}
}Birden Fazla Kaynak Nesneden Mapping
@Mapper(componentModel = "spring")
public interface OrderMapper {
@Mapping(target = "customerName", source = "user.name")
@Mapping(target = "customerEmail", source = "user.email")
@Mapping(target = "orderId", source = "order.id")
@Mapping(target = "totalAmount", source = "order.totalAmount")
@Mapping(target = "itemCount", expression = "java(order.getItems().size())")
OrderSummaryResponse toSummary(Order order, User user);
}
// Kullanım:
OrderSummaryResponse summary = orderMapper.toSummary(order, user);Custom Method ve Default Method
MapStruct, karmaşık dönüşümler için default method'lar kullanmanıza izin verir:
@Mapper(componentModel = "spring")
public interface ProductMapper {
@Mapping(target = "discountedPrice", ignore = true)
@Mapping(target = "categoryPath", ignore = true)
ProductResponse toResponse(Product product);
// Default method ile post-processing
default ProductResponse toResponseWithCalculations(Product product) {
ProductResponse response = toResponse(product);
// record ise yeni instance oluşturmanız gerekir
// class ise setter kullanabilirsiniz
return new ProductResponse(
response.id(),
response.name(),
response.price(),
calculateDiscountedPrice(product),
buildCategoryPath(product.getCategory())
);
}
default BigDecimal calculateDiscountedPrice(Product product) {
if (product.getDiscount() == null) return product.getPrice();
return product.getPrice()
.subtract(product.getPrice().multiply(product.getDiscount())
.divide(BigDecimal.valueOf(100)));
}
default String buildCategoryPath(Category category) {
if (category == null) return "";
List<String> path = new ArrayList<>();
Category current = category;
while (current != null) {
path.add(0, current.getName());
current = current.getParent();
}
return String.join(" > ", path);
}
}@AfterMapping ve @BeforeMapping
MapStruct, mapping öncesi ve sonrası hook'lar sunar:
@Mapper(componentModel = "spring")
public abstract class UserMapper {
@Mapping(target = "departmentName", source = "department.name")
public abstract UserResponse toResponse(User user);
@AfterMapping
protected void enrichResponse(User user, @MappingTarget UserResponse.Builder builder) {
// Response oluşturulduktan sonra ek mantık
if (user.getBalance().compareTo(VIP_THRESHOLD) > 0) {
builder.vip(true);
}
}
@BeforeMapping
protected void validateUser(User user) {
// Mapping öncesi kontrol
if (user == null) {
throw new IllegalArgumentException("User cannot be null");
}
}
}Enum Mapping
MapStruct, enum dönüşümlerini de otomatik yapar:
// Kaynak enum
public enum OrderStatus {
PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED
}
// Hedef enum (farklı isimler)
public enum OrderStatusDTO {
BEKLEMEDE, ISLENIYOR, KARGODA, TESLIM_EDILDI, IPTAL
}
@Mapper(componentModel = "spring")
public interface OrderMapper {
@ValueMapping(source = "PENDING", target = "BEKLEMEDE")
@ValueMapping(source = "PROCESSING", target = "ISLENIYOR")
@ValueMapping(source = "SHIPPED", target = "KARGODA")
@ValueMapping(source = "DELIVERED", target = "TESLIM_EDILDI")
@ValueMapping(source = "CANCELLED", target = "IPTAL")
OrderStatusDTO toStatusDTO(OrderStatus status);
// Aynı isimliyse otomatik eşleşir — @ValueMapping gerekmez
// OrderStatus toStatus(OrderStatus status);
}Test Etme
MapStruct mapper'larını test etmek kolaydır — generated code saf Java'dır:
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = {UserMapperImpl.class})
class UserMapperTest {
@Autowired
private UserMapper userMapper;
@Test
void shouldMapEntityToResponse() {
// Given
User user = new User();
user.setId(1L);
user.setFirstName("Ahmet");
user.setLastName("Yılmaz");
user.setEmail("ahmet@example.com");
user.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30));
Department dept = new Department();
dept.setName("Yazılım");
user.setDepartment(dept);
// When
UserResponse response = userMapper.toResponse(user);
// Then
assertThat(response.id()).isEqualTo(1L);
assertThat(response.firstName()).isEqualTo("Ahmet");
assertThat(response.fullName()).isEqualTo("Ahmet Yılmaz");
assertThat(response.email()).isEqualTo("ahmet@example.com");
assertThat(response.departmentName()).isEqualTo("Yazılım");
assertThat(response.memberSince()).isEqualTo("15.01.2024");
}
@Test
void shouldMapRequestToEntity() {
CreateUserRequest request = new CreateUserRequest(
"Mehmet", "Kaya", "mehmet@example.com", "Pass1234", 1L);
User user = userMapper.toEntity(request);
assertThat(user.getId()).isNull(); // ignore edildi
assertThat(user.getFirstName()).isEqualTo("Mehmet");
assertThat(user.getPasswordHash()).isNull(); // ignore edildi
}
@Test
void shouldHandleNullInput() {
UserResponse response = userMapper.toResponse(null);
assertThat(response).isNull();
}
@Test
void shouldUpdateExistingEntity() {
User user = new User();
user.setId(1L);
user.setFirstName("Eski İsim");
user.setCreatedAt(LocalDateTime.now());
UpdateUserRequest request = new UpdateUserRequest("Yeni İsim", "yeni@email.com");
userMapper.updateEntity(request, user);
assertThat(user.getId()).isEqualTo(1L); // Değişmedi
assertThat(user.getFirstName()).isEqualTo("Yeni İsim"); // Güncellendi
assertThat(user.getCreatedAt()).isNotNull(); // Değişmedi
}
}Yaygın Hatalar
1. ❌ componentModel Belirtmemek
// ❌ YANLIŞ — Spring bean olarak kaydedilmez
@Mapper
public interface UserMapper { }
// → @Autowired ile inject EDILEMEZ
// ✅ DOĞRU
@Mapper(componentModel = "spring")
public interface UserMapper { }
// → @Component olarak kaydedilir, inject edilebilir2. ❌ Lombok + MapStruct Binding Eksik
// Hata: "No property named 'firstName' exists in source..."
// Sebep: MapStruct, Lombok'un getter'larını göremez
// Çözüm: lombok-mapstruct-binding ekle3. ❌ Eşleşmeyen Alanlar için Uyarıyı Görmezden Gelmek
// MapStruct compile-time uyarı verir:
// "Unmapped target property: 'createdAt'"
// Bu uyarıyı görmezden gelmeyin!
// ✅ Açıkça ignore edin:
@Mapping(target = "createdAt", ignore = true)
User toEntity(CreateUserRequest request);4. ❌ Generated Code'u Kontrol Etmemek
target/generated-sources/annotations/com/example/mapper/UserMapperImpl.javaMapping beklendiği gibi çalışmıyorsa, generated code'u IDE'de açıp inceleyin. Debug bile yapabilirsiniz!
Özet
MapStruct, compile-time'da type-safe DTO mapping kodu üretir — reflection yok, performans kaybı yok
@Mapper(componentModel = "spring")ile Spring bean olarak kaydedilirAynı isimli alanlar otomatik eşleşir, farklı isimler için
@Mapping(target, source)kullanılırexpression,dateFormat,numberFormat,constant,ignoreile esnek mapping@MappingTargetile mevcut entity güncellenir (update/patch senaryoları)nullValuePropertyMappingStrategy = IGNORE→ PATCH operasyonları için null alanları atlarLombok kullanıyorsanız
lombok-mapstruct-bindingdependency'si zorunludurGenerated code'u (
target/generated-sources) kontrol edin — debug edilebilirİleri kullanım (custom qualifier, decorator, condition) için bir sonraki derse bakın
AI Asistan
Sorularını yanıtlamaya hazır