← Kursa Dön
📄 Text · 15 min

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

ÖzellikManuel MappingModelMapperMapStruct
MekanizmaElle yazılmış kodRuntime reflectionCompile-time code gen
PerformanceHızlı (elle yazılmış)Yavaş (reflection)Hızlı (generated code)
Tip güvenliğiKısmi (compiler kontrol etmez)Yok (runtime hatalar)Tam (compile-time)
Yeni alan eklemeUnutulabilirOtomatik (riskli)Uyarı verir
Öğrenme eğrisiYokDüşükOrta
DebugKolayZor (reflection)Kolay (generated code)
Spring entegrasyonuElleKonfigü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 edilebilir

2. ❌ 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 ekle

3. ❌ 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.java

Mapping 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 kaydedilir

  • Aynı isimli alanlar otomatik eşleşir, farklı isimler için @Mapping(target, source) kullanılır

  • expression, dateFormat, numberFormat, constant, ignore ile esnek mapping

  • @MappingTarget ile mevcut entity güncellenir (update/patch senaryoları)

  • nullValuePropertyMappingStrategy = IGNORE → PATCH operasyonları için null alanları atlar

  • Lombok kullanıyorsanız lombok-mapstruct-binding dependency'si zorunludur

  • Generated code'u (target/generated-sources) kontrol edin — debug edilebilir

  • İleri kullanım (custom qualifier, decorator, condition) için bir sonraki derse bakın