Spring MVC - MapStruct

Publicado el 2025-04-19

Escrito por C. V. Charco

Introducción al problema

Supongamos que tenemos una aplicación cualquiera en Spring que recibe un DTO del controlador (un CreateAccountDto) y necesitamos transformarlo a la entidad correspondiente Account para poder almacenarlo en base de datos. Veamos la implementación de estas dos clases rápidamente.

// accounts/Account.java

// Nota: estamos usando Hibernate 6+ que ofrece mayor compatibilidad con Java records

@Entity
@Table(name = "accounts")
public record Account (
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id,
  @Column(name = "username", nullable = false, unique = true) String username,
  @Column(name = "password", nullable = false) String password,
  @Column(name = "email", nullable = false, updatable = true) String email,
  @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) LocalDateTime createdAt,
  @UpdateTimestamp @Column(name = "updated_at", nullable = false) LocalDateTime updatedAt
) {}
// accounts/dto/CreateAccountDto.java

public record CreateAccountDto (
  @NotBlank String username,
  @NotBlank @Size(min = 8, max = 40) String password,
  @NotBlank @Email String email
) {}

Cuando mandamos el DTO del controlador al servicio, este último debe convertirlo en un objeto de entidad para poder trabajar e interactuar con la lógica de negocio de nuestra aplicación. Por ejemplo, para almacenar el nuevo registro en la base de datos. Para hacer esto necesitamos escribir manualmente la lógica para convertir entre ambos tipos.

// accounts/AccountService.java

public Account createAccount(CreateaAccountDto createAccountDto) {

  Account account = new Account(
    null, // id
    createAccountDto.username,
    createAccountDto.password, // En aplicación real debemos cifrar contraseña (Ej: Bcrypt)
    createAccountDto.email,
    null, // createdAt
    null // updatedAt
  );

  // Guardar Account en base de datos
  Account response = accountRepository.save(account);

  return response;
}

Si bien este ejemplo es muy sencillo ya que la entidad tiene muy pocos campos, se puede comenzar a entender los problemas que conlleva hacerlo así.

  • La creación manual de entidades a partir de un DTO (y viceversa) es repetitivo y ensucia nuestro código.
  • Es poco mantenible, ya que si agregamos un nuevo campo a la entidad y al DTO debemos modificar nuestro código en todos los lugares donde se realiza esta conversión.
  • Poco intuitivo, debemos saber exactamente en qué posición del constructor debemos declarar cada atributo. Esto podría arreglarse parcialmente con un patrón Builder, pero incluso así podría olvidarse mapear algún valor en algún punto de la aplicación.

Una solución simple consistiría en crear en el DTO (o la entidad) funciones de conversión entre estos, de la siguiente manera:

// accounts/dto/CreateAccountDto.java

public record CreateAccountDto (
  @NotBlank String username,
  @NotBlank @Size(min = 8, max = 40) String password,
  @NotBlank @Email String email
) {

  public Account toAccount() {
    // Aquí podemos incluso cifrar la contraseña
    String hashedPassword = encriptar(this.password());

    return new Account(
      null, // id
      this.username(),
      hashedPassword,
      this.email(),
      null, // createdAt
      null // updatedAt
    );
  }

}

Entonces en nuestro servicio simplemente haríamos lo siguiente:

// accounts/AccountService.java

public Account createAccount(CreateaAccountDto createAccountDto) {

  Account account = createAccountDto.toAccount();

  // Guardar Account en base de datos
  Account response = accountRepository.save(account);

  return response;
}

Este enfoque es mucho más mantenible y limpio, ya que centralizamos en un único lugar la conversión entre DTOs y entidades. Sin embargo, todavía tenemos que implementar manualmente la lógica de conversión en cada DTO (o centralizarla en un mapper) y actualizar esta lógica cada vez que agregamos campos a la entidad y al DTO. Entonces, ¿cómo puedo simplificarlo todavía más y promover una mayor mantenibilidad?

MapStruct

MapStruct es una biblioteca de Java que permite mapear automáticamente objetos entre clases, especialmente útil para convertir entre entidades y DTOs, usando generación de código en tiempo de compilación, lo que lo hace muy rápido y sin reflexión.

Para poder continuar, debemos agregar las siguientes dependencias al pom.xml de nuestro proyecto.

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.5.Final</version>
</dependency>

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>1.5.5.Final</version>
    <scope>provided</scope>
</dependency>

<!-- Compatibilidad con Lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok-mapstruct-binding</artifactId>
    <version>0.2.0</version>
    <scope>provided</scope>
</dependency>

Con MapStruct nosotros simplemente crearemos una interfaz que defina qué conversiones queremos que se implementen automáticamente y entre qué DTOs y entidades. Veamos un ejemplo. Crearemos una clase accounts/AccountsMapper.java con el siguiente código.

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;

// Con @Mapper le decimos a MapStruct que debe usar esta interfaz para generar el código de conversión
// Es importante usar `component = spring` para que Spring pueda inyectarlo como un componente en otras clases
@Mapper(componentModel = "spring")
public interface UserMapper {

    // Indicamos los campos que no deben mapearse (están presentes en la entidad, pero no en el DTO)
    @Mappings({
        @Mapping(target = "id", ignore = true),
        @Mapping(target = "createdAt", ignore = true),
        @Mapping(target = "updatedAt", ignore = true)
    })
    Account createDtoToEntity(CreateAccountDto dto);
    
    ResponsAccountDto entityToResponseDto(Account account);

    // Actualiza la entidad con los datos no nulos del dto (útil para el update)
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    void updateAccountDtoToEntity(UpdateAccountDto dto, @MappingTarget Account account);
    
}

Si observas, solo hemos declarado firmas de los métodos, no los hemos implementado, de esto se encarga MapStruct. Esto nos provee dos beneficios:

  1. Escribimos mucho menos código.
  2. Si en el futuro agregamos nuevos atributos a la entidad y al DTO no hará falta modificar nada, ya que MapStruct los añadirá por nosotros.
  3. Nos permite agregar funcionalidad adicional, como la de actualizar una entidad con los valores no nulos de un DTO.

Ahora para usar este mapper en nuestro servicio el procedimiento es muy parecido.

// accounts/AccountService.java

@Autowired
private final AccountMapper accountMapper; // Inyectamos el mapper

public Account createAccount(CreateaAccountDto createAccountDto) {

  Account account = accountMapper.createDtoToEntity(createAccountDto);

  // Guardar Account en base de datos
  Account response = accountRepository.save(account);

  return response;
}

Hasta aquí hemos visto el funcionamiento básico de MapStruct, pero que ya nos va a permitir trabajar en una gran variedad de escenarios distintos y que nos va a ayudar a mantener un código más limpio, mantenible y optimizado. Una vez que domines todo esto, te recomiendo que eches un ojo a la documentación de MapStruct para ver todas las posibilidades que ofrece. ¡Nos leemos!