Saltar al contenido principal

Mapping

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, bajo el tag mapping.
Por qué mapping está aquí

Mapping no es técnicamente un cross-cutting concern. No es un filter, aspect, o interceptor. Pero lo encontrarás tan frecuentemente en proyectos reales que vale la pena configurarlo temprano con la ayuda de una librería junto con tus otras decisiones de infraestructura. Por eso lo cubrimos aquí.

En el documento de primer endpoint, FilmRestMapper era una clase escrita a mano que convertía un modelo de dominio Film en un DTO FilmResponse campo por campo. Eso funciona para siete campos. Deja de ser aceptable cuando hay decenas de campos, objetos anidados y conversiones de tipo que gestionar, y nunca genera errores en tiempo de compilación cuando se agrega un campo nuevo al dominio pero se olvida en el mapper.

Este documento reemplaza el mapper escrito a mano con una librería especializada: MapStruct para Java y Kotlin, ModelMapper para Groovy.

Archivos modificados

Archivos a Crear/Modificar
File Tree
├── build.gradle
└── src
└── main
└── java
└── dev
└── pollito
└── spring_java
├── config
│ └── mapper
│ └── MapperSpringConfig.java
├── sakila
│ └── film
│ └── adapter
│ └── in
│ └── rest
│ ├── ...
│ └── FilmRestMapper.java
└── ...

El archivo de build recibe nuevas dependencias, se agrega una clase de configuración para integrar la librería de mapping con Spring, y FilmRestMapper se reemplaza. Para Groovy, el controller también cambia porque el mapper ya no es una utilidad estática, sino un bean gestionado por Spring.

Agregar la dependencia

build.gradle
// ...
dependencies {
// ...
def mapstructVersion = '1.6.3'
def mapstructSpringExtensionsVersion = '2.0.0'
implementation "org.mapstruct:mapstruct:${mapstructVersion}"
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
implementation "org.mapstruct.extensions.spring:mapstruct-spring-annotations:${mapstructSpringExtensionsVersion}"
annotationProcessor "org.mapstruct.extensions.spring:mapstruct-spring-extensions:${mapstructSpringExtensionsVersion}"
testImplementation "org.mapstruct.extensions.spring:mapstruct-spring-test-extensions:${mapstructSpringExtensionsVersion}"
testAnnotationProcessor "org.mapstruct.extensions.spring:mapstruct-spring-extensions:${mapstructSpringExtensionsVersion}"
}
// ...

Java y Kotlin usan MapStruct con sus extensiones de Spring. Las extensiones registran automáticamente cada @Mapper como un converter de ConversionService de Spring, lo que elimina la necesidad de inyectar mappers individuales en las clases que los necesitan. Kotlin además requiere el plugin kapt porque MapStruct genera código en tiempo de compilación mediante procesamiento de anotaciones, y kapt es el ejecutor de procesadores de anotaciones de Kotlin.

Groovy usa ModelMapper, una librería basada en reflection. A diferencia de MapStruct, no requiere procesamiento de anotaciones. Funciona completamente en tiempo de ejecución, haciendo coincidir campos por nombre y tipo.

Configurar la librería de mapping

Antes de definir cualquier mapper, cada módulo necesita una pequeña clase de configuración.

java/dev/pollito/spring_java/config/mapper/MapperSpringConfig.java
package dev.pollito.spring_java.config.mapper;

import org.mapstruct.MapperConfig;
import org.mapstruct.extensions.spring.SpringMapperConfig;

@MapperConfig(componentModel = "spring")
@SpringMapperConfig(
conversionServiceAdapterPackage = "dev.pollito.spring_java.config.mapper")
public interface MapperSpringConfig {}

Java y Kotlin declaran MapperSpringConfig, una interfaz anotada con @MapperConfig y @SpringMapperConfig. Esto le indica a MapStruct dos cosas: usar "spring" como modelo de componente (para que los mappers generados sean beans de Spring), y dónde ubicar el ConversionServiceAdapter generado que registra todos los converters en el ConversionService de Spring.

Groovy declara ModelMapperConfig, una clase @Configuration estándar que expone un bean ModelMapper. ModelMapper no tiene estado y es thread-safe, por lo que una única instancia compartida en toda la aplicación es la elección correcta.

Reemplazar FilmRestMapper

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestMapper.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest;

import dev.pollito.spring_java.config.mapper.MapperSpringConfig;
import dev.pollito.spring_java.sakila.film.adapter.in.rest.dto.FilmResponse;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.jspecify.annotations.Nullable;
import org.mapstruct.Mapper;
import org.springframework.core.convert.converter.Converter;

@Mapper(config = MapperSpringConfig.class)
public interface FilmRestMapper extends Converter<Film, FilmResponse> {
@Override
FilmResponse convert(@Nullable Film source);
}

Java y Kotlin ahora declaran FilmRestMapper como una interfaz. La anotación @Mapper(config = MapperSpringConfig.class) le indica a MapStruct que genere una implementación gestionada por Spring en tiempo de compilación. Extender Converter<Film, FilmResponse> integra el mapper en el ConversionService de Spring, por lo que en cualquier lugar donde Spring sepa cómo convertir tipos, este mapper se utilizará automáticamente.

La anotación @Nullable en la versión Java es una anotación de nullability de jspecify. MapStruct la respeta y genera una implementación null-safe. Si el origen es null, el converter retorna null sin un NullPointerException.

Groovy reemplaza la clase de utilidad estática con un @Component de Spring. Recibe el bean ModelMapper mediante inyección por constructor y delega la conversión en él. ModelMapper.map(source, FilmResponse) inspecciona ambas clases en tiempo de ejecución y copia los campos coincidentes por nombre y tipo.

Actualizar el controller (solo Groovy)

El controller original de Groovy importaba FilmRestMapper.convert como un método estático. Ahora que FilmRestMapper es un bean de Spring, el controller necesita recibirlo mediante inyección.

groovy/dev/pollito/spring_groovy/sakila/film/adapter/in/rest/FilmRestController.groovy
// ...
class FilmRestController {
FindByIdPortIn findByIdPortIn
FilmRestMapper mapper

FilmRestController(FindByIdPortIn findByIdPortIn, FilmRestMapper mapper) {
this.findByIdPortIn = findByIdPortIn
this.mapper = mapper
}

@GetMapping("/{id}")
FilmResponse findById(@PathVariable("id") Integer id) {
mapper.convert(findByIdPortIn.findById(id))
}
}

Los controllers de Java y Kotlin no necesitan actualizarse. Ya inyectaban FilmRestMapper como campo, y esa inyección sigue funcionando igual, solo que ahora apunta a la implementación generada por MapStruct en lugar de la clase escrita a mano.