← Kursa Dön
📄 Text · 20 min

MapStruct İleri Kullanım

Giriş — Mapping Neden Bu Kadar Önemli?

Her Spring Boot uygulamasında entity'ler ve DTO'lar arasında veri taşırsın. User entity'si veritabanından gelir, UserDTO API'den döner. Arada dönüşüm yapmak gerekir. Bu dönüşüm basit görünür ama 50 field'lı bir entity'de elle getter/setter yazmak hem sıkıcı hem hata kaynağı.

MapStruct bu sorunu compile-time code generation ile çözer. Runtime'da reflection yok, performans native Java kodu ile aynı. Temel kullanımı zaten biliyorsun — @Mapper, @Mapping, basit field mapping. Bu derste ileri konulara dalıyoruz: nested mapping, collection'lar, update mapping, multiple source, expression'lar ve daha fazlası.


1. Nested Object Mapping

Gerçek uygulamalarda entity'ler genellikle iç içe (nested) nesneler içerir. Bir Order entity'si Customer ve List<OrderItem> içerir. Bunların hepsini DTO'lara map'lemek gerekir.

Senaryo: Order → OrderDTO

// Entity'ler
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderNumber;
    private LocalDateTime createdAt;
    private OrderStatus status;
    
    @ManyToOne
    private Customer customer;
    
    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;
    
    private BigDecimal totalAmount;
    
    // Getter/setter...
}

@Entity
public class Customer {
    @Id
    private Long id;
    private String firstName;
    private String lastName;
    private String email;
    private String phone;
    // Getter/setter...
}

@Entity
public class OrderItem {
    @Id
    private Long id;
    private String productName;
    private int quantity;
    private BigDecimal unitPrice;
    private BigDecimal subtotal;
    // Getter/setter...
}
// DTO'lar
public class OrderDTO {
    private Long id;
    private String orderNumber;
    private String status;
    private String customerName;      // customer.firstName + " " + customer.lastName
    private String customerEmail;     // customer.email
    private List<OrderItemDTO> items; // Nested liste
    private BigDecimal totalAmount;
    private String createdAt;         // Formatted date
    // Getter/setter...
}

public class OrderItemDTO {
    private String productName;
    private int quantity;
    private BigDecimal unitPrice;
    private BigDecimal subtotal;
    // Getter/setter...
}

Mapper

@Mapper(componentModel = "spring")
public interface OrderMapper {

    @Mapping(target = "customerName", expression = "java(order.getCustomer().getFirstName() + \" \" + order.getCustomer().getLastName())")
    @Mapping(target = "customerEmail", source = "customer.email")
    @Mapping(target = "status", source = "status")  // Enum → String otomatik
    @Mapping(target = "createdAt", dateFormat = "dd.MM.yyyy HH:mm")
    OrderDTO toDTO(Order order);

    OrderItemDTO toItemDTO(OrderItem item);

    List<OrderDTO> toDTOList(List<Order> orders);
}

MapStruct burada birkaç şeyi otomatik yapar:

  1. `List<OrderItem>` → `List<OrderItemDTO>`: toItemDTO() metodunu bulur ve her eleman için çağırır

  2. `OrderStatus` enum → `String`: toString() kullanır

  3. `customer.email` → `customerEmail`: Nested field erişimi dot notation ile

  4. `LocalDateTime` → `String`: dateFormat ile format belirtilir

💡 İpucu: MapStruct nested object'ler için ayrı mapper metodu tanımladığında, List<A>List<B> dönüşümünü otomatik olarak o metodu kullanarak yapar. Ekstra bir şey yazman gerekmez.


2. Collection Mapping

Otomatik Liste Dönüşümü

@Mapper(componentModel = "spring")
public interface ProductMapper {

    ProductDTO toDTO(Product product);

    // MapStruct bu metodu gördüğünde, yukarıdaki toDTO'yu
    // her eleman için çağıran kodu otomatik üretir
    List<ProductDTO> toDTOList(List<Product> products);

    // Set de çalışır
    Set<ProductDTO> toDTOSet(Set<Product> products);
}

MapStruct'ın ürettiği kod:

// Generated code (compile sonrası target klasöründe görebilirsin)
@Override
public List<ProductDTO> toDTOList(List<Product> products) {
    if (products == null) {
        return null;
    }
    
    List<ProductDTO> list = new ArrayList<>(products.size());
    for (Product product : products) {
        list.add(toDTO(product));
    }
    return list;
}

Map Dönüşümü

@Mapper(componentModel = "spring")
public interface CategoryMapper {

    @MapMapping(keyDateFormat = "yyyy-MM-dd")
    Map<String, CategoryDTO> toCategoryMap(Map<String, Category> categories);
}

Stream Desteği

@Mapper(componentModel = "spring")
public interface UserMapper {

    UserDTO toDTO(User user);

    // Stream girdi
    default List<UserDTO> toDTOList(Stream<User> users) {
        return users.map(this::toDTO).collect(Collectors.toList());
    }
}

⚠️ Dikkat: MapStruct null collection'ları null olarak döndürür, boş liste değil. Bu NPE riski taşır. Bunu değiştirmek için mapper'a nullValueMappingStrategy ekle:

@Mapper(
    componentModel = "spring",
    nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT
)
public interface ProductMapper {
    // Artık null liste gönderilince boş liste döner
    List<ProductDTO> toDTOList(List<Product> products);
}

3. @AfterMapping ve @BeforeMapping

Bazen mapping sırasında custom logic gerekir. Hesaplama, validasyon, format dönüşümü... Bu dekoratör pattern'ine benzer.

@AfterMapping: Mapping Sonrası İşlem

@Mapper(componentModel = "spring")
public interface InvoiceMapper {

    @Mapping(target = "taxAmount", ignore = true)      // AfterMapping'de hesaplanacak
    @Mapping(target = "displayStatus", ignore = true)  // AfterMapping'de set edilecek
    InvoiceDTO toDTO(Invoice invoice);

    @AfterMapping
    default void calculateDerivedFields(Invoice source, @MappingTarget InvoiceDTO target) {
        // Vergi hesapla
        if (target.getSubtotal() != null) {
            BigDecimal tax = target.getSubtotal().multiply(new BigDecimal("0.20"));
            target.setTaxAmount(tax);
        }
        
        // Durum metnini Türkçe'ye çevir
        switch (source.getStatus()) {
            case PAID -> target.setDisplayStatus("Ödendi ✓");
            case PENDING -> target.setDisplayStatus("Bekliyor");
            case OVERDUE -> target.setDisplayStatus("Gecikmiş ⚠️");
            case CANCELLED -> target.setDisplayStatus("İptal");
        }
    }
}

@BeforeMapping: Mapping Öncesi İşlem

@Mapper(componentModel = "spring")
public interface UserMapper {

    UserDTO toDTO(User user);

    @BeforeMapping
    default void validateUser(User user) {
        if (user == null) {
            throw new IllegalArgumentException("User null olamaz");
        }
        if (user.getEmail() == null || user.getEmail().isBlank()) {
            throw new IllegalStateException("Email boş olamaz");
        }
    }

    @AfterMapping
    default void maskSensitiveData(@MappingTarget UserDTO dto) {
        // Email'i maskele: ali@test.com → a***@test.com
        if (dto.getEmail() != null && dto.getEmail().contains("@")) {
            String[] parts = dto.getEmail().split("@");
            String masked = parts[0].charAt(0) + "***@" + parts[1];
            dto.setMaskedEmail(masked);
        }
        
        // Telefonu maskele: 532-xxx-xx89
        if (dto.getPhone() != null && dto.getPhone().length() >= 4) {
            String last4 = dto.getPhone().substring(dto.getPhone().length() - 2);
            dto.setMaskedPhone("***-***-**" + last4);
        }
    }
}

Sıralama

1. @BeforeMapping çalışır (validation, preprocessing)
2. Field mapping'ler çalışır (source → target kopyalama)
3. @AfterMapping çalışır (derived fields, postprocessing)

💡 İpucu: @AfterMapping metodları @MappingTarget parametresiyle hedef nesneyi alır. Bu nesne üzerinde field'ları değiştirebilirsin. @BeforeMapping daha çok validasyon veya source nesneyi hazırlamak için kullanılır.


4. Update Mapping: @MappingTarget

PATCH endpoint'lerinde entity'nin sadece belirli field'larını güncellemek istersin. MapStruct'ın @MappingTarget özelliği mevcut bir nesneyi günceller — yeni nesne oluşturmaz.

Senaryo: PATCH /users/{id}

// Güncelleme DTO'su — sadece güncellenecek alanlar
public class UpdateUserRequest {
    private String firstName;   // null ise güncellenmeyecek
    private String lastName;    // null ise güncellenmeyecek
    private String email;       // null ise güncellenmeyecek
    private String phone;       // null ise güncellenmeyecek
    // Getter/setter...
}
@Mapper(
    componentModel = "spring",
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
    // ↑ Bu kritik! null değerleri mevcut nesneye kopyalama
)
public interface UserMapper {

    UserDTO toDTO(User user);

    // Mevcut User entity'sini güncelle
    void updateFromDTO(UpdateUserRequest dto, @MappingTarget User entity);
}

`nullValuePropertyMappingStrategy = IGNORE` — DTO'daki null field'lar entity'ye kopyalanmaz. Yani sadece gönderilen (non-null) field'lar güncellenir. PATCH semantiği!

Controller'da Kullanım

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserRepository userRepository;
    private final UserMapper userMapper;

    @PatchMapping("/{id}")
    public ResponseEntity<UserDTO> updateUser(@PathVariable Long id,
                                               @RequestBody UpdateUserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("Kullanıcı bulunamadı: " + id));
        
        // Sadece gönderilen alanları güncelle
        userMapper.updateFromDTO(request, user);
        
        User savedUser = userRepository.save(user);
        return ResponseEntity.ok(userMapper.toDTO(savedUser));
    }
}
// PATCH /api/users/42
// Body: {"email": "yeni@test.com"}
//
// Sonuç: Sadece email güncellenir, diğer alanlar korunur

Nested Update

@Mapper(
    componentModel = "spring",
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface OrderMapper {

    void updateOrder(UpdateOrderRequest dto, @MappingTarget Order entity);
    
    void updateAddress(UpdateAddressRequest dto, @MappingTarget Address entity);
}

⚠️ Dikkat: IGNORE stratejisi null'ları atlar. Ama ya kullanıcı bir field'ı bilerek null yapmak istiyorsa? (Örneğin telefon numarasını silmek.) Bu durumda Optional wrapper veya ayrı bir "clear" mekanizması gerekir. JSON Patch (RFC 6902) standardı bu sorunu çözer ama MapStruct ile doğrudan çalışmaz.


5. Multiple Source Mapping

Bazen DTO'yu tek bir entity'den değil, birden fazla kaynaktan oluşturursun.

İki Kaynaktan Tek DTO

// İki farklı entity
public class User {
    private Long id;
    private String name;
    private String email;
    // ...
}

public class UserProfile {
    private String bio;
    private String avatarUrl;
    private int followerCount;
    // ...
}

// Birleşik DTO
public class UserDetailDTO {
    private Long id;
    private String name;
    private String email;
    private String bio;
    private String avatarUrl;
    private int followerCount;
    // ...
}
@Mapper(componentModel = "spring")
public interface UserDetailMapper {

    @Mapping(target = "id", source = "user.id")
    @Mapping(target = "name", source = "user.name")
    @Mapping(target = "email", source = "user.email")
    @Mapping(target = "bio", source = "profile.bio")
    @Mapping(target = "avatarUrl", source = "profile.avatarUrl")
    @Mapping(target = "followerCount", source = "profile.followerCount")
    UserDetailDTO toDetailDTO(User user, UserProfile profile);
}

Üç Kaynaktan

@Mapper(componentModel = "spring")
public interface DashboardMapper {

    @Mapping(target = "userName", source = "user.name")
    @Mapping(target = "orderCount", source = "stats.orderCount")
    @Mapping(target = "totalSpent", source = "stats.totalSpent")
    @Mapping(target = "lastLoginAt", source = "session.lastLoginAt")
    @Mapping(target = "currentIp", source = "session.ipAddress")
    DashboardDTO toDashboard(User user, UserStats stats, UserSession session);
}

Aynı isimli field'lar birden fazla source'da varsa, source ile hangi kaynaktan alınacağını belirtmen zorunlu:

// Her iki source'ta da "id" var — hangisi?
@Mapping(target = "userId", source = "user.id")
@Mapping(target = "profileId", source = "profile.id")

💡 İpucu: Multiple source mapping, service layer'da farklı repository'lerden gelen verileri birleştirirken çok faydalı. Tek bir mapper çağrısıyla tüm verileri DTO'ya map'leyebilirsin.


6. Abstract Class vs Interface Mapper

MapStruct mapper'ları interface veya abstract class olarak tanımlanabilir. Her ikisinin de avantajları var.

Interface Mapper (Varsayılan)

@Mapper(componentModel = "spring")
public interface UserMapper {
    UserDTO toDTO(User user);
    User toEntity(UserDTO dto);
}

Avantajları:

  • Basit, temiz

  • Çoğu senaryo için yeterli

  • Default method'larla custom logic eklenebilir

Abstract Class Mapper

@Mapper(componentModel = "spring")
public abstract class UserMapper {

    @Autowired
    protected RoleRepository roleRepository;  // DI kullanabilirsin!

    @Mapping(target = "roleName", ignore = true)
    public abstract UserDTO toDTO(User user);

    @AfterMapping
    protected void enrichWithRole(User user, @MappingTarget UserDTO dto) {
        // Repository'den ek veri çek
        if (user.getRoleId() != null) {
            roleRepository.findById(user.getRoleId())
                .ifPresent(role -> dto.setRoleName(role.getName()));
        }
    }
}

Abstract class avantajları:

  • @Autowired ile Spring bean'lerini inject edebilirsin

  • Protected field'lar kullanabilirsin

  • Daha karmaşık custom logic

  • State tutabilirsin (ama dikkatli ol, thread safety!)

Ne zaman abstract class?

  • Mapper içinde başka bir service/repository çağırman gerektiğinde

  • Karmaşık custom logic gerektiğinde

  • Interface'in default method'ları yetmediğinde

⚠️ Dikkat: Abstract class mapper'da @Autowired ile repository inject etmek, mapper'ı veritabanına bağımlı yapar. Bu test etmeyi zorlaştırır ve mapper'ın sorumluluğunu aşar. Mümkünse bu pattern'den kaçın — ek verileri service layer'da çek, mapper'a hazır ver.


7. Expression ile Custom Logic

@Mapping annotation'ında expression attribute'u ile Java kodu yazabilirsin:

@Mapper(componentModel = "spring", imports = {LocalDateTime.class, UUID.class})
public interface OrderMapper {

    // Java expression ile
    @Mapping(target = "createdAt", expression = "java(LocalDateTime.now())")
    @Mapping(target = "trackingId", expression = "java(UUID.randomUUID().toString())")
    @Mapping(target = "fullName", expression = "java(order.getFirstName() + \" \" + order.getLastName())")
    @Mapping(target = "discountedPrice", expression = "java(order.getPrice().multiply(java.math.BigDecimal.valueOf(0.9)))")
    OrderDTO toDTO(Order order);
}

imports Attribute

Expression içinde kullandığın sınıfları import etmelisin:

@Mapper(
    componentModel = "spring",
    imports = {
        LocalDateTime.class,
        UUID.class,
        StringUtils.class,
        BigDecimal.class
    }
)
public interface ProductMapper {

    @Mapping(target = "slug", expression = "java(product.getName().toLowerCase().replaceAll(\"\\\\s+\", \"-\"))")
    @Mapping(target = "priceFormatted", expression = "java(String.format(\"%.2f TL\", product.getPrice()))")
    ProductDTO toDTO(Product product);
}

Conditional Expression

@Mapping(
    target = "status", 
    expression = "java(order.isPaid() ? \"PAID\" : \"PENDING\")"
)
@Mapping(
    target = "priority",
    expression = "java(order.getTotalAmount().compareTo(java.math.BigDecimal.valueOf(1000)) > 0 ? \"HIGH\" : \"NORMAL\")"
)
OrderDTO toDTO(Order order);

⚠️ Dikkat: Expression içinde karmaşık logic yazma. Okunabilirlik düşer, debug etmek zorlaşır. Uzun expression'ları @AfterMapping veya default/abstract metoda taşı. Expression kısa ve basit kalmalı.


8. Constant ve Default Değerler

Constant: Her Zaman Aynı Değer

@Mapper(componentModel = "spring")
public interface AuditMapper {

    @Mapping(target = "source", constant = "WEB_API")
    @Mapping(target = "version", constant = "v2")
    @Mapping(target = "environment", constant = "PRODUCTION")
    AuditDTO toDTO(AuditLog log);
}

Default Value: Source Null İse

@Mapper(componentModel = "spring")
public interface UserMapper {

    @Mapping(target = "displayName", source = "nickname", defaultValue = "Anonim")
    @Mapping(target = "language", source = "preferredLanguage", defaultValue = "tr")
    @Mapping(target = "avatarUrl", source = "avatar", defaultValue = "https://cdn.example.com/default-avatar.png")
    @Mapping(target = "role", source = "roleCode", defaultValue = "USER")
    UserDTO toDTO(User user);
}

Default Expression: Dinamik Default

@Mapper(componentModel = "spring", imports = {UUID.class, LocalDateTime.class})
public interface EventMapper {

    @Mapping(target = "eventId", source = "id", 
             defaultExpression = "java(UUID.randomUUID().toString())")
    @Mapping(target = "timestamp", source = "createdAt",
             defaultExpression = "java(LocalDateTime.now())")
    EventDTO toDTO(Event event);
}

defaultExpression: source değeri null ise expression çalışır. source değeri varsa source kullanılır.

💡 İpucu: constant her zaman sabit değer atar (source'u yok sayar). defaultValue ise source null olduğunda kullanılır. defaultExpression ise source null olduğunda dinamik bir değer üretir. Üçünü karıştırma!


9. Qualifier ile Custom Metod Seçimi

Aynı tipte birden fazla mapping metodu olduğunda, MapStruct hangisini kullanacağını bilemez. Qualifier annotation'ları ile belirtirsin.

Senaryo: Farklı Formatlarda İsim Dönüşümü

// Custom qualifier annotation'lar
@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface TitleCase {}

@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface UpperCase {}

@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface Slug {}
// String dönüşüm helper
public class StringFormatter {

    @TitleCase
    public String toTitleCase(String input) {
        if (input == null || input.isBlank()) return input;
        return Arrays.stream(input.split("\\s+"))
            .map(word -> word.substring(0, 1).toUpperCase() + word.substring(1).toLowerCase())
            .collect(Collectors.joining(" "));
    }

    @UpperCase
    public String toUpperCase(String input) {
        return input != null ? input.toUpperCase() : null;
    }

    @Slug
    public String toSlug(String input) {
        if (input == null) return null;
        return input.toLowerCase()
                    .replaceAll("[^a-z0-9\\s-]", "")
                    .replaceAll("\\s+", "-");
    }
}
@Mapper(componentModel = "spring", uses = StringFormatter.class)
public interface ProductMapper {

    @Mapping(target = "displayName", source = "name", qualifiedBy = TitleCase.class)
    @Mapping(target = "categoryCode", source = "category", qualifiedBy = UpperCase.class)
    @Mapping(target = "slug", source = "name", qualifiedBy = Slug.class)
    ProductDTO toDTO(Product product);
}

@Named ile Daha Basit Yöntem

Custom annotation oluşturmak istemiyorsan, @Named kullan:

public class PriceFormatter {

    @Named("formatTL")
    public String formatAsTL(BigDecimal price) {
        if (price == null) return "0,00 TL";
        return String.format("%,.2f TL", price);
    }

    @Named("formatUSD")
    public String formatAsUSD(BigDecimal price) {
        if (price == null) return "$0.00";
        return String.format("$%,.2f", price);
    }
}
@Mapper(componentModel = "spring", uses = PriceFormatter.class)
public interface ProductMapper {

    @Mapping(target = "priceTL", source = "price", qualifiedByName = "formatTL")
    @Mapping(target = "priceUSD", source = "priceUsd", qualifiedByName = "formatUSD")
    ProductDTO toDTO(Product product);
}

10. Spring Component Model

DI Entegrasyonu

@Mapper(componentModel = "spring")
public interface UserMapper {
    UserDTO toDTO(User user);
}

MapStruct ürettiği implementasyonu @Component olarak işaretler:

// Generated — MapStruct otomatik üretir
@Component
public class UserMapperImpl implements UserMapper {
    @Override
    public UserDTO toDTO(User user) {
        // ... mapping kodu
    }
}

Artık @Autowired veya constructor injection ile kullanabilirsin:

@Service
public class UserService {
    
    private final UserMapper userMapper;
    
    // Constructor injection (önerilen)
    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }
    
    public UserDTO getUserById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        return userMapper.toDTO(user);
    }
}

uses: Başka Mapper'ları Kullanma

@Mapper(componentModel = "spring")
public interface AddressMapper {
    AddressDTO toDTO(Address address);
}

@Mapper(componentModel = "spring", uses = {AddressMapper.class})
public interface UserMapper {
    // User.address → UserDTO.address dönüşümünde 
    // AddressMapper otomatik kullanılır
    UserDTO toDTO(User user);
}

MapStruct, uses'ta belirtilen mapper'ları Spring DI ile inject eder:

// Generated
@Component
public class UserMapperImpl implements UserMapper {
    
    @Autowired
    private AddressMapper addressMapper;  // Otomatik inject
    
    @Override
    public UserDTO toDTO(User user) {
        // ...
        dto.setAddress(addressMapper.toDTO(user.getAddress()));
        // ...
    }
}

11. MapStruct + Lombok

Lombok ve MapStruct birlikte çalışır ama annotation processor sıralaması önemli.

Maven Konfigürasyonu

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version>
            <configuration>
                <annotationProcessorPaths>
                    <!-- Lombok ÖNCE olmalı! -->
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                        <version>${lombok.version}</version>
                    </path>
                    <!-- Lombok-MapStruct binding -->
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok-mapstruct-binding</artifactId>
                        <version>0.2.0</version>
                    </path>
                    <!-- MapStruct SONRA -->
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${mapstruct.version}</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

⚠️ Dikkat: Sıralama kritik! Lombok önce çalışıp getter/setter/builder'ları üretmeli, sonra MapStruct bu üretilmiş method'ları kullanarak mapping kodu üretmeli. Sıra yanlışsa MapStruct field'ları bulamaz ve hata verir.

Lombok Builder ile MapStruct

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
    private Long id;
    private String name;
    private String email;
}
@Mapper(componentModel = "spring", builder = @Builder(disableBuilder = false))
public interface UserMapper {
    // MapStruct, UserDTO'nun builder'ını otomatik kullanır
    UserDTO toDTO(User user);
}

MapStruct ürettiği kod builder kullanır:

// Generated
@Override
public UserDTO toDTO(User user) {
    if (user == null) return null;
    
    UserDTO.UserDTOBuilder userDTO = UserDTO.builder();
    userDTO.id(user.getId());
    userDTO.name(user.getName());
    userDTO.email(user.getEmail());
    
    return userDTO.build();
}

Builder Devre Dışı Bırakma

Bazen builder kullanmak istemezsin (örneğin @MappingTarget ile):

@Mapper(componentModel = "spring", builder = @Builder(disableBuilder = true))
public interface UserMapper {
    // Setter kullanır, builder değil
    void updateFromDTO(UpdateUserRequest dto, @MappingTarget User entity);
}

💡 İpucu: @MappingTarget (update mapping) builder ile çalışmaz çünkü builder yeni nesne oluşturur. Update mapping setter gerektirir. MapStruct bunu genellikle otomatik algılar ama açık belirtmek daha güvenli.


12. MapStruct + Java Records

Java 16+ ile gelen record'lar immutable DTO'lar için mükemmel. MapStruct record'ları destekler.

Record DTO'lar

// Record DTO — getter otomatik, setter yok, immutable
public record UserDTO(
    Long id,
    String name,
    String email,
    String createdAt
) {}

public record OrderItemDTO(
    String productName,
    int quantity,
    BigDecimal price
) {}

public record OrderDTO(
    Long id,
    String orderNumber,
    List<OrderItemDTO> items,
    BigDecimal total
) {}

Mapper

@Mapper(componentModel = "spring")
public interface UserMapper {

    @Mapping(target = "createdAt", dateFormat = "dd.MM.yyyy")
    UserDTO toDTO(User user);

    List<UserDTO> toDTOList(List<User> users);
}

MapStruct record için constructor-based mapping kullanır:

// Generated
@Override
public UserDTO toDTO(User user) {
    if (user == null) return null;
    
    Long id = user.getId();
    String name = user.getName();
    String email = user.getEmail();
    String createdAt = new SimpleDateFormat("dd.MM.yyyy").format(user.getCreatedAt());
    
    return new UserDTO(id, name, email, createdAt);
}

Record Limitasyonları

// Record immutable olduğu için @MappingTarget çalışmaz:
// void updateFromDTO(UpdateRequest dto, @MappingTarget UserDTO record); ❌
// Record'da setter yok!

// Çözüm: Yeni record oluştur
UserDTO updatedDTO(UpdateRequest dto, UserDTO existing);

// veya
@Mapping(target = "name", source = "dto.name", defaultExpression = "java(existing.name())")
@Mapping(target = "email", source = "dto.email", defaultExpression = "java(existing.email())")
UserDTO merge(UpdateRequest dto, UserDTO existing);

💡 İpucu: Record'lar immutable olduğu için PATCH update mapping'lerde kullanılamaz. Entity'leri update etmek için normal class kullan, API response'lar için record kullan. İki ayrı DTO tipi gayet mantıklı.


13. Performans Karşılaştırması

MapStruct vs ModelMapper vs Manual Mapping

Benchmark (10.000 iterasyon, basit 10 field mapping):

Manual mapping:    ~2ms   (referans)
MapStruct:         ~3ms   (manual'e çok yakın)
ModelMapper:       ~150ms (reflection tabanlı, 50x yavaş)
Dozer:             ~200ms (XML config, eski)

İlk çalışma (cold start):
MapStruct:         0ms    (compile-time, runtime overhead yok)
ModelMapper:       ~500ms (reflection setup, class scanning)

Neden MapStruct Hızlı?

MapStruct:    Source Code → Annotation Processor → Generated Java Code → Compiled Class
ModelMapper:  Runtime → Reflection → Property Discovery → Type Matching → Conversion

MapStruct COMPILE TIME'da çalışır. Runtime'da normal Java method çağrısı yapar.
ModelMapper RUNTIME'da reflection kullanır. Her çağrıda overhead var.

Memory Kullanımı

MapStruct:    Sadece generated class — minimal memory
ModelMapper:  Type map cache, property map cache, converter registry — yüksek memory

Ne Zaman ModelMapper/Dozer?

Neredeyse hiçbir zaman. MapStruct her senaryoda üstün:

  • Performans: 50-100x daha hızlı

  • Type safety: Compile-time kontrol, yanlış mapping derlenmez

  • IDE support: Refactoring otomatik günceller

  • Debug: Generated code okunabilir, breakpoint koyabilirsin

⚠️ Dikkat: ModelMapper'ın "convention-based" otomatik mapping'i kullanışlı görünür ama tehlikelidir. Field isimleri değiştiğinde sessizce mapping kaybolur, runtime'da fark edersin. MapStruct'ta compile hata verir — güvenli.


14. İleri Düzey Konfigürasyon

Global Mapper Konfigürasyonu

// Tüm mapper'lar için ortak ayarlar
@MapperConfig(
    componentModel = "spring",
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
    nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT,
    unmappedTargetPolicy = ReportingPolicy.WARN,
    builder = @Builder(disableBuilder = false)
)
public interface MapperConfiguration {}
// Bu config'i kullanan mapper
@Mapper(config = MapperConfiguration.class)
public interface UserMapper {
    UserDTO toDTO(User user);
}

@Mapper(config = MapperConfiguration.class)
public interface OrderMapper {
    OrderDTO toDTO(Order order);
}

Unmapped Property Kontrolü

@Mapper(
    componentModel = "spring",
    unmappedTargetPolicy = ReportingPolicy.ERROR  // Map edilmemiş field → compile hatası
)
public interface StrictMapper {
    // Target DTO'da source'ta karşılığı olmayan field varsa
    // compile-time hata alırsın. Güvenli!
    
    @Mapping(target = "createdBy", constant = "SYSTEM")
    @Mapping(target = "version", ignore = true)
    UserDTO toDTO(User user);
}
PolicyDavranış
IGNORESessizce geç (varsayılan)
WARNCompile-time uyarı
ERRORCompile-time hata

💡 İpucu: Yeni projelerde unmappedTargetPolicy = ERROR kullan. Bu, yeni field eklediğinde mapping'i güncellemeyi unutmanı engeller. Production'da field eksik kaldığını runtime'da değil, compile'da yakalar.

Generated Code'u İnceleme

MapStruct'ın ürettiği kodu görmek debug için çok faydalı:

# Maven ile — target/generated-sources/annotations/ altında
mvn compile

# IntelliJ IDEA'da:
# Build → Rebuild Project
# Sonra generated class'ı aç: UserMapperImpl
# veya Navigate → Class → "UserMapperImpl" yaz

Yaygın Hatalar ve Çözümleri

1. "Unmapped target property" Uyarısı

warning: Unmapped target property: "createdAt".

Çözüm: Ya map et ya da ignore et:

@Mapping(target = "createdAt", ignore = true)

2. Lombok Getter Bulunamadı

error: Unknown property "name" in result type.

Çözüm: lombok-mapstruct-binding bağımlılığını ekle ve annotation processor sıralamasını kontrol et (Lombok önce!).

3. Ambiguous Mapping Method

error: Ambiguous mapping methods found for mapping property

Çözüm: qualifiedBy veya qualifiedByName ile hangi metodun kullanılacağını belirt.

4. Collection Null Dönüyor

List<UserDTO> result = userMapper.toDTOList(null);
// result = null  (NPE riski!)

Çözüm:

@Mapper(nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT)
// Artık null input → boş liste döner

5. Circular Reference (Entity A ↔ Entity B)

// User → orders → user → orders → ... SONSUZ DÖNGÜ!

Çözüm: Döngüyü kıran ayrı DTO'lar kullan:

// OrderDTO içinde UserDTO olmasın, sadece userId olsun
@Mapping(target = "userId", source = "user.id")
@Mapping(target = "userName", source = "user.name")
OrderDTO toDTO(Order order);

Özet

  • Nested mapping otomatik çalışır — alt entity için mapper metodu tanımla, MapStruct List<Entity>List<DTO> dönüşümünü bile otomatik yapar

  • `@MappingTarget` ile mevcut nesneyi güncelle — PATCH endpoint'leri için nullValuePropertyMappingStrategy = IGNORE ile sadece non-null field'ları güncelle

  • Multiple source mapping ile 2+ kaynaktan tek DTO oluştur — source = "user.name" ile hangi kaynaktan geldiğini belirt

  • `@AfterMapping` / `@BeforeMapping` ile mapping öncesi/sonrası custom logic ekle — hesaplama, validasyon, maskeleme gibi işlemler

  • Spring component model ile mapper'lar otomatik @Component olur — constructor injection ile kullan, uses ile mapper'ları birbirine bağla

  • MapStruct compile-time çalışır, runtime reflection yok — ModelMapper'dan 50-100x hızlı, type-safe, IDE refactoring destekler, unmappedTargetPolicy = ERROR ile eksik mapping'leri compile'da yakala