Micronaut Data and Java Records

Learn how to a leverage Java records for immutable configuration, Micronaut Data Mapped Entities and Projection DTOs

Authors: Sergio del Amo

Micronaut Version: 4.4.1

1. Getting Started

In this guide, we will create a Micronaut application written in Java.

You are going to use Record Classes in a Micronaut application.

Record classes, which are a special kind of class, help to model plain data aggregates with less ceremony than normal classes.

A record declaration specifies in a header a description of its contents; the appropriate accessors, constructor, equals, hashCode, and toString methods are created automatically. A record’s fields are final because the class is intended to serve as a simple "data carrier."

2. What you will need

To complete this guide, you will need the following:

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

Create an application using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app --jdk=17 \
   example.micronaut.micronautguide \
   --features=data-jdbc,postgres,liquibase \
   --build=maven \
   --lang=java \
To use Java records, specify JDK 17 when you create the application.
If you don’t specify the --build argument, Gradle is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.
If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.

The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.

If you use Micronaut Launch, select "Micronaut Application" as application type and add postgres, data-jdbc, and liquibase as features.

3.1. Immutable Configuration with Java Records

src/main/java/example/micronaut/ValueAddedTaxConfiguration.java
package example.micronaut;

import io.micronaut.context.annotation.ConfigurationProperties;
import io.micronaut.core.annotation.NonNull;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;

@ConfigurationProperties("vat") (1)
public record ValueAddedTaxConfiguration(
    @NonNull @NotNull BigDecimal percentage) {
}
1 The @ConfigurationProperties annotation takes the configuration prefix.

Write a test:

src/test/java/example/micronaut/ValueAddedTaxConfigurationTest.java
package example.micronaut;

import io.micronaut.context.BeanContext;
import io.micronaut.context.annotation.Property;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;

@Property(name = "vat.percentage", value = "21.0") (1)
@MicronautTest(startApplication = false) (2)
class ValueAddedTaxConfigurationTest {

    @Inject
    BeanContext beanContext;

    @Test
    void immutableConfigurationViaJavaRecords() {
        assertTrue(beanContext.containsBean(ValueAddedTaxConfiguration.class));
        assertEquals(new BigDecimal("21.0"),
                beanContext.getBean(ValueAddedTaxConfiguration.class).percentage());
    }
}
1 Annotate the class with @Property to supply configuration to the test.
2 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context. This test does not need the embedded server. Set startApplication to false to avoid starting it.

3.2. Database Migration with Liquibase

We need a way to create the database schema. For that, we use Micronaut integration with Liquibase.

Add the following snippet to include the necessary dependencies:

pom.xml
<dependency>
    <groupId>io.micronaut.liquibase</groupId>
    <artifactId>micronaut-liquibase</artifactId>
    <scope>compile</scope>
</dependency>

Configure the database migrations directory for Liquibase in application.yml.

src/main/resources/application.yml
liquibase:
  enabled: true
  datasources:
    default:
      change-log: 'classpath:db/liquibase-changelog.xml'

Create the following files with the database schema creation and a book:

src/main/resources/db/liquibase-changelog.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
         http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
    <include file="changelog/01-create-books-schema.xml" relativeToChangelogFile="true"/>
    <include file="changelog/02-insert-book.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
src/main/resources/db/changelog/01-create-books-schema.xml
<?xml version="1.0" encoding="UTF-8"?>

<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
         http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
    <changeSet id="01" author="sdelamo">
        <createTable tableName="book" remarks="A table to contain all books">
            <column name="isbn" type="varchar(255)">
                <constraints nullable="false" unique="true" primaryKey="true"/>
            </column>
            <column name="title" type="varchar(255)">
                <constraints nullable="false"/>
            </column>
            <column name="price" type="NUMERIC">
                <constraints nullable="false"/>
            </column>
            <column name="about" type="LONGVARCHAR">
                <constraints nullable="true"/>
            </column>
        </createTable>
    </changeSet>
</databaseChangeLog>
src/main/resources/db/changelog/02-insert-book.xml
<?xml version="1.0" encoding="UTF-8"?>

<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
         http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
    <changeSet id="02" author="sdelamo">
        <insert tableName="book">
            <column name="isbn">0321601912</column>
            <column name="title">Continuous Delivery</column>
            <column name="price">39.99</column>
            <column name="about">Winner of the 2011 Jolt Excellence Award! Getting software released to users is often a painful, risky, and time-consuming process. This groundbreaking new book sets out the principles and technical practices that enable rapid, incremental delivery of high quality, valuable new functionality to users.</column>
        </insert>
    </changeSet>
</databaseChangeLog>

During application startup, Liquibase executes the SQL file, creates the schema needed for the application and inserts one book.

3.3. Mapped Entities with Java Records

Create a Micronaut Data Mapped Entity

src/main/java/example/micronaut/Book.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;

@MappedEntity (1)
public record Book(@Id @NonNull @NotBlank @Size(max = 255) String isbn, (2)
                   @NonNull @NotBlank @Size(max = 255) String title, (3)
                   @NonNull @NotNull BigDecimal price, (3)
                   @Nullable String about) {  (4)
}
1 Annotate the class with @MappedEntity to map the class to the table defined in the schema.
2 The primary key is annotated with @Id. It is an assigned primary key.
3 Use jakarta.validation.constraints Constraints to ensure the data matches your expectations.
4 Annotate about with @Nullable, since it is optional.

3.4. Projections with Java Records

Create a record to project some data from the book table. For example, exclude the about field.

src/main/java/example/micronaut/BookCard.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.serde.annotation.Serdeable;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;

@Serdeable (1)
public record BookCard(@NonNull @NotBlank @Size(max = 255) String isbn, (2)
                       @NonNull @NotBlank @Size(max = 255) String title, (2)
                       @NonNull @NotNull BigDecimal price) {
}
1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.
2 Use jakarta.validation.constraints Constraints to ensure the data matches your expectations.

Create a Repository, which uses the previous Java record as a DTO projection.

src/main/java/example/micronaut/BookRepository.java
package example.micronaut;

import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import java.util.List;

@JdbcRepository(dialect = Dialect.POSTGRES) (1)
public interface BookRepository extends CrudRepository<Book, String> { (2)
    List<BookCard> find(); (3)
}
1 @JdbcRepository with a specific dialect.
2 By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations.
3 Micronaut Data supports reflection-free Data Transfer Object (DTO) projections if the return type is annotated with @Introspected.

3.5. JSON serialization with Java Records

Create a Java record to represent a JSON response:

src/main/java/example/micronaut/BookForSale.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.ReflectiveAccess;
import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;

@Serdeable (1)
@ReflectiveAccess (2)
public record BookForSale(@NonNull @NotBlank String isbn,  (3)
                          @NonNull @NotBlank String title,
                          @NonNull @NotNull BigDecimal price) { }
1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.
2 Jackson requires reflection to use Records. With @ReflectiveAccess the Micronaut framework will add the class to the GraalVM reflect-config.json file.
3 Use jakarta.validation.constraints Constraints to ensure the data matches your expectations.

Create a Controller that uses the previous record:

src/main/java/example/micronaut/BookController.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;

@Controller("/books") (1)
class BookController {

    private final BookRepository bookRepository;
    private final ValueAddedTaxConfiguration valueAddedTaxConfiguration;

    BookController(BookRepository bookRepository, (2)
                   ValueAddedTaxConfiguration valueAddedTaxConfiguration) {
        this.bookRepository = bookRepository;
        this.valueAddedTaxConfiguration = valueAddedTaxConfiguration;
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (3)
    @Get (4)
    List<BookForSale> index() { (5)
        return bookRepository.find()
                .stream()
                .map(bookCard -> new BookForSale(bookCard.isbn(), 
                    bookCard.title(), 
                    salePrice(bookCard)))
                .collect(Collectors.toList());
    }

    @NonNull
    private BigDecimal salePrice(@NonNull BookCard bookCard) {
        return bookCard.price()
                .add(bookCard.price()
                    .multiply(valueAddedTaxConfiguration.percentage()
                        .divide(new BigDecimal("100.0"), 2, RoundingMode.HALF_DOWN)))
                .setScale(2, RoundingMode.HALF_DOWN);
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /books.
2 Use constructor injection to inject a bean of type BookRepository.
3 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
4 The @Get annotation maps the index method to an HTTP GET request on '/'.
5 Respond a Java Record and the application responds JSON representation of the object

3.6. Create a Test

Create a test:

src/test/java/example/micronaut/BookControllerTest.java
package example.micronaut;

import io.micronaut.context.annotation.Property;
import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertEquals;

@Property(name = "vat.percentage", value = "21.0")  (1)
@MicronautTest(transactional = false)  (2)
class BookControllerTest {

    int booksInsertedInDbByMigration = 1;

    @Inject
    @Client("/")
    HttpClient httpClient; (3)

    @Inject
    BookRepository bookRepository;

    @Test
    void recordsUsedForJsonSerialization() {
        String title = "Building Microservices";
        String isbn = "1491950358";
        (4)
        String about = """
                        Distributed systems have become more fine-grained in the past 10 years, shifting from code-heavy monolithic applications to smaller, self-contained microservices. But developing these systems brings its own set of headaches. With lots of examples and practical advice, this book takes a holistic view of the topics that system architects and administrators must consider when building, managing, and evolving microservice architectures.
                                                
                        Microservice technologies are moving quickly. Author Sam Newman provides you with a firm grounding in the concepts while diving into current solutions for modeling, integrating, testing, deploying, and monitoring your own autonomous services. You’ll follow a fictional company throughout the book to learn how building a microservice architecture affects a single domain.
                                                
                        Discover how microservices allow you to align your system design with your organization’s goals
                        Learn options for integrating a service with the rest of your system
                        Take an incremental approach when splitting monolithic codebases
                        Deploy individual microservices through continuous integration
                        Examine the complexities of testing and monitoring distributed services
                        Manage security with user-to-service and service-to-service models
                        Understand the challenges of scaling microservice architectures
                        """;        
        Book b = new Book(isbn,
            title,
            new BigDecimal("38.15"), 
            about);
        Book book = bookRepository.save(b);
        assertEquals(booksInsertedInDbByMigration + 1, bookRepository.count());
        BlockingHttpClient client = httpClient.toBlocking();
        List<BookForSale> books = client.retrieve(HttpRequest.GET("/books"),
                Argument.listOf(BookForSale.class)); (5)
        assertNotNull(books);
        assertEquals(booksInsertedInDbByMigration + 1, books.size());
        assertEquals("Building Microservices", books.get(1).title());
        assertEquals("1491950358", books.get(1).isbn());
        assertEquals(new BigDecimal("46.16"), books.get(1).price());
        bookRepository.delete(book);
    }
}
1 Annotate the class with @Property to supply configuration to the test.
2 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. By default, each @Test method will be wrapped in a transaction that will be rolled back when the test finishes. This behaviour is is changed by setting transaction to false.
3 Inject the HttpClient bean and point it to the embedded server.
4 Multiline string
5 Micronaut HTTP Client simplifies binding a JSON array to a list of POJOs by using Argument::listOf.

4. Test Resources

When the application is started locally — either under test or by running the application — resolution of the datasource URL is detected and the Test Resources service will start a local PostgreSQL docker container, and inject the properties required to use this as the datasource.

For more information, see the JDBC section of the Test Resources documentation.

5. Datasource configuration

Although the URL is configured automatically via Test Resources, we must configure the PostgreSQL driver and dialect in application.yml:

src/main/resources/application.yml
datasources:
  default:
    driverClassName: org.postgresql.Driver  (1)
    dialect: POSTGRES (2)
    schema-generate: NONE (3)
1 Use PostgreSQL driver.
2 Configure the PostgreSQL dialect.
3 You handle database migrations via Liquibase

6. Testing the Application

To run the tests:

./mvnw test

7. Running the application

Set up the environment variable to configure the VAT percentage.

Configure

export VAT_PERCENTAGE=20

To run the application, use the ./mvnw mn:run command, which starts the application on port 8080.

You can run a cURL command to test the application:

curl http://localhost:8080/books
[{"isbn":"0321601912","title":"Continuous Delivery","price":47.99}]

8. Generate a Micronaut Application Native Executable with GraalVM

We will use GraalVM, the polyglot embeddable virtual machine, to generate a native executable of our Micronaut application.

Compiling native executables ahead of time with GraalVM improves startup time and reduces the memory footprint of JVM-based applications.

Only Java and Kotlin projects support using GraalVM’s native-image tool. Groovy relies heavily on reflection, which is only partially supported by GraalVM.

8.1. GraalVM installation

The easiest way to install GraalVM on Linux or Mac is to use SDKMan.io.

Java 17
sdk install java 17.0.8-graal
Java 17
sdk use java 17.0.8-graal

For installation on Windows, or for manual installation on Linux or Mac, see the GraalVM Getting Started documentation.

The previous command installs Oracle GraalVM, which is free to use in production and free to redistribute, at no cost, under the GraalVM Free Terms and Conditions.

Alternatively, you can use the GraalVM Community Edition:

Java 17
sdk install java 17.0.8-graalce
Java 17
sdk use java 17.0.8-graalce

8.2. Native executable generation

To generate a native executable using Maven, run:

./mvnw package -Dpackaging=native-image

The native executable is created in the target directory and can be run with target/micronautguide.

Due to a bug with GraalVM and Java Records it is necessary to include the flag --report-unsupported-elements-at-runtime when building the native executable. Create the file native-image.properties:
src/main/resources/META-INF/native-image/example.micronaut/guide/native-image.properties
Args = --report-unsupported-elements-at-runtime

You can run a cURL command to test the application:

curl http://localhost:8080/books
[{"isbn":"0321601912","title":"Continuous Delivery","price":47.99}]

You receive an empty array because there are no books in the database. You can create a Liquibase changelog to add seed data.

9. Next steps

Explore more features with Micronaut Guides.

10. Help with the Micronaut Framework

The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.

11. License

All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…​).