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, commit(s) Mapper
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 handwritten 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 handwritten 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
├── sakila
│ └── film
│ ├── adapter
│ │ └── in
│ │ └── rest
│ │ ├── ...
│ │ └── FilmRestMapper.java
│ └── ...
└── ...

The build file gets new dependencies, and FilmRestMapper is rewritten. 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 block)
def mapstructVersion = '1.7.0.Beta1'
implementation "org.mapstruct:mapstruct:${mapstructVersion}"
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
  • Java and Kotlin use MapStruct.
    • 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.

(Groovy Only) Configure ModelMapper

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.

spring_groovy/config/mapper/ModelMapperConfig.groovy
package dev.pollito.spring_groovy.config.mapper

import groovy.transform.CompileStatic
import org.modelmapper.ModelMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
@CompileStatic
class ModelMapperConfig {
@Bean
ModelMapper modelMapper() {
new ModelMapper()
}
}

Rewrite FilmRestMapper

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

import static org.mapstruct.MappingConstants.ComponentModel.SPRING;

import dev.pollito.spring_java.sakila.film.adapter.in.rest.dto.FilmResponse;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.mapstruct.Mapper;

@Mapper(componentModel = SPRING)
public interface FilmRestMapper {
FilmResponse map(Film source);
}
  • Java and Kotlin now declare FilmRestMapper as an interface. The @Mapper(componentModel = SPRING) annotation tells MapStruct to generate a Spring-managed implementation at compile time.
  • 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.

(Groovy Only) Update the controller

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
package dev.pollito.spring_groovy.sakila.film.adapter.in.rest

import dev.pollito.spring_groovy.sakila.film.adapter.in.rest.dto.FilmResponse
import dev.pollito.spring_groovy.sakila.film.domain.port.in.FilmUseCases
import groovy.transform.CompileStatic
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/films")
@CompileStatic
class FilmRestController {
FilmUseCases useCases
FilmRestMapper mapper

FilmRestController(FilmUseCases useCases, FilmRestMapper mapper) {
this.useCases = useCases
this.mapper = mapper
}

@GetMapping("/{id}")
FilmResponse getFilm(@PathVariable("id") Integer id) {
mapper.map(useCases.getFilm(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 handwritten class.

With the mapping library in place, FilmRestMapper is no longer maintained by hand. MapStruct generates the Java and Kotlin implementations at compile time, and ModelMapper handles Groovy at runtime. Both approaches integrate with Spring's dependency injection, so the rest of the application stays untouched.