Annotation processing is a Java compilation option which has been around since Java 5. It enables the generation of additional files during compilation, such as classes or documentation. Gradle abstracts the complexity of passing the correct compilation options to Java with the annotationProcessor dependency configuration, which we’ll explore in detail in this article with a full working example.

An example of a popular annotation processor we’ll look at today is mapstruct, which automatically generates mapper classes to map data between Java data objects.

Understanding annotation processing with javac

The javac command runs the Java compiler, and it’s called during any Gradle Java compilation task. Specifically for annotation processing, javac includes this option:

--processor-path path or -processorpath path Specifies where to find annotation processors. If this option isn’t used, then the class path is searched for processors.

An annotation processor is a class which extends AbstractProcessor (in the package javax.annotation.processing) providing the functionality needed to generate whatever it needs to generate, such as classes in the case of mapstruct.

Whatever annotation processing library we want to use can be passed with --processor-path or -processorpath and it will be called by the Java compiler to generate classes.

Annotation processing in Gradle

Fortunately the clever people at Gradle HQ have provided us with a very easy way to specify the above -processorpath compiler option. We do this by marking a particular dependency as part of the annotationProcessor dependency configuration:

dependencies {
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.3.1.Final'
    ...
}

What does this do? Well much like the other dependency configurations (implementation, compileOnly etc.), all we’re really doing is defining what will get passed to the Java compiler when the compileJava Gradle task get executed. In the case of annotationProcessor we’re defining what libraries get passed to the -processorpath javac option.

With a build.gradle as above, the -processorpath calculated by Gradle can be demonstrated if we add this small snippet which prints out the value:

tasks.withType(JavaCompile) {
    doFirst {
        println "AnnotationProcessorPath for $name is ${options.getAnnotationProcessorPath().getFiles()}"
    }
}

If we run ./gradlew compileJava we’ll get output like this:

> Task :compileJava
AnnotationProcessorPath for compileJava is [C:\Users\Tom\.gradle\caches\modules-2\files-2.1\org.mapstruct\mapstruct-processor\1.3.1.Final\a5e2a807eee3372cbfba0685b70505d7c0b7ae9a\mapstruct-
processor-1.3.1.Final.jar]

This clearly shows that the dependency which was specified with annotationProcessor has ended up on the -processorpath of the Java compiler. ✅

A full end-to-end working example

To demonstrate annotation processing with Gradle in action, we’re going to use mapstruct, which again is a library which helps with mapping data objects from one representation to another. In our example, we’ll put together a project to map from a CarEntity class to a CarDto class.

We’ll use this GitHub repository which has everything already set up for you, including:

  • CarEntity and CarDto classes
  • a CarMapper interface
  • a CarMapperTest to ensure the mapping works
  • a build.gradle to build the project and run the test

Feel free to clone the project and try it out as we run through it piece-by-piece. 👌

The way mapstruct works is that you provide it with an interface of a mapper, then during the annotation processing stage it generates an implementation of that interface which you can use at runtime to map between classes.

In our case the CarMapper interface looks like this:

package com.tomgregory;

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper
public interface CarMapper {
    @Mapping(source = "numberOfSeats", target = "seatCount")
    CarDto carToCarDto(CarEntity car);
}

The details of this interface aren’t too important here as we’re just demonstrating the annotation processing, but any fields that match exactly by name will be mapped automatically by mapstruct. Any others can be specified using the @Mapping annotation.

Configuring the mapstruct annotation processor

You can see in the build.gradle below that we have the mapstruct-processor library specified in the annotationProcessor dependency configuration .

plugins {
    id 'java'
}

group 'org.example'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.mapstruct:mapstruct:1.3.1.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.3.1.Final'
    testImplementation group: 'junit', name: 'junit', version: '4.12'
}
  • as well as mapstruct-processor we also include mapstruct as this is the library we use to actually call our generated mapper class (see line 16 of CarMapperTest below)
  • junit is needed for the test we’ll write in the next section

With this configuration when the Java compiler runs it should run the mapstruct-processor annotation processor, which in turn should generate a CarMapperImpl class based on the CarMapper interface. 🏎️

Demonstration using a unit test

To show that this example works with mapstruct, a unit test is included in the GitHub repository:

package com.tomgregory;

import org.junit.Test;
import org.mapstruct.factory.Mappers;

import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;

public class CarMapperTest {

    @Test
    public void mapsCar() {
        CarEntity lamborghini = new CarEntity("Lamborghini", 2);

        CarMapper mapper = Mappers.getMapper( CarMapper.class );
        CarDto carDto = mapper.carToCarDto(lamborghini);

        assertThat(carDto.getMake(), is("Lamborghini"));
        assertThat(carDto.getSeatCount(), is(2));
    }
}
  • we create an instance of the CarMapper, which under the hood uses the newly generated CarMapperImpl class
  • we then use that to convert between a CarEntity and CarDto class
  • we assert the fields have been mapped correctly

The test can be run by running ./gradlew test. In build/reports/tests/index.html you’ll see this report showing that the test passed:

Viewing the generated class

We can see the generated CarMapperImpl class in build/generated/sources/annotationProcessor then java/main/com/tomgregory:

The details of this class are not important, after all that’s why libraries such as mapstruct exist, to hide these complexities. What’s important is that the class has been generated during the compileJava stage of our build.

We can validate this if we comment out line 14 of build.gradle:

dependencies {
    implementation 'org.mapstruct:mapstruct:1.3.1.Final'
//    annotationProcessor 'org.mapstruct:mapstruct-processor:1.3.1.Final'
    testImplementation group: 'junit', name: 'junit', version: '4.12'
}

Now when we run ./gradlew clean compileJava we’ll see an empty generated sources directory because the annotation processor has not been run by the Java compiler:

It’s worth noting that the location into which this class is generated at generated/sources/annotationProcessor is a standard set by the Gradle Java Plugin.

Resources

GitHub repository

Follow along with this article using the example provided in this repository

javac documentation

Read about the -processorpath option in javac in this Oracle documentation

mapstruct documentation

Learn more about the mapstruct library with the official documentation

Other Gradle dependency configurations

To learn more check out the article How to use Gradle api vs. implementation dependencies with the Java Library plugin

Watch this video demonstrating the ideas from this article.

Missing Gradle knowledge makes you slow

A broken build is easy to fix with a quick Google search, but a misconfigured build costs hours of time each week.

Time to build. Time to test. Time to fix.

Knowing the "right" way to build a project isn't obvious, especially with pages of hard-to-follow documentation. But for your project to scale, you must master the build script and configure it effectively.

That's now a lot easier with Gradle Build Bible.

Download this step-by-step guide designed for Java developers like you who are ready to master Gradle.

  • Uncover all the mysteries of the Gradle build script.
  • Fix your build to make development fun again.