Compilation time source code generators

Micronaut SourceGen exposes a language-neutral API for source code generation.

Version: 1.3.1

1 Introduction

Micronaut SourceGen exposes a language-neutral API for performing source code generation. Since JavaPoet is no longer maintained this module includes a fork of the code with extensions to support Java Records and other modern Java constructs.

An additional API is provided in the io.micronaut.sourcegen.model package that abstracts both JavaPoet and KotlinPoet such that the developer can build source generators in a language neutral manner.

Any processors built with the Micronaut SourceGen API work with:

  • Java

  • Kotlin via KAPT

  • Kotlin via KSP

Note that Groovy is not supported at this time of writing since it lacks APIs to add generated sources to the current compilation unit and perform multiple rounds of processing like Java and Kotlin do.

2 Release History

For this project, you can find a list of releases (with release notes) here:

3 Quick Start

To get started add Micronaut SourceGen to the annotation processor scope of your build configuration:

For Java projects add:

annotationProcessor("io.micronaut.sourcegen:micronaut-sourcegen-generator-java")
<annotationProcessorPaths>
    <path>
        <groupId>io.micronaut.sourcegen</groupId>
        <artifactId>micronaut-sourcegen-generator-java</artifactId>
    </path>
</annotationProcessorPaths>

For Kotlin projects using KSP add:

ksp("io.micronaut.sourcegen:micronaut-sourcegen-generator-kotlin")
<dependency>
    <groupId>io.micronaut.sourcegen</groupId>
    <artifactId>micronaut-sourcegen-generator-kotlin</artifactId>
    <scope>ksp</scope>
</dependency>

or, for those using KAPT add:

kapt("io.micronaut.sourcegen:micronaut-sourcegen-generator-kotlin")
<dependency>
    <groupId>io.micronaut.sourcegen</groupId>
    <artifactId>micronaut-sourcegen-generator-kotlin</artifactId>
    <scope>kapt</scope>
</dependency>

4 Writing a Source Generator

To write a source generator you have a few options. If you only wish to support Java you can use the JavaPoet API that is forked into the io.micronaut.sourcegen.javapoet package. Since JavaPoet is no longer being developed and maintained we recommending using this fork since it is maintained as part of the project and includes support for Java 17+ constructors like Records.

Alternatively, you can also use the language neutral code generation API defined in the io.micronaut.sourcegen.model package that works with both Kotlin and Java adding the ability to write code generators that work cross language.

To get started add dependency on the micronaut-core-processor module:

implementation("io.micronaut:micronaut-core-processor")
<dependency>
    <groupId>io.micronaut</groupId>
    <artifactId>micronaut-core-processor</artifactId>
</dependency>

And the micronaut-sourcegen-model module:

implementation("io.micronaut.sourcegen:micronaut-sourcegen-model")
<dependency>
    <groupId>io.micronaut.sourcegen</groupId>
    <artifactId>micronaut-sourcegen-model</artifactId>
</dependency>

The module you build will have to be placed on the annotation processor classpath of the target project. You CANNOT mix source generation code and application code is the same source tree. Typically you will have a separate project for your source generator a separate project for your application that uses the source generator. In addition the micronaut-core-processor module should NEVER be in the application classpath.

The following is an example of using the API:

import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.inject.ast.ClassElement;
import io.micronaut.inject.processing.ProcessingException;
import io.micronaut.inject.visitor.TypeElementVisitor;
import io.micronaut.inject.visitor.VisitorContext;
import io.micronaut.sourcegen.custom.example.GenerateInterface;
import io.micronaut.sourcegen.generator.SourceGenerator;
import io.micronaut.sourcegen.generator.SourceGenerators;
import io.micronaut.sourcegen.model.InterfaceDef;
import io.micronaut.sourcegen.model.MethodDef;
import io.micronaut.sourcegen.model.TypeDef;

import javax.lang.model.element.Modifier;
import java.io.IOException;

@Internal
public final class GenerateInterfaceBuilder implements TypeElementVisitor<GenerateInterface, Object> { // (1)

    @Override
    public @NonNull VisitorKind getVisitorKind() {
        return VisitorKind.ISOLATING;
    } // (2)

    @Override
    public void visitClass(ClassElement element, VisitorContext context) {
        SourceGenerator sourceGenerator = SourceGenerators.findByLanguage(context.getLanguage()).orElse(null); // (3)
        if (sourceGenerator == null) {
            return;
        }

        String builderClassName = element.getPackageName() + ".MyInterface1";

        InterfaceDef interfaceDef = InterfaceDef.builder(builderClassName) // (4)
            .addModifiers(Modifier.PUBLIC)

            .addMethod(MethodDef.builder("findLong")
                .addModifiers(Modifier.ABSTRACT, Modifier.PUBLIC)
                .returns(Long.class)
                .build())

            .addMethod(MethodDef.builder("saveString")
                .addModifiers(Modifier.ABSTRACT, Modifier.PUBLIC)
                .addParameter("myString", String.class)
                .returns(TypeDef.VOID)
                .build())

            .build();

        context.visitGeneratedSourceFile(interfaceDef.getPackageName(), interfaceDef.getSimpleName(), element) // (5)
            .ifPresent(generatedFile -> {
                try {
                    generatedFile.write(writer -> sourceGenerator.write(interfaceDef, writer));
                } catch (Exception e) {
                    throw new ProcessingException(element, e.getMessage(), e);
                }
            });
    }
}
1 A source generator should implement TypeElementVisitor. The first type argument is the type-level annotation you want to visit in source code the second argument is the member level (field, method, constructor) level annotation. You can specify Object to visit all.
2 The VisitorKind should typically be ISOLATING if you generate a single source file corresponding to a single originating source file (1-to-1 mapping). If you generate a source file that takes into account multiple other source files then change this to AGGREGATING
3 You should obtain an instance of SourceGenerator from the context, in general this should never be null but you never know if a new language is added in the the future.
4 You can use one of the *Def classes as a builder to build the source file. In this case we use InterfaceDef but there are others like RecordDef, ClassDef etc.
5 Once you have built the model write it to a source file!
To see more examples take a look at some of the existing implementations like BuilderAnnotationVisitor that powers the @Builder annotation.

5 Annotations

The module Micronaut SourceGen annotations ships with annotations which you can use in your projects:

To use them, you need the following dependency:

implementation("io.micronaut.sourcegen:micronaut-sourcegen-annotations")
<dependency>
    <groupId>io.micronaut.sourcegen</groupId>
    <artifactId>micronaut-sourcegen-annotations</artifactId>
</dependency>

Annotation Description

Builder

Create a builder of the annotated type.

Wither

Create an interface with copy style method and possible builder style methods for a record

5.1 Builder

If you annotate a Java Record with @Builder, a PersonBuilder class is generated at compilation-time.

import io.micronaut.sourcegen.annotations.Builder;

@Builder
public record Person(Long id, String name, byte[] bytes) {
}

You can use a builder pattern to create a Person instance:

@Test
public void buildsPerson() {
    var person = PersonBuilder.builder()
        .id(123L)
        .name("Cédric")
        .bytes(new byte[]{1,2,3})
        .build();
    assertEquals("Cédric", person.name());
    assertArrayEquals(new byte[]{1, 2, 3}, person.bytes());
    assertEquals(123L, person.id());
}
The BuilderAnnotationVisitor is an example of how to use the Micronaut SourceGen API.

5.2 Wither

If you annotate a Java Record with @Wither, a YourRecordWither interface is generated at compilation-time with all the methods having a default implementation (nothing needs to be implemented). The record class can add that interface using implements YourRecordWither which will expand it with the default withProperty copy style methods like YourRecord withPropertyName(PropertyValue), calling those methods will create a copy of the record with a modified property.

@Wither
public record Walrus (
    @NonNull
    String name,
    int age,
    byte[] chipInfo
) implements WalrusWither  {
}

Example of different ways to use the copy methods:

@Test
public void test() throws Exception {
    Walrus walrus = new Walrus("Abc", 123, new byte[]{56});

    assertEquals(walrus.name(), "Abc");
    assertEquals(walrus.age(), 123);
    assertArrayEquals(walrus.chipInfo(), new byte[]{56});

    Walrus finalWalrus = walrus;
    // The name property is annotated with @NonNull the `withName(null)` method should fail
    assertThrowsExactly(NullPointerException.class, () -> finalWalrus.withName(null));

    walrus = walrus.withName("Xyz");

    assertEquals(walrus.name(), "Xyz");
    assertEquals(walrus.age(), 123);
    assertArrayEquals(walrus.chipInfo(), new byte[]{56});

    walrus = walrus.withAge(99);

    assertEquals(walrus.name(), "Xyz");
    assertEquals(walrus.age(), 99);
    assertArrayEquals(walrus.chipInfo(), new byte[]{56});

    walrus = walrus.withChipInfo(new byte[]{1, 2, 3});

    assertEquals(walrus.name(), "Xyz");
    assertEquals(walrus.age(), 99);
    assertArrayEquals(walrus.chipInfo(), new byte[]{1, 2, 3});
}

If your record is annotated with Builder the wither interface will also include:

  • default YourRecordBuilder with() {…​} method that will return the record builder populated with the current values of the record

  • default YourRecord with(Consumer<YourRecordBuilder> consumer) {…​} a method receiving a lambda that can modify the populated record builder with the current values of the record and producing a new instance of a record as a result

@Wither
@Builder
public record Walrus2(
    String name,
    int age,
    byte[] chipInfo
) implements Walrus2Wither  {
}

Example of different ways to use the copy and builder methods:

@Test
public void testWitherAndBuilder() throws Exception {
    Walrus2 walrus = new Walrus2("Abc", 123, new byte[]{56});

    assertEquals(walrus.name(), "Abc");
    assertEquals(walrus.age(), 123);
    assertArrayEquals(walrus.chipInfo(), new byte[]{56});

    // The name property is NOT annotated with @NotNull so `withName(null)` method should NOT fail
    walrus = walrus.withName(null);

    assertNull(walrus.name());
    assertEquals(walrus.age(), 123);
    assertArrayEquals(walrus.chipInfo(), new byte[]{56});

    walrus = walrus.withName("Xyz");

    assertEquals(walrus.name(), "Xyz");
    assertEquals(walrus.age(), 123);
    assertArrayEquals(walrus.chipInfo(), new byte[]{56});

    walrus = walrus.withAge(99);

    assertEquals(walrus.name(), "Xyz");
    assertEquals(walrus.age(), 99);
    assertArrayEquals(walrus.chipInfo(), new byte[]{56});

    walrus = walrus.withChipInfo(new byte[]{1, 2, 3});

    assertEquals(walrus.name(), "Xyz");
    assertEquals(walrus.age(), 99);
    assertArrayEquals(walrus.chipInfo(), new byte[]{1, 2, 3});

    walrus = walrus.with().build();

    assertEquals(walrus.name(), "Xyz");
    assertEquals(walrus.age(), 99);
    assertArrayEquals(walrus.chipInfo(), new byte[]{1, 2, 3});

    walrus = walrus.with().name("Foobar").build();

    assertEquals(walrus.name(), "Foobar");
    assertEquals(walrus.age(), 99);
    assertArrayEquals(walrus.chipInfo(), new byte[]{1, 2, 3});

    walrus = walrus.with().name("Abc").age(123).chipInfo(new byte[]{9, 8, 7}).build();

    assertEquals(walrus.name(), "Abc");
    assertEquals(walrus.age(), 123);
    assertArrayEquals(walrus.chipInfo(), new byte[]{9, 8, 7});

    walrus = walrus.with(builder -> builder.name("Denis"));

    assertEquals(walrus.name(), "Denis");
    assertEquals(walrus.age(), 123);
    assertArrayEquals(walrus.chipInfo(), new byte[]{9, 8, 7});

    walrus = walrus.with(builder -> builder.name("Kevin").age(1).chipInfo(new byte[]{123}));

    assertEquals(walrus.name(), "Kevin");
    assertEquals(walrus.age(), 1);
    assertArrayEquals(walrus.chipInfo(), new byte[]{123});
}

5.3 Super Builder

To use a builder pattern with types that are using inheritance you annotate a Java bean with @SuperBuilder:

@SuperBuilder
public abstract class Animal {

    private String name;
    private int age;
    private String color;

    // Getters / Setters
}
@SuperBuilder
public class Cat extends Animal {

    private int meowLevel;
    private String bread;

    // Getters / Setters

    @Vetoed // vetoed is currently required.
    public static CatSuperBuilder builder() {
        return new CatSuperBuilder();
    }
}
@SuperBuilder
public class Dog extends Animal {

    private int barkLevel;
    private String bread;
    private boolean big;

    // Getters / Setters
}

For every class annotated with @SuperBuilder there going to be two builders generated at compilation-time, an abstract one intended to be inherited and second one to constructor the bean.

In the previous example five classes are generated:

  • AbstractAnimalSuperBuilder

  • AbstractCatSuperBuilder

  • CatSuperBuilder

  • AbstractDogSuperBuilder

  • DogSuperBuilder

@SuperBuilder requires every super-class to be annotated with @SuperBuilder

You can use a builder to create an instance:

@Test
public void testCat() {
    Cat cat = new CatSuperBuilder()
        .name("MrPurr")
        .age(2)
        .bread("British")
        .meowLevel(100)
        .color("Red")
        .build();

    assertEquals(cat.getName(), "MrPurr");
    assertEquals(cat.getAge(), 2);
    assertEquals(cat.getBread(), "British");
    assertEquals(cat.getMeowLevel(), 100);
    assertEquals(cat.getColor(), "Red");
}

@Test
public void testDog() {
    Dog dog = new DogSuperBuilder()
        .name("MrDog")
        .age(3)
        .bread("JackR")
        .barkLevel(20)
        .color("Blue")
        .big(true)
        .build();

    assertEquals(dog.getName(), "MrDog");
    assertEquals(dog.getAge(), 3);
    assertEquals(dog.getBread(), "JackR");
    assertEquals(dog.getBarkLevel(), 20);
    assertEquals(dog.getColor(), "Blue");
    assertTrue(dog.isBig());
}

5.4 Singular

The @Singular annotation is used together with @Builder or @SuperBuilder on a collection property.

The property annotated as singular will have the following:

  • A method with a singular name to add a single element to a collection

  • A method with a plural name and a collection parameter to include all the elements from that collection

  • A clear+PropertyName to clean all the items of the collection.

The annotation only following collection types:

  • java.lang.Iterable

  • java.util.Collection

  • java.util.List

  • java.util.Set

  • java.util.SortedSet

  • java.util.Map

  • java.util.SortedMap

The final collection is always immutable
An example of a bean with two properties annotated as singular
import io.micronaut.sourcegen.annotations.Singular;
import io.micronaut.sourcegen.annotations.SuperBuilder;

import java.util.List;
import java.util.Map;

@SuperBuilder
public record User(Long id,
                   String name,
                   @Singular List<String> roles,
                   @Singular("property") Map<String, Object> properties) {
}
A test case with different examples how new methods can be used
@Test
public void testSimple() {
    User user = new UserSuperBuilder()
        .id(123L)
        .name("Denis")
        .role("READ")
        .role("WRITE")
        .property("key", "value")
        .build();
    assertEquals(123L, user.id());
    assertEquals("Denis", user.name());
    assertEquals(List.of("READ", "WRITE"), user.roles());
    assertEquals(Map.of("key", "value"), user.properties());
}

@Test
public void testAddAll() {
    User user = new UserSuperBuilder()
        .id(123L)
        .name("Denis")
        .role("READ")
        .role("WRITE")
        .roles(List.of("ROLES_ADMIN", "ROLES_USER"))
        .property("key", "value")
        .properties(Map.of("key1", "value1", "key2", "value2"))
        .build();
    assertEquals(123L, user.id());
    assertEquals("Denis", user.name());
    assertEquals(List.of("READ", "WRITE", "ROLES_ADMIN", "ROLES_USER"), user.roles());
    assertEquals(Map.of("key", "value", "key1", "value1", "key2", "value2"), user.properties());
}

@Test
public void testClear() {
    User user = new UserSuperBuilder()
        .id(123L)
        .name("Denis")
        .role("READ")
        .role("WRITE")
        .clearRoles()
        .roles(List.of("ROLES_ADMIN", "ROLES_USER"))
        .property("key", "value")
        .clearProperties()
        .properties(Map.of("key1", "value1", "key2", "value2"))
        .build();
    assertEquals(123L, user.id());
    assertEquals("Denis", user.name());
    assertEquals(List.of("ROLES_ADMIN", "ROLES_USER"), user.roles());
    assertEquals(Map.of("key1", "value1", "key2", "value2"), user.properties());
}

6 Repository

You can find the source code of this project in this repository: