Software Engineer's Blog

What Is MapStruct and Why Do We Need It?

What Is MapStruct and Why Do We Need It?

A practical way to manage DTO mapping cleanly and safely in Spring-based Java projects

Table of Contents

  1. Overview of MapStruct
  2. Is MapStruct an Industry Standard?
  3. Static Mapper vs MapStruct
  4. When to Use MapStruct
  5. Real-World Impact
  6. Key Takeaways

1. Overview of MapStruct

MapStruct is a code generator that automatically creates mapping code between Java Beans at compile time. It is commonly used for converting Entity → DTO or DTO → Entity, helping developers reduce repetitive and error-prone boilerplate code.

Why Do We Need Entity ↔ DTO Mapping?

In Spring-based applications, we typically separate:

  • Entity — JPA entities that represent database tables
  • DTO — Data Transfer Objects for API requests/responses

This separation is crucial because:

  • Entities contain JPA annotations and relationships (not suitable for API exposure)
  • DTOs provide a clean contract for external clients
  • Prevents exposing internal database structure
  • Allows different representations of the same data (summary, detail, etc.)

Traditional Approach: Manual Mapping

// ❌ Manual approach - lots of boilerplate
public class UserMapper {

    public static UserDto toDto(User entity) {
        UserDto dto = new UserDto();
        dto.setId(entity.getId());
        dto.setUsername(entity.getUsername());
        dto.setEmail(entity.getEmail());
        dto.setFirebaseUid(entity.getFirebaseUid());
        dto.setProfilePictureUrl(entity.getProfilePictureUrl());
        dto.setStatus(entity.getStatus());
        dto.setCreatedAt(entity.getCreatedAt());
        dto.setUpdatedAt(entity.getUpdatedAt());

        // Nested objects require manual mapping too
        if (entity.getUserDetail() != null) {
            UserDetailDto detailDto = new UserDetailDto();
            detailDto.setUserId(entity.getUserDetail().getUser().getId());
            detailDto.setGender(entity.getUserDetail().getGender());
           detailDto.setDateOfBirth(entity.getUserDetail().getDateOfBirth());
            // ... more fields
            dto.setUserDetail(detailDto);
        }

        // Collections need manual iteration
        if (entity.getUserNames() != null) {
            Set<UserNameDto> nameDtos = entity.getUserNames().stream()
                .map(name -> {
                    UserNameDto nameDto = new UserNameDto();
                    nameDto.setId(name.getId());
                    nameDto.setUseType(name.getUseType());
                    // ... more fields
                    return nameDto;
                })
                .collect(Collectors.toSet());
            dto.setUserNames(nameDtos);
        }

        return dto;
    }
}

Problems with this approach:

  • 50+ lines of code for a single mapping method
  • Easy to miss fields when adding new properties
  • No compile-time validation
  • Significant code duplication across similar mappers
  • Difficult to maintain as DTOs grow

MapStruct Approach: Interface Only

// ✅ MapStruct - clean and declarative
@Mapper(
    componentModel = "spring",
    uses = {UserDetailMapper.class, UserNameMapper.class}
)
public interface UserMapper {
    UserDto toDto(User entity);
}

That’s it! The entire implementation is generated at compile time.

FeatureDescription
Generated implementation~100 lines of optimized Java code
No reflectionDirect field access for maximum performance
Type-safeCompilation fails if field types don’t match
Nested mappingAutomatically delegates to UserDetailMapper and UserNameMapper

2. Is MapStruct an Industry Standard or Best Practice?

✅ Widely Used in the Industry

  • One of the most popular mapping libraries in the Spring ecosystem
  • Used by large-scale companies such as Netflix, Uber, and Airbnb
  • Strong community adoption and long-term maintenance

⚠️ Not Mandatory in Every Project

  • The decision depends on project size and complexity
  • Manual mapping or other libraries can still be valid choices

👉 What matters most is having a clear reason for adopting it, not using it blindly.

3. Static Mapper vs MapStruct

❌ Problems with the Static Mapper Approach

// ❌ Static utility class pattern
public class UserMapper {
    public static UserDto toDto(User entity) {
        UserDto dto = new UserDto();
        dto.setId(entity.getId());
        dto.setEmail(entity.getEmail());
        // ... 20 more lines
        return dto;
    }
}
// Service using static mapper
@Service
public class UserServiceImpl {
    public UserDto getUser(UUID id) {
        User user = userRepository.findById(id).orElseThrow();
        return UserMapper.toDto(user);  // ❌ Hard-coded dependency
    }
}

Issues:

  1. No Dependency Injection — Cannot swap implementations
  2. Hard to test — Cannot mock with Mockito
  3. Not extensible — Cannot inject other mappers or services
  4. Not a Spring Bean — Poor integration with Spring ecosystem

✅ Using MapStruct — Real Example from user-service

// ✅ MapStruct interface with DI support
@Mapper(
    componentModel = "spring",
    uses = {
        UserDetailMapper.class,
        UserNameMapper.class,
        UserContactMapper.class,
        UserIdentifierMapper.class,
        UserLanguageMapper.class,
        UserAddressMapper.class
    }
)
public interface UserMapper {

    /**
     * Convert User entity to UserDto.
     * Nested objects are automatically mapped using their respective mappers.
     */
    @Mapping(source = "userAddrs", target = "userAddresses")
    UserDto toDto(User user);

    /**
     * Convert UserDto to User entity (for user creation).
     */
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "createdAt", ignore = true)
    @Mapping(target = "updatedAt", ignore = true)
    @Mapping(target = "fhirProfileSent", constant = "false")
    @Mapping(target = "roles", ignore = true)
    User toEntity(UserDto dto);

    /**
     * Update User entity from UserDto (for PATCH operations).
     * Null values in DTO are ignored.
     */
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "firebaseUid", ignore = true)
    @Mapping(target = "createdAt", ignore = true)
    @Mapping(target = "updatedAt", ignore = true)
    void updateEntityFromDto(UserDto dto, @MappingTarget User user);

    /**
     * Custom method to map roles from Role entities to String list.
     */
    default List<String> mapRoles(Set<Role> roles) {
        if (roles == null || roles.isEmpty()) {
            return null;
        }
        return roles.stream()
                .map(role -> role.getRole().getValue())
                .collect(Collectors.toList());
    }
}

Service using MapStruct mapper:

@Service
@AllArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;  // ✅ Injected as Spring Bean

    @Override
    public UserDto getUser(UUID id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));
        return userMapper.toDto(user);  // ✅ Clean and testable
    }

    @Override
    @Transactional
    public UserDto partialUpdateUser(UserDto userDto, UUID id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));

        // ✅ PATCH update - only non-null fields are updated
        userMapper.updateEntityFromDto(userDto, user);

        User savedUser = userRepository.save(user);
        return userMapper.toDto(savedUser);
    }
}

Benefits Summary

BenefitDescription
Compile-time safetyField mismatches fail the build
Excellent performanceNo reflection, pure Java code
Dependency InjectionManaged as Spring Bean
TestabilityEasy to mock with Mockito
IDE-friendlyAuto-completion and refactoring work perfectly
ExtensibleCan inject other mappers via uses attribute

4. From a Practical Perspective: When to Use MapStruct

✅ Good Use Cases for MapStruct

1) Many DTOs with Many Fields

The user-service has complex domain models with nested relationships:

// Entity: User (users table)
@Entity
@Table(name = "users")
public class User {
    @Id
    private UUID id;
    private String username;
    private String email;
    private String firebaseUid;
    private String profilePictureUrl;
    private UserStatus status;

    // Nested relationships
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
    private UserDetail userDetail;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private Set<UserName> userNames;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private Set<UserContact> userContacts;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private Set<UserIdentifier> userIdentifiers;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private Set<UserLang> userLangs;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private Set<UserAddr> userAddrs;

    @ManyToMany
    private Set<Role> roles;
}

Without MapStruct, you would need 100+ lines just for one mapping method. With MapStruct:

@Mapper(componentModel = "spring", uses = {...})
public interface UserMapper {

    // ✅ All this complexity handled in ONE line
    @Mapping(source = "userAddrs", target = "userAddresses")
    UserDto toDto(User user);

    // ✅ Bonus: List conversion for free
    List<UserDto> toDtoList(List<User> users);
}

2) Nested Object Mapping with Custom Logic

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

    @Mapping(source = "user.id", target = "userId")  // Map from nested object
    UserDetailDto toDto(UserDetail userDetail);

    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "user", ignore = true)
    void updateFromRequest(UserDetailRequestDto request, @MappingTarget UserDetail entity);
}

3) Custom Field Transformations

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

    @Mapping(source = "userAddrs", target = "userAddresses")
    UserDto toDto(User user);

    /**
     * Custom method - called automatically for Set<Role> → List<String> mapping
     */
    default List<String> mapRoles(Set<Role> roles) {
        if (roles == null || roles.isEmpty()) {
            return null;
        }
        return roles.stream()
                .map(role -> role.getRole().getValue())
                .collect(Collectors.toList());
    }
}

4) PATCH Operations with @MappingTarget

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "id", ignore = true)
@Mapping(target = "firebaseUid", ignore = true)
void updateEntityFromDto(UserDto dto, @MappingTarget User user);

Without MapStruct, you would need manual null checks for every field:

// ❌ Manual PATCH handling - tedious and error-prone
if (dto.getEmail() != null) user.setEmail(dto.getEmail());
if (dto.getUsername() != null) user.setUsername(dto.getUsername());
if (dto.getProfilePictureUrl() != null) user.setProfilePictureUrl(dto.getProfilePictureUrl());
// ... 20 more fields

❌ When MapStruct May Be Unnecessary

1) Very Small or Simple Projects

// Simple case: 2-3 fields, no nested objects
public record UserSummaryDto(UUID id, String email) {}

public class UserMapper {
    public static UserSummaryDto toSummary(User user) {
        return new UserSummaryDto(user.getId(), user.getEmail());
    }
}

If you have few DTOs (1-3), very simple structures (no nesting), and no strong need for DI or testing, then manual mapping might be simpler.

2) Very Complex Transformation Logic

// Complex business logic with conditionals
public UserDto toDto(User user) {
    String displayName = calculateDisplayName(user);
    int riskScore = computeRiskScore(user);
    String category = determineCategory(user);

    return new UserDto(user.getId(), displayName, riskScore, category);
}

If the mapping involves heavy business logic or calculations, manual mapping can be clearer. However, you can still use MapStruct for basic field mapping and add custom logic via default methods.

Real-World Impact: Before & After

MetricBefore MapStructAfter MapStruct
UserMapper.java~150 lines manual code~30 lines (interface only)
Field safetyHigh risk of missing fieldsCompile-time validation
MaintenanceDifficult as model grewAutomatic field handling
New DTO effortDuplicate mapping logicMinimal changes
Overall maintenance costHighReduced by ~70%

Key Takeaways

  1. MapStruct eliminates boilerplate — A single interface declaration replaces 100+ lines of manual mapping code
  2. Compile-time safety — Field mismatches are caught during build, not at runtime
  3. Zero performance overhead — No reflection, generates plain Java code
  4. Spring ecosystem integration — Works seamlessly with DI, making testing easier
  5. Handles complexity gracefully — Nested objects, custom transformations, and PATCH operations

When to Choose MapStruct

ScenarioMapStructManual Mapping
10+ DTOs with nested objects✅ Recommended❌ Too much boilerplate
Simple 2-3 field mappings⚠️ Optional✅ Simpler
PATCH operations (partial updates)✅ Built-in support❌ Tedious null checks
Heavy business logic in mapping⚠️ Use default methods✅ More explicit
Need testability & DI✅ Spring Bean support❌ Static methods

One-Sentence Takeaway

MapStruct is not just about reducing boilerplate — it is a design choice for building scalable, testable, and maintainable Spring applications.

Further Reading