javapostgresqlspring-bootspring-securitymapstruct

All MapStruct fields are encrypted with PasswordEncoder instead of only the password


I’m developing an application with Spring Boot and facing an issue using MapStruct to map a DTO (RegisterRequest) to an entity (Usuario) and save the data in a PostgreSQL database.

My goal is for only the password field to be encrypted with PasswordEncoder (using BCrypt), while the other fields (firstName, lastName, email, role) should be mapped directly from the DTO.

However, the implementation generated by MapStruct is encrypting all the fields, which causes a length error:
value too long for type character varying(30)

on the firstName column, which has a 30-character limit, while the generated hashes are approximately 60 characters long.

What I want to do

To avoid doing the mapping manually, I wanted to use MapStruct (as I had done before with other mappings) to delegate this work and reduce boilerplate code. The flow I needed was:


Relevant Code

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface UsuarioRegisterMapper {

    @Mapping(target = "rol", constant = "User")
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "contrasena", expression = "java(encodePassword(request.contrasena(), passwordEncoder))")
    Usuario toEntity(RegisterRequest request, @Context PasswordEncoder passwordEncoder);

    default String encodePassword(String rawPassword, @Context PasswordEncoder passwordEncoder) {
        return passwordEncoder.encode(rawPassword);
    }
}

the implementation automatically generated by MapStruct:

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2025-07-14T21:19:15-0600",
    comments = "version: 1.5.5.Final, compiler: javac, environment: Java 23.0.2 (Oracle Corporation)"
)
@Component
public class UsuarioRegisterMapperImpl implements UsuarioRegisterMapper {

    @Override
    public Usuario toEntity(RegisterRequest request, PasswordEncoder passwordEncoder) {
        if (request == null) {
            return null;
        }

        Usuario usuario = new Usuario();

        usuario.setNombre(encodePassword(request.nombre(), passwordEncoder));
        usuario.setApellido(encodePassword(request.apellido(), passwordEncoder));
        usuario.setEmail(encodePassword(request.email(), passwordEncoder));
        usuario.setRol(encodePassword("User", passwordEncoder));
        usuario.setContrasena(encodePassword(request.contrasena(), passwordEncoder));

        return usuario;
    }
}
package com.proyectoUno.security.dto;

public record RegisterRequest(
        String email,
        String contrasena,
        String nombre,
        String apellido
) {}
@Service
public class AuthService {
    // ... (injections)

    public AuthResponse register(RegisterRequest request) {
        if (usuarioRepository.findByEmail(request.email()).isPresent()) {
            throw new EntidadDuplicadaException("Email is already associated with an account", "email", Collections.singletonList(request.email()));
        }

// This is where I use the mapper to skip the manual process of instantiating the class and calling .set for each value
        Usuario usuario = usuarioRegisterMapper.toEntity(request, this.passwordEncoder);

        System.out.println("Usuario before saving: " + usuario);
        usuarioRepository.save(usuario);

        UserDetails userDetails = new CustomUserDetails(usuario);
        String jwtToken = jwtService.generateAccessToken(userDetails);
        String refreshToken = jwtService.generateRefreshToken(userDetails);
        return new AuthResponse(jwtToken, refreshToken);
    }
    // ...
}

Postman:

{
  "email": "mario.rodriguez@gmail.com",
  "contrasena": "soyMario1050",
  "nombre": "Mario",
  "apellido": "Rodriguez"
}

After sending the request from Postman, the bug appeared. To verify it was caused by the Mapper configuration, I did some quick debugging to check the mapper’s output.

Usuario before saving: Usuario{id=null, 
  nombre='$2a$10$SKiTvYYJK.vZPetwOtY1OOBMCz6m15.bSUCZzk67Q..Ybs0h0n6nu', 
  apellido='$2a$10$h/PjGAv8aF7sGdCMo7jK/.CaHfcS.e1bHGIM28bb/RIYd/t1CL0jy', 
  email='$2a$10$ookM1PA26edcSu0mt4FnZegsvg/Cm3S0zdp5aRmfY/e1pcJ7TqT8K', 
  contrasena='$2a$10$aMIQJAo/pX7TEWhbKwtj/O0x/yuy8eqkfMVBfY7..fmnnmcwLo1e.', 
  rol='$2a$10$WV1kZvIx.j/UZfCSmcPMoOvOMFXXUM.qDKIarD5rIOLAHThULlUKK', 
  fechaRegistro=null, activo=false, prestamo=[]}

And that’s when the issue became clear: the mapper wasn’t just handling the password field — it was applying the default method to all fields, even though it was only supposed to be used for the password.

What I’ve tried

Question

Any help or suggestions would be greatly appreciated. Thanks in advance.


Solution

  • The reason why everything gets mapped is due to the fact that MapStruct sees encodePassword and thinks that it can be applied to all String -> String mappings.

    The best way to avoid this is to use @Named, which would mark the method as a qualified method and you have to explicitly pick that in order to be used. That would look like:

    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public interface UsuarioRegisterMapper {
    
        @Mapping(target = "rol", constant = "User")
        @Mapping(target = "id", ignore = true)
        @Mapping(target = "contrasena", qualifiedByName = "encode")
        Usuario toEntity(RegisterRequest request, @Context PasswordEncoder passwordEncoder);
    
        @Named("encode")
        default String encodePassword(String rawPassword, @Context PasswordEncoder passwordEncoder) {
            return passwordEncoder.encode(rawPassword);
        }
    }