Avoiding Boilerplate Code With MapStruct, Spring Boot and Kotlin

Avoiding Boilerplate Code With MapStruct, Spring Boot and Kotlin

Automatic DTO handling in Kotlin

Building Spring Boot Rest Web Services might become cumbersome, especially when domain model objects need to be converted in many DTOs (Data Transfer Object) and vice versa. Writing mapping manually is boring and involves boilerplate code. Is it possible to avoid it?

Yes, it is! Thanks to MapStruct! MapStruct is a Java annotation processor for the generation of type-safe and performant mappers for Java bean classes.

Integrating MapStruct with Spring Boot in Java is well documented, but what if we wanted to use Kotlin?

Let's see how MapStruct can be integrated into a Spring Boot and Kotlin project.

1. Gradle Dependency

Let's add the Kapt compiler plugin and the MapStruct processor dependencies in build.gradle.kts.

plugins {
   kotlin("kapt") version "1.3.72"
}
dependencies {   
   kapt("org.mapstruct:mapstruct-processor:1.3.1.Final")
}

_mapstruct-processor_ is required to generate the mapper implementation during build-time, while _kapt_ is the Kotlin Annotation Processing Tool, and it is used to reference the generated code from Kotlin.

Now, our build.gradle.kts file looks like this:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.3.1.RELEASE"
    id("io.spring.dependency-management") version "1.0.9.RELEASE"
    kotlin("jvm") version "1.3.72"
    kotlin("plugin.spring") version "1.3.72"
    kotlin("kapt") version "1.3.72"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.springframework.boot:spring-boot-starter-web:2.3.1.RELEASE")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.8")
    implementation("org.mapstruct:mapstruct-jdk8:1.3.1.Final")
    kapt("org.mapstruct:mapstruct-processor:1.3.1.Final")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    }
}

2. Creating the Domain Model Objects and Their DTOs

We are going to create two domain model classes: Author and Book.

class Author {
    var id: Int? = null
    var name: String? = null
    var surname: String? = null
    var birthDate: Date? = null
    val books: MutableList<Book> = ArrayList()

    // the default constructor is required by MapStruct to convert
    // a DTO object into a model domain object
    constructor()

    constructor(id : Int, name: String, surname: String, birthDate: Date) {
        this.id = id
        this.name = name
        this.surname = surname
        this.birthDate = birthDate
    }

    constructor(id: Int, name: String, surname: String, birthDate: Date, books: List<Book>) : this(id, name, surname, birthDate) {
        this.books.addAll(books)
    }
}
class Book {
    var id: Int? = null
    var title: String? = null
    var releaseDate: Date? = null

    // the default constructor is required by MapStruct to convert
    // a DTO object into a model domain object
    constructor()

    constructor(id : Int, title : String, releaseDate: Date) {
        this.id = id
        this.title = title
        this.releaseDate = releaseDate
    }
}

Now, we need to create the corresponding DTO classes.

class AuthorDto {
    @JsonProperty("id")
    var id: Int? = null

    @JsonProperty("name")
    var name: String? = null

    @JsonProperty("surname")
    var surname: String? = null

    @JsonProperty("birth")
    @JsonFormat(pattern = "MM/dd/yyyy")
    var birthDate: Date? = null

    @JsonProperty("books")
    var books: List<BookDto> = ArrayList()
}
class BookDto {
    @JsonProperty("id")
    var id: Int? = null

    @JsonProperty("title")
    var title: String? = null

    @JsonProperty("release")
    @JsonFormat(pattern = "MM/dd/yyyy")
    var releaseDate: Date? = null
}

What if we wanted a special DTO for Author? Maybe, we might be interested only in the first book written by an author, and not in their birthdate. Let's build a special DTO to accomplish this goal.

class SpecialAuthorDto {
    @JsonProperty("name")
    var name: String? = null
    @JsonProperty("surname")
    var surname: String? = null
    // no birthDate
    @JsonProperty("firstBook")  
    var firstBook : BookDto? = null
}

3. Defining the MapStruct Mappers

MapStruct is not based on magic and, in order to map domain model objects in DTO objects, requires the definition of one or more mappers.

Each mapper is an interface or an abstract class and must be annotated with @Mapper annotation. This causes the MapStruct code generator to create an implementation of the mapper interface or abstract class during build-time. Hence, implementation is not required. This is where the magic begins and allows us to avoid boilerplate code.

By default, in the generated method implementations all readable properties from the source type (e.g., Author) will be mapped into the corresponding property in the target type (e.g., AuthorDto).

Let's see how a mapper can be defined.

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
interface AuthorMapper {
    fun authorToAuthorDto(
        author : Author
    ) : AuthorDto

    fun authorsToAuthorDtos(
        authors : List<Author>
    ) : List<AuthorDto>
}

The componentModel attribute is set to "spring" to force the MapStruct processor to generate a singleton Spring bean mapper that can be injected directly where need it.

If you are interested in studying what the MapStruct processor produced at build-time, you can find the implementation classes in build/generated/source/kapt/.

Let's see an example.

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2020-06-28T09:24:47+0200",
    comments = "version: 1.3.1.Final, compiler: javac, environment: Java 13.0.2 (Oracle Corporation)"
)
@Component
public class AuthorMapperImpl implements AuthorMapper {

    @Override
    public AuthorDto authorToAuthorDto(Author author) {
        if ( author == null ) {
            return null;
        }

        AuthorDto authorDto = new AuthorDto();

        authorDto.setId( author.getId() );
        authorDto.setName( author.getName() );
        authorDto.setSurname( author.getSurname() );
        authorDto.setBirthDate( author.getBirthDate() );
        authorDto.setBooks( bookListToBookDtoList( author.getBooks() ) );

        return authorDto;
    }

    @Override
    public List<AuthorDto> authorsToAuthorDtos(List<Author> authors) {
        if ( authors == null ) {
            return null;
        }

        List<AuthorDto> list = new ArrayList<AuthorDto>( authors.size() );
        for ( Author author : authors ) {
            list.add( authorToAuthorDto( author ) );
        }

        return list;
    }

    protected BookDto bookToBookDto(Book book) {
        if ( book == null ) {
            return null;
        }

        BookDto bookDto = new BookDto();

        bookDto.setId( book.getId() );
        bookDto.setTitle( book.getTitle() );
        bookDto.setReleaseDate( book.getReleaseDate() );

        return bookDto;
    }

    protected List<BookDto> bookListToBookDtoList(List<Book> list) {
        if ( list == null ) {
            return null;
        }

        List<BookDto> list1 = new ArrayList<BookDto>( list.size() );
        for ( Book book : list ) {
            list1.add( bookToBookDto( book ) );
        }

        return list1;
    }
}

What if we need a custom mapping logic, as in the SpecialAuthor example?

We have to tell the MapStruct processor that a specific field of the source object has to be mapped through a special method, containing the mapping logic.

In order to make MaStruct build a correct implementation class, a mapper abstract class is required. In fact, Kotlin default method implementation in interfaces seems to be ignored by the MapStruct processor.

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
abstract class SpecialAuthorMapper {
    // author's book property is accessed through java setter
    @Mappings(
        Mapping(target="firstBook", expression = "java(booksToFirstBook(author.getBooks()))")
    )
    abstract fun authorToSpecialAuthorDto(
        author : Author
    ) : SpecialAuthorDto

    // required in order to convert Book into BookDto
    // in booksToFirstBook
    abstract fun bookToBookDto(
        book : Book
    ) : BookDto

    // converting books into the first released book
    fun booksToFirstBook(books : List<Book>) : BookDto {
        return bookToBookDto(
            books
                .sortedWith(Comparator { e1: Book, e2: Book -> e1.releaseDate.compareTo(e2.releaseDate) })
                .first()
        )
    }
}

In the expression field we need to use Java-like setters, since Java is required.

4. Putting It All Together

Let's create a controller and test our work.

@RestController
@RequestMapping("/authors")
class AuthorController {
    @Autowired
    lateinit var authorRepository: AuthorRepository

    @Autowired
    lateinit var authorMapper: AuthorMapper

    @Autowired
    lateinit var specialAuthorMapper: SpecialAuthorMapper

    @GetMapping
    fun getAll() : ResponseEntity<List<AuthorDto>> {
        return ResponseEntity(
            authorMapper.authorsToAuthorDtos(authorRepository.findAll()),
            HttpStatus.OK)
    }

    @GetMapping("special/{id}")
    fun getSpecial(@PathVariable(value = "id") id: Int) : ResponseEntity<SpecialAuthorDto> {
        return ResponseEntity(
            specialAuthorMapper.authorToSpecialAuthorDto(authorRepository.find(id)),
            HttpStatus.OK)
    }

    @GetMapping("{id}")
    fun get(@PathVariable(value = "id") id: Int) : ResponseEntity<AuthorDto> {
        return ResponseEntity(
            authorMapper.authorToAuthorDto(authorRepository.find(id)),
            HttpStatus.OK)
    }
}

The two mappers are injected into the controller, which uses them to produce the required DTO classes. These classes are then converted into JSON by Jackson and used to build the response of each API.

Let's generate some authors and books.

@Component
class DataSource {
    val data : MutableList<Author> = ArrayList()

    init {
        val book1 = Book(
            1,
            "Book 1",
            GregorianCalendar(2018, 10, 24).time
        )

        val book2 = Book(
            2,
            "Book 2",
            GregorianCalendar(2010, 7, 12).time
        )

        val book3 = Book(
            3,
            "Book 3",
            GregorianCalendar(2011, 3, 8).time
        )

        val author1 = Author(
                1,
                "John",
                "Smith",
                GregorianCalendar(1967, 1, 2).time,
                listOf(book1, book2, book3)
        )

        val book4 = Book(
            4,
            "Book 4",
            GregorianCalendar(2012, 12, 9).time
        )

        val book5 = Book(
            5,
            "Book 5",
            GregorianCalendar(2017, 9, 22).time
        )

        val author2 = Author(
                2,
                "Emma",
                "Potter",
                GregorianCalendar(1967, 1, 2).time,
                listOf(book5, book4)
        )

        data.addAll(listOf(author1, author2))
    }
}

Then, these will be the responses of each API:

[
   {
      "id":1,
      "name":"John",
      "surname":"Smith",
      "birth":"02/01/1967",
      "books":[
         {
            "id":1,
            "title":"Book 1",
            "release":"11/23/2018"
         },
         {
            "id":2,
            "title":"Book 2",
            "release":"08/11/2010"
         },
         {
            "id":3,
            "title":"Book 3",
            "release":"04/07/2011"
         }
      ]
   },
   {
      "id":2,
      "name":"Emma",
      "surname":"Potter",
      "birth":"02/01/1967",
      "books":[
         {
            "id":5,
            "title":"Book 5",
            "release":"10/21/2017"
         },
         {
            "id":4,
            "title":"Book 4",
            "release":"01/08/2013"
         }
      ]
   }
]
{
   "id":1,
   "name":"John",
   "surname":"Smith",
   "birth":"02/01/1967",
   "books":[
      {
         "id":1,
         "title":"Book 1",
         "release":"11/23/2018"
      },
      {
         "id":2,
         "title":"Book 2",
         "release":"08/11/2010"
      },
      {
         "id":3,
         "title":"Book 3",
         "release":"04/07/2011"
      }
   ]
}
{
   "name":"John",
   "surname":"Smith",
   "firstBook":{
      "id":2,
      "title":"Book 2",
      "release":"08/11/2010"
   }
}

Extras

DTOs can also be used to define a specific (de)serialization layer in a multi-layered architecture as described here: Designing a Multi-Layered Architecture for Building RESTful Web Services With Spring Boot and Kotlin.

The source code of his article can be found on my GitHub repository.

I hope this helps someone use MapStruct with Spring Boot and Kotlin!


The post "Avoiding Boilerplate Code With MapStruct, Spring Boot and Kotlin" appeared first on Writech.