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: 4.6.3

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:

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=maven --lang=java
If you don’t specify the --build argument, Gradle with the Kotlin DSL 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.

4.1. Configure Data Source and JPA

Add the following snippet to include the necessary dependencies:

pom.xml
<dependency> (1)
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.10</version>
    <scope>compile</scope>
</dependency>
<dependency> (2)
    <groupId>io.micronaut.sql</groupId>
    <artifactId>micronaut-jdbc-hikari</artifactId>
    <scope>compile</scope>
</dependency>
<dependency> (3)
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
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
Unresolved directive in micronaut-data-access-mybatis-maven-java.adoc - include::build/code/micronaut-data-access-mybatis/micronaut-data-access-mybatis-maven-java/src/main/resources/application.yml[tag=datasource]

4.2. 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 class:

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

import io.micronaut.context.annotation.Factory;
import jakarta.inject.Singleton;
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 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.3. 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.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.serde.annotation.Serdeable;

import jakarta.validation.constraints.NotBlank;
import java.util.HashSet;
import java.util.Set;

@Serdeable
public class Genre {

    @Nullable
    private Long id;

    @NonNull
    @NotBlank
    private String name;

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

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

    @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 +
                '}';
    }
}
src/main/java/example/micronaut/domain/Book.java
package example.micronaut.domain;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.serde.annotation.Serdeable;

import jakarta.validation.constraints.NotBlank;

@Serdeable
public class Book {

    @Nullable
    private Long id;

    @NonNull
    @NotBlank
    private String name;

    @NonNull
    @NotBlank
    private String isbn;

    private Genre genre;

    public Book(@NonNull @NotBlank String isbn,
                @NonNull @NotBlank String name,
                Genre genre) {
        this.isbn = isbn;
        this.name = name;
        this.genre = genre;
    }

    @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 String getIsbn() {
        return isbn;
    }

    public void setIsbn(@NonNull String isbn) {
        this.isbn = isbn;
    }

    public Genre getGenre() {
        return genre;
    }

    public void setGenre(Genre genre) {
        this.genre = genre;
    }

    @Override
    public String toString() {
        return "Book{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", isbn='" + isbn + '\'' +
                ", genre=" + genre +
                '}';
    }
}

4.4. 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 jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.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(@PositiveOrZero int offset,
                                                     @Positive int 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(@PositiveOrZero int offset, @Positive int max);
}

And the implementation:

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

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

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.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)
        }
    }

    @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(@PositiveOrZero int offset,
                                                            @Positive int 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(@PositiveOrZero int offset,
                                             @Positive int max) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAllByOffsetAndMax(offset, max);
        }
    }

    private GenreMapper getGenreMapper(SqlSession sqlSession) {
        return sqlSession.getMapper(GenreMapper.class); (4)
    }
}
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 example.micronaut.ListingArguments;
import example.micronaut.domain.Genre;
import io.micronaut.core.annotation.NonNull;

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

public interface GenreRepository {

    @NonNull
    Optional<Genre> findById(long id);

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

    void deleteById(long id);

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

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

And the implementation using GenreMapper:

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

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

import jakarta.validation.constraints.NotBlank;
import jakarta.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(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(long id) {
        findById(id).ifPresent(genre -> genreMapper.deleteById(id));
    }

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

        if (args.getMax() != null && args.getSort() != null && args.getOffset() != null && args.getOrder() != null) {
            return genreMapper.findAllByOffsetAndMaxAndSortAndOrder(
                    args.getOffset(),
                    args.getMax(),
                    args.getSort(),
                    args.getOrder());
        }

        if (args.getMax() != null && args.getOffset()!= null && (args.getSort() == null || args.getOrder() == null)) {
            return genreMapper.findAllByOffsetAndMax(args.getOffset(), args.getMax());
        }

        if ((args.getMax() == null || args.getOffset() == null) && args.getSort() != null && args.getOrder() !=null) {
            return genreMapper.findAllBySortAndOrder(args.getSort(), args.getOrder());
        }

        return genreMapper.findAll();
    }

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

4.5. Controller

Micronaut validation is built on the standard framework – JSR 380, also known as Bean Validation 2.0. Micronaut Validation has built-in support for validation of beans that are annotated with jakarta.validation annotations.

To use Micronaut Validation, you need the following dependencies:

pom.xml
<!-- Add the following to your annotationProcessorPaths element -->
<path>
    <groupId>io.micronaut.validation</groupId>
    <artifactId>micronaut-validation-processor</artifactId>
</path>
<dependency>
    <groupId>io.micronaut.validation</groupId>
    <artifactId>micronaut-validation</artifactId>
    <scope>compile</scope>
</dependency>

Alternatively, you can use Micronaut Hibernate Validator, which uses Hibernate Validator; a reference implementation of the validation API.

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.NonNull;
import io.micronaut.serde.annotation.Serdeable;

import jakarta.validation.constraints.NotBlank;

@Serdeable
public class GenreSaveCommand {

    @NotBlank
    @NonNull
    private String name;

    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.NonNull;
import io.micronaut.serde.annotation.Serdeable;

import jakarta.validation.constraints.NotBlank;

@Serdeable
public class GenreUpdateCommand {

    private long id;

    @NotBlank
    @NonNull
    private String name;

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

    public long getId() {
        return id;
    }

    public void setId(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.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.serde.annotation.Serdeable;
import io.micronaut.http.uri.UriBuilder;

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

@Serdeable
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(Integer offset, @Nullable Integer max, @Nullable String sort, @Nullable String order) {
        this.offset = offset;
        this.max = max;
        this.sort = sort;
        this.order = order;
    }

    @Nullable
    public Integer getOffset() {
        return offset;
    }

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

    @Nullable
    public Integer getMax() {
        return max;
    }

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

    @Nullable
    public String getSort() {
        return sort;
    }

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

    @Nullable
    public String getOrder() {
        return order;
    }

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

    @NonNull
    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 Integer offset;

        @Nullable
        private Integer max;

        @Nullable
        private String sort;

        @Nullable
        private String order;
        private Builder() {
        }

        @NonNull
        public Builder max(int max) {
            this.max = max;
            return this;
        }

        @NonNull
        public Builder sort(String sort) {
            this.sort = sort;
            return this;
        }

        @NonNull
        public Builder order(String order) {
            this.order = order;
            return this;
        }

        @NonNull
        public Builder offset(int offset) {
            this.offset = offset;
            return this;
        }

        @NonNull
        public ListingArguments build() {
            return new ListingArguments(Optional.ofNullable(offset).orElse(0), max, sort, order);
        }
    }
}

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

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

public interface ApplicationConfiguration {

    int getMax();
}
src/main/java/example/micronaut/ApplicationConfigurationProperties.java
package example.micronaut;

import io.micronaut.context.annotation.ConfigurationProperties;

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

    private final int DEFAULT_MAX = 10;

    private int max = DEFAULT_MAX;

    @Override
    public int getMax() {
        return max;
    }

    public void setMax(int 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 jakarta.validation.Valid;
import java.net.URI;
import java.util.List;

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

    private 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();
    }

    private URI location(Long id) {
        return URI.create("/genres/" + id);
    }
}
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.6. Database Migration with Flyway

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

Flyway automates schema changes, significantly simplifying schema management tasks, such as migrating, rolling back, and reproducing in multiple environments.

Add the following snippet to include the necessary dependencies:

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

We will enable Flyway in the Micronaut configuration file and configure it to perform migrations on one of the defined data sources.

src/main/resources/application.properties
(1)
flyway.datasources.default.enabled=true
1 Enable Flyway for the default datasource.
Configuring multiple data sources is as simple as enabling Flyway for each one. You can also specify directories that will be used for migrating each data source. Review the Micronaut Flyway documentation for additional details.

Flyway migration will be automatically triggered before your Micronaut application starts. Flyway will read migration commands in the resources/db/migration/ directory, execute them if necessary, and verify that the configured data source is consistent with them.

Create the following migration files with the database schema creation:

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

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

CREATE TABLE BOOK (
  id    BIGINT AUTO_INCREMENT 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.7. 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.HttpRequest;
import io.micronaut.http.HttpResponse;
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 jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static io.micronaut.http.HttpHeaders.LOCATION;
import static io.micronaut.http.HttpStatus.CREATED;
import static io.micronaut.http.HttpStatus.NO_CONTENT;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@MicronautTest (1)
public class GenreControllerTest {

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

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

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

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

        response = saveGenre("Microservices"); (3)
        assertEquals(CREATED, response.getStatus());

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

        response = update(id, "Micro-services");
        assertEquals(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(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);
    }

    private Long entityId(HttpResponse<?> response) {
        String value = response.header(LOCATION);
        if (value == null) {
            return null;
        }

        String path = "/genres/";
        int index = value.indexOf(path);
        if (index != -1) {
            return Long.valueOf(value.substring(index + path.length()));
        }

        return null;
    }

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

    private HttpResponse<?> saveGenre(String genre) {
        HttpRequest<?> request = HttpRequest.POST("/genres", new GenreSaveCommand(genre)); (3)
        return getClient().exchange(request);
    }
}
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:

./mvnw test

4.8. Running the App

5. Running the Application

To run the application, use the ./mvnw mn: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 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.

6.1. GraalVM installation

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

Java 21
sdk install java 21.0.5-graal
Java 21
sdk use java 21.0.5-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 21
sdk install java 21.0.2-graalce
Java 21
sdk use java 21.0.2-graalce

6.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.

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

6.3. Next Steps

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

7. Help with the Micronaut Framework

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

8. 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…​).