Access a database with MyBatis

Learn how to access a database with MyBatis using the Micronaut framework.

Authors: Iván López, Sergio del Amo

Micronaut Version: 3.1.0

1. Getting Started

Learn how to access a database with MyBatis using the Micronaut framework.

2. What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

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.

4. Writing the Application

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

mn create-app example.micronaut.micronautguide --build=gradle --lang=java
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.

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

4.1. Enable annotation Processing

If you use Java or Kotlin and IntelliJ IDEA, make sure to enable annotation processing.

annotationprocessorsintellij

4.2. Configure Data Source and JPA

Add the following snippet to include the necessary dependencies:

build.gradle
implementation("org.mybatis:mybatis:3.5.7") (1)
implementation("io.micronaut.sql:micronaut-jdbc-hikari") (2)
runtimeOnly("com.h2database:h2") (3)
1 Add MyBatis dependency.
2 Configures SQL DataSource instances using Hikari Connection Pool.
3 Add dependency to in-memory H2 Database.

Define the data source in src/main/resources/application.yml.

src/main/resources/application.yml
datasources:
  default:
    url: jdbc:h2:mem:default;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password: ""
    driverClassName: org.h2.Driver

4.3. MyBatis configuration

As there is no out-of-the-box support yet in the Micronaut framework for MyBatis, it is necessary to manually wire SqlSessionFactory.

Create the following @Factory file src/main/java/example/micronaut/MybatisFactory.java:

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

import io.micronaut.context.annotation.Bean;
import io.micronaut.context.annotation.Factory;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.TransactionFactory;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;

import jakarta.inject.Singleton;
import javax.sql.DataSource;

@Factory (1)
public class MybatisFactory {

    private final DataSource dataSource; (2)

    public MybatisFactory(DataSource dataSource) {
        this.dataSource = dataSource; (2)
    }

    @Singleton (3)
    public SqlSessionFactory sqlSessionFactory() {
        TransactionFactory transactionFactory = new JdbcTransactionFactory();

        Environment environment = new Environment("dev", transactionFactory, dataSource); (4)
        Configuration configuration = new Configuration(environment);
        configuration.addMappers("example.micronaut"); (5)

        return new SqlSessionFactoryBuilder().build(configuration); (6)
    }

}
1 Annotate the class with @Factory.
2 Use constructor injection to inject a bean of type DataSource
3 Define a @Bean of type SqlSessionFactory.
4 Use the dataSource to create a new MyBatis environment.
5 Define the package to scan for mappers.
6 Create a new SqlSessionFactory bean.

4.4. Domain

Create the domain entities:

src/main/java/example/micronaut/domain/Genre.java
package example.micronaut.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;

import javax.validation.constraints.NotNull;
import java.util.HashSet;
import java.util.Set;

@Introspected
public class Genre {

    @Nullable
    private Long id;

    @NotNull
    @NonNull
    private String name;

    public Genre() {
    }

    public Genre(@NonNull @NotNull String name) {
        this.name = name;
    }

    @NonNull
    @JsonIgnore
    private Set<Book> books = new HashSet<>();

    @Nullable
    public Long getId() {
        return id;
    }

    public void setId(@Nullable Long id) {
        this.id = id;
    }

    @NonNull
    public String getName() {
        return name;
    }

    public void setName(@NonNull String name) {
        this.name = name;
    }

    @NonNull
    public Set<Book> getBooks() {
        return books;
    }

    public void setBooks(@NonNull Set<Book> books) {
        this.books = books;
    }

    @Override
    public String toString() {
        return "Genre{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", books=" + books +
                '}';
    }
}

4.5. Repository Access

Create an interface to define the operations to access the database and use MyBatis annotations to map the methods to SQL queries:

src/main/java/example/micronaut/genre/GenreMapper.java
package example.micronaut.genre;

import example.micronaut.domain.Genre;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.PositiveOrZero;
import java.util.List;

public interface GenreMapper {

    @Select("select * from genre where id=#{id}")
    Genre findById(long id);

    @Insert("insert into genre(name) values(#{name})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void save(Genre genre);

    @Delete("delete from genre where id=#{id}")
    void deleteById(long id);

    @Update("update genre set name=#{name} where id=#{id}")
    void update(@Param("id") long id, @Param("name") String name);

    @Select("select * from genre")
    List<Genre> findAll();

    @Select("select * from genre order by ${sort} ${order}")
    List<Genre> findAllBySortAndOrder(@NotNull @Pattern(regexp = "id|name") String sort,
                                      @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order);

    @Select("select * from genre order by ${sort} ${order} limit ${offset}, ${max}")
    List<Genre> findAllByOffsetAndMaxAndSortAndOrder(@NotNull @PositiveOrZero Integer offset,
                                                     @Positive @NotNull Integer max,
                                                     @NotNull @Pattern(regexp = "id|name") String sort,
                                                     @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order);

    @Select("select * from genre limit ${offset}, ${max}")
    List<Genre> findAllByOffsetAndMax(@NotNull @PositiveOrZero Integer offset, @Positive @NotNull Integer max);

}

And the implementation:

src/main/java/example/micronaut/genre/GenreMapperImpl.java
package example.micronaut.genre;

import example.micronaut.domain.Genre;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;

import jakarta.inject.Singleton;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.PositiveOrZero;
import java.util.List;

@Singleton (1)
public class GenreMapperImpl implements GenreMapper {

    private final SqlSessionFactory sqlSessionFactory; (2)

    public GenreMapperImpl(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactory = sqlSessionFactory; (2)
    }

    @Override
    public Genre findById(long id) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) { (3)
            return getGenreMapper(sqlSession).findById(id); (5)
        }
    }

    private GenreMapper getGenreMapper(SqlSession sqlSession) {
        return sqlSession.getMapper(GenreMapper.class); (4)
    }


    @Override
    public void save(Genre genre) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            getGenreMapper(sqlSession).save(genre);
            sqlSession.commit(); (6)
        }
    }

    @Override
    public void deleteById(long id) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            getGenreMapper(sqlSession).deleteById(id);
            sqlSession.commit();
        }
    }

    @Override
    public void update(long id, String name) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            getGenreMapper(sqlSession).update(id, name);
            sqlSession.commit();
        }
    }

    @Override
    public List<Genre> findAll() {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAll();
        }
    }

    @Override
    public List<Genre> findAllBySortAndOrder(@NotNull @Pattern(regexp = "id|name") String sort,
                                             @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAllBySortAndOrder(sort, order);
        }
    }

    @Override
    public List<Genre> findAllByOffsetAndMaxAndSortAndOrder(@NotNull @PositiveOrZero Integer offset,
                                                            @Positive @NotNull Integer max,
                                                            @NotNull @Pattern(regexp = "id|name") String sort,
                                                            @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAllByOffsetAndMaxAndSortAndOrder(offset, max, sort, order);
        }
    }

    @Override
    public List<Genre> findAllByOffsetAndMax(@NotNull @PositiveOrZero Integer offset,
                                             @Positive @NotNull Integer max) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAllByOffsetAndMax(offset, max);
        }
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 Easily inject the SqlSessionFactory bean created by the @Factory.
3 Use try-with-resources to automatically close the SQL session.
4 Get MyBatis mapper implementation for the interface.
5 Execute the desired method using the mapper. This will trigger the SQL query.
6 In a database write access, commit the transaction.

Create an interface to define the high level operations exposed to the application:

src/main/java/example/micronaut/genre/GenreRepository.java
package example.micronaut.genre;

import io.micronaut.core.annotation.NonNull;
import example.micronaut.ListingArguments;
import example.micronaut.domain.Genre;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;

public interface GenreRepository {

    @NonNull
    Optional<Genre> findById(@NonNull @NotNull Long id);

    @NonNull
    Genre save(@NonNull @NotBlank String name);

    void deleteById(@NonNull @NotNull Long id);

    @NonNull
    List<Genre> findAll(@NonNull @NotNull ListingArguments args);

    int update(@NonNull @NotNull Long id, @NonNull @NotBlank String name);
}

And the implementation using GenreMapper:

src/main/java/example/micronaut/genre/GenreRepositoryImpl.java
package example.micronaut.genre;

import io.micronaut.core.annotation.NonNull;
import example.micronaut.ListingArguments;
import example.micronaut.domain.Genre;

import jakarta.inject.Singleton;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;

@Singleton (1)
public class GenreRepositoryImpl implements GenreRepository {

    private final GenreMapper genreMapper;

    public GenreRepositoryImpl(GenreMapper genreMapper) {
        this.genreMapper = genreMapper;
    }

    @Override
    @NonNull
    public Optional<Genre> findById(@NonNull @NotNull Long id) {
        return Optional.ofNullable(genreMapper.findById(id));
    }

    @Override
    @NonNull
    public Genre save(@NonNull @NotBlank String name) {
        Genre genre = new Genre(name);
        genreMapper.save(genre);
        return genre;
    }

    @Override
    public void deleteById(@NonNull @NotNull Long id) {
        findById(id).ifPresent(genre -> genreMapper.deleteById(id));
    }

    @NonNull
    public List<Genre> findAll(@NonNull @NotNull ListingArguments args) {

        if (args.getMax().isPresent() && args.getSort().isPresent() && args.getOffset().isPresent() && args.getSort().isPresent()) {
            return genreMapper.findAllByOffsetAndMaxAndSortAndOrder(args.getOffset().get(),
                    args.getMax().get(),
                    args.getSort().get(),
                    args.getOrder().get());
        }
        if (args.getMax().isPresent() && args.getOffset().isPresent() && (!args.getSort().isPresent() || !args.getOrder().isPresent())) {
            return genreMapper.findAllByOffsetAndMax(args.getOffset().get(),
                    args.getMax().get());
        }
        if ((!args.getMax().isPresent() || !args.getOffset().isPresent()) && args.getSort().isPresent() && args.getOrder().isPresent()) {
            return genreMapper.findAllBySortAndOrder(args.getSort().get(),
                    args.getOrder().get());
        }
        return genreMapper.findAll();
    }

    @Override
    public int update(@NonNull @NotNull Long id, @NonNull @NotBlank String name) {
        genreMapper.update(id, name);
        return -1;
    }
}

4.6. Controller

Micronaut validation is built on the standard framework – JSR 380, also known as Bean Validation 2.0.

Hibernate Validator is a reference implementation of the validation API. Starting with Micronaut 1.2, Micronaut has built-in support for validation of beans that are annotated with javax.validation annotations.

The necessary dependencies are included by default when creating a new application, so you don’t need to add anything else.

Create two classes to encapsulate Save and Update operations:

src/main/java/example/micronaut/genre/GenreSaveCommand.java
package example.micronaut.genre;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;

import javax.validation.constraints.NotBlank;

@Introspected
public class GenreSaveCommand {

    @NotBlank
    @NonNull
    private String name;

    public GenreSaveCommand() {
    }

    public GenreSaveCommand(@NonNull @NotBlank String name) {
        this.name = name;
    }

    @NonNull
    public String getName() {
        return name;
    }

    public void setName(@NonNull String name) {
        this.name = name;
    }
}
src/main/java/example/micronaut/genre/GenreUpdateCommand.java
package example.micronaut.genre;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Introspected
public class GenreUpdateCommand {

    @NotNull
    @NonNull
    private Long id;

    @NotBlank
    @NonNull
    private String name;

    public GenreUpdateCommand() {
    }

    public GenreUpdateCommand(@NonNull @NotNull Long id, @NonNull @NotBlank String name) {
        this.id = id;
        this.name = name;
    }

    @NonNull
    public Long getId() {
        return id;
    }

    public void setId(@NonNull Long id) {
        this.id = id;
    }

    @NonNull
    public String getName() {
        return name;
    }

    public void setName(@NonNull String name) {
        this.name = name;
    }
}

Create a POJO to encapsulate Sorting and Pagination:

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

import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.http.uri.UriBuilder;

import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.PositiveOrZero;
import java.net.URI;
import java.util.Optional;

@Introspected
public class ListingArguments {

    @PositiveOrZero
    private Integer offset = 0;

    @Nullable
    @Positive
    private Integer max;

    @Nullable
    @Pattern(regexp = "id|name")
    private String sort;

    @Pattern(regexp = "asc|ASC|desc|DESC")
    @Nullable
    private String order;

    public ListingArguments() {

    }

    public Optional<Integer> getOffset() {
        return Optional.ofNullable(offset);
    }

    public void setOffset(@Nullable Integer offset) {
        this.offset = offset;
    }

    public Optional<Integer> getMax() {
        return Optional.ofNullable(max);
    }

    public void setMax(@Nullable Integer max) {
        this.max = max;
    }

    public Optional<String> getSort() {
        return Optional.ofNullable(sort);
    }

    public void setSort(@Nullable String sort) {
        this.sort = sort;
    }

    public Optional<String> getOrder() {
        return Optional.ofNullable(order);
    }

    public void setOrder(@Nullable String order) {
        this.order = order;
    }

    public static Builder builder() {
        return new Builder();
    }

    public URI of(UriBuilder uriBuilder) {
        if (max != null) {
            uriBuilder.queryParam("max", max);
        }
        if (order != null) {
            uriBuilder.queryParam("order", order);
        }
        if (offset != null) {
            uriBuilder.queryParam("offset", offset);
        }
        if (sort != null) {
            uriBuilder.queryParam("sort", sort);
        }
        return uriBuilder.build();
    }

    public static final class Builder {
        private ListingArguments args = new ListingArguments();

        private Builder() {

        }

        public Builder max(int max) {
            args.setMax(max);
            return this;
        }

        public Builder sort(String sort) {
            args.setSort(sort);
            return this;
        }

        public Builder order(String order) {
            args.setOrder(order);
            return this;
        }

        public Builder offset(int offset) {
            args.setOffset(offset);
            return this;
        }

        public ListingArguments build() {
            return this.args;
        }
    }

}

Create a ConfigurationProperties class to encapsulate the configuration of the default max value.

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

import io.micronaut.core.annotation.NonNull;

public interface ApplicationConfiguration {

    @NonNull
    Integer getMax();
}
src/main/java/example/micronaut/ApplicationConfigurationProperties.java
package example.micronaut;

import io.micronaut.context.annotation.ConfigurationProperties;
import io.micronaut.core.annotation.NonNull;

import javax.validation.constraints.NotNull;

@ConfigurationProperties("application") (1)
public class ApplicationConfigurationProperties implements ApplicationConfiguration {

    protected final Integer DEFAULT_MAX = 10;

    @NonNull
    @NotNull
    private Integer max = DEFAULT_MAX;

    @Override
    @NonNull
    public Integer getMax() {
        return max;
    }

    public void setMax(@NonNull Integer max) {
        this.max = max;
    }
}

Create GenreController, a controller which exposes a resource with the common CRUD operations:

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

import example.micronaut.domain.Genre;
import example.micronaut.genre.GenreRepository;
import example.micronaut.genre.GenreSaveCommand;
import example.micronaut.genre.GenreUpdateCommand;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Delete;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Put;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@Controller("/genres") (1)
public class GenreController {

    protected final GenreRepository genreRepository;

    public GenreController(GenreRepository genreRepository) { (2)
        this.genreRepository = genreRepository;
    }

    @Get("/{id}") (3)
    public Genre show(Long id) {
        return genreRepository
                .findById(id)
                .orElse(null); (4)
    }

    @Put (5)
    public HttpResponse<?> update(@Body @Valid GenreUpdateCommand command) { (6)
        genreRepository.update(command.getId(), command.getName());
        return HttpResponse
                .noContent()
                .header(HttpHeaders.LOCATION, location(command.getId()).getPath()); (7)
    }

    @Get(value = "/list{?args*}") (8)
    public List<Genre> list(@Valid ListingArguments args) {
        return genreRepository.findAll(args);
    }

    @Post (9)
    public HttpResponse<Genre> save(@Body @Valid GenreSaveCommand cmd) {
        Genre genre = genreRepository.save(cmd.getName());

        return HttpResponse
                .created(genre)
                .headers(headers -> headers.location(location(genre.getId())));
    }

    @Delete("/{id}") (10)
    public HttpResponse<?> delete(Long id) {
        genreRepository.deleteById(id);
        return HttpResponse.noContent();
    }

    protected URI location(Long id) {
        return URI.create("/genres/" + id);
    }

    protected URI location(Genre genre) {
        return location(genre.getId());
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /genres
2 Constructor injection.
3 Maps a GET request to /genres/{id} which attempts to show a genre. This illustrates the use of a URL path variable.
4 Returning null when the genre doesn’t exist makes the Micronaut framework respond with 404 (not found).
5 Maps a PUT request to /genres which attempts to update a genre.
6 Add @Valid to any method parameter which requires validation. Use a POJO supplied as a JSON payload in the request to populate command.
7 It is easy to add custom headers to the response.
8 Maps a GET request to /genres which returns a list of genres. This mapping illustrates optional URL parameters.
9 Maps a POST request to /genres which attempts to save a genre.
10 Maps a DELETE request to /genres/{id} which attempts to remove a genre. This illustrates the use of a URL path variable.

4.7. DB Schema

Now that MyBatis is set up, we need a way to create the database schema. For that we will use Micronaut integration with Flyway.

Add the following snippet to include the necessary dependencies:

build.gradle
implementation("io.micronaut.flyway:micronaut-flyway")

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

src/main/resources/application.yml
flyway:
  datasources:
    default:
      enabled: true

Create the file V1__schema.sql with the database schema creation statements:

src/main/resources/db/migration/V1__schema.sql
DROP TABLE IF EXISTS GENRE;
DROP TABLE IF EXISTS BOOK;

CREATE TABLE GENRE (
  id    BIGINT SERIAL PRIMARY KEY NOT NULL,
  name VARCHAR(255)              NOT NULL UNIQUE
);

CREATE TABLE BOOK (
  id    BIGINT SERIAL PRIMARY KEY NOT NULL,
  name VARCHAR(255)              NOT NULL,
  isbn VARCHAR(255)              NOT NULL,
  genre_id BIGINT,
    constraint FKM1T3YVW5I7OLWDF32CWUUL7TA
    foreign key (GENRE_ID) references GENRE
);

During application startup, Flyway will execute the SQL file and create the schema needed for the application.

4.8. Tests

Create a JUnit test to verify the CRUD operations:

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

import example.micronaut.domain.Genre;
import example.micronaut.genre.GenreSaveCommand;
import example.micronaut.genre.GenreUpdateCommand;
import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.http.uri.UriTemplate;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;

import jakarta.inject.Inject;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

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

@MicronautTest (1)
public class GenreControllerTest {

    @Inject
    @Client("/")
    public HttpClient httpClient; (2)

    BlockingHttpClient getClient() {
        return httpClient.toBlocking();
    }

    @Test
    public void supplyAnInvalidOrderTriggersValidationFailure() {
        assertThrows(HttpClientResponseException.class, () -> {
            List<Genre> genres = getClient().retrieve(HttpRequest.GET("/genres/list?order=foo"), Argument.of(List.class, Genre.class));

            assertNotNull(genres);
            assertEquals(0, genres.size());
        });
    }

    @Test
    public void testFindNonExistingGenreReturns404() {
        assertThrows(HttpClientResponseException.class, () -> {
            Genre genre = getClient().retrieve(HttpRequest.GET("/genres/99"), Argument.of(Genre.class));

            assertNull(genre);
        });
    }

    private HttpResponse saveGenre(String genre) {
        HttpRequest request = HttpRequest.POST("/genres", new GenreSaveCommand(genre)); (3)
        return getClient().exchange(request);
    }

    @Test
    public void testGenreCrudOperations() {
        List<Long> genreIds = new ArrayList<>();
        HttpResponse response = saveGenre("DevOps");
        System.out.println(response);
        genreIds.add(entityId(response));
        assertEquals(HttpStatus.CREATED, response.getStatus());

        response = saveGenre("Microservices"); (3)
                System.out.println(response);
        assertEquals(HttpStatus.CREATED, response.getStatus());

        Long id = entityId(response);
        genreIds.add(id);
        Genre genre = show(id);
        assertEquals("Microservices", genre.getName());

        response = update(id, "Micro-services");
                System.out.println(response);
        assertEquals(HttpStatus.NO_CONTENT, response.getStatus());

        genre = show(id);
        assertEquals("Micro-services", genre.getName());

        List<Genre> genres = listGenres(ListingArguments.builder().build());
        assertEquals(2, genres.size());

        genres = listGenres(ListingArguments.builder().max(1).build());
        assertEquals(1, genres.size());
        assertEquals("DevOps", genres.get(0).getName());

        genres = listGenres(ListingArguments.builder().max(1).order("desc").sort("name").build());
        assertEquals(1, genres.size());
        assertEquals("Micro-services", genres.get(0).getName());

        genres = listGenres(ListingArguments.builder().max(1).offset(10).build());
        assertEquals(0, genres.size());

        // cleanup:
        for (Long genreId : genreIds) {
            response = delete(genreId);
            assertEquals(HttpStatus.NO_CONTENT, response.getStatus());
        }
    }

    private List<Genre> listGenres(ListingArguments args) {
        URI uri = args.of(UriBuilder.of("/genres/list"));
        HttpRequest request = HttpRequest.GET(uri);
        return getClient().retrieve(request, Argument.of(List.class, Genre.class)); (4)
    }

    private Genre show(Long id) {
        String uri = UriTemplate.of("/genres/{id}").expand(Collections.singletonMap("id", id));
        HttpRequest request = HttpRequest.GET(uri);
        return getClient().retrieve(request, Genre.class);
    }

    private HttpResponse update(Long id, String name) {
        HttpRequest request = HttpRequest.PUT("/genres", new GenreUpdateCommand(id, name));
        return getClient().exchange(request); (5)
    }

    private HttpResponse delete(Long id) {
        HttpRequest request = HttpRequest.DELETE("/genres/" + id);
        return getClient().exchange(request);
    }

    protected Long entityId(HttpResponse response) {
        String path = "/genres/";
        String value = response.header(HttpHeaders.LOCATION);
        System.out.println(value);

        if (value == null) {
            return null;
        }
        int index = value.indexOf(path);
        if (index != -1) {
            return Long.valueOf(value.substring(index + path.length()));
        }
        return null;
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info.
2 Inject the HttpClient bean and point it to the embedded server.
3 Creating HTTP Requests is easy thanks to the Micronaut framework fluid API.
4 If you care just about the object in the response use retrieve.
5 Sometimes, receiving just the object is not enough and you need information about the response. In this case, instead of retrieve you should use the exchange method.

Run the tests:

$ ./gradlew test

4.9. Running the App

5. Running the Application

To run the application, use the ./gradlew run command which starts the application on port 8080.

We can use curl to check that everything works as expected:

$ curl http://localhost:8080/genres/list
[]

$ curl -X POST -d '{"name":"Sci-fi"}' -H "Content-Type: application/json" http://localhost:8080/genres
{"id":1,"name":"Sci-fi"}

$ curl -X POST -d '{"name":"Science"}' -H "Content-Type: application/json" http://localhost:8080/genres
{"id":2,"name":"Science"}

$ curl http://localhost:8080/genres/list
[{"id":1,"name":"Sci-fi"},{"id":2,"name":"Science"}]

$ curl -X DELETE http://localhost:8080/genres/1

$ curl http://localhost:8080/genres/list
[{"id":2,"name":"Science"}]

6. Generate a Micronaut Application Native Image with GraalVM

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

Compiling native images 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.

6.1. Native image generation

The easiest way to install GraalVM is to use SDKMan.io.

Java 11
$ sdk install java 21.3.0.r11-grl
Java 17
$ sdk install java 21.3.0.r17-grl
If you still use Java 8 use the GraalVM JDK11 version.

You need to install the native-image component, which is not installed by default.

$ gu install native-image

To generate a native image using Gradle, run:

$ ./gradlew nativeImage

The native image is created in build/native-image/application and can be run with ./build/native-image/application

It is possible to customize the name of the native image or pass additional parameters to GraalVM:

build.gradle
nativeImage {
    args('--verbose')
    imageName('mn-graalvm-application') (1)
}
1 The native image name will now be mn-graalvm-application

You can execute the same curl request as before to check that the native image works.

6.2. Next Steps

Read more about Configurations for Data Access section and Flyway support in the Micronaut framework documentation.

7. Help with the Micronaut Framework

Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.