Skip to main content

Mapping

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, under the tag mapping.
Why Mapping Is Here

Mapping isn't technically a cross-cutting concern. It's not a filter, aspect, or interceptor. But you'll encounter it so often in real projects that it deserves early setup with a library alongside your other infrastructure decisions. That's why we cover it here.

In the first endpoint document, FilmRestMapper was a hand-written class that converted a Film domain model into a FilmResponse DTO field by field. That works for seven fields. It stops being acceptable when you have dozens of fields, nested objects, and type conversions to manage, and it never generates errors at compile time when a new field is added to the domain but forgotten in the mapper.

This document replaces the hand-written mapper with a dedicated mapping library: MapStruct for Java and Kotlin, ModelMapper for Groovy.

Changed Files

Files to Create/Modify
File Tree
├── build.gradle
└── src
└── main
└── java
└── dev
└── pollito
└── spring_java
├── config
│ └── mapper
│ └── MapperSpringConfig.java
├── sakila
│ └── film
│ └── adapter
│ └── in
│ └── rest
│ ├── ...
│ └── FilmRestMapper.java
└── ...

The build file gets new dependencies, a config class is added to wire the mapper library into Spring, and FilmRestMapper is replaced. For Groovy, the controller also changes because the mapper is no longer a static utility; it's a Spring-managed bean.

Add the Dependency

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 and Kotlin use MapStruct with its Spring extensions. The extensions register every @Mapper as a Spring ConversionService converter automatically, which removes the need to inject individual mappers into classes that need them. Kotlin additionally requires the kapt plugin because MapStruct generates code at compile time through annotation processing, and kapt is Kotlin's annotation processor runner.

Groovy uses ModelMapper, a reflection-based library. Unlike MapStruct, it requires no annotation processing. It works entirely at runtime, matching fields by name and type.

Configure the Mapper Library

Before defining any mapper, each module needs a small configuration class.

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 and Kotlin declare MapperSpringConfig, an interface annotated with @MapperConfig and @SpringMapperConfig. This tells MapStruct two things: use "spring" as the component model (so generated mappers become Spring beans), and where to place the generated ConversionServiceAdapter that registers all converters with Spring's ConversionService.

Groovy declares ModelMapperConfig, a standard @Configuration class that exposes a ModelMapper bean. ModelMapper is stateless and thread-safe, so a single instance shared across the application is the right choice.

Replace 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 and Kotlin now declare FilmRestMapper as an interface. The @Mapper(config = MapperSpringConfig.class) annotation tells MapStruct to generate a Spring-managed implementation at compile time. Extending Converter<Film, FilmResponse> integrates the mapper into Spring's ConversionService, so anywhere Spring knows how to convert types, this mapper will be picked up automatically.

The @Nullable annotation on the Java version is a jspecify nullability annotation. MapStruct respects it and generates a null-safe implementation. If the source is null, the converter returns null without a NullPointerException.

Groovy replaces the static utility class with a Spring @Component. It receives the ModelMapper bean through constructor injection and delegates conversion to it. ModelMapper.map(source, FilmResponse) inspects both classes at runtime and copies matching fields by name and type.

Update the Controller (Groovy Only)

The original Groovy controller imported FilmRestMapper.convert as a static method. Now that FilmRestMapper is a Spring bean, the controller needs to receive it through injection instead.

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))
}
}

Java and Kotlin controllers don't need updating. They already injected FilmRestMapper as a field. That injection still works the same way, just against the MapStruct-generated implementation instead of the hand-written class.