mn create-app example.micronaut.micronautguide \
--features=flyway,jooq,postgres,testcontainers,assertj \
--build=maven \
--lang=java
Table of Contents
- 1. About this Guide
- 2. What you will need
- 3. Solution
- 4. What we are going to achieve in this guide
- 5. Writing the Application
- 6. Getting Started
- 7. Configuration
- 8. Create Flyway database migration scripts
- 9. Configure jOOQ code generation using Maven plugin
- 10. Create model classes
- 11. Implementing basic database operations using jOOQ
- 12. Tests
- 13. Testcontainers Dependencies
- 14. Fetching complex object trees using jOOQ
- 15. Testing the Application
- 16. Testing with Test Resources
- 17. Summary
- 18. Next Steps
- 19. Help with the Micronaut Framework
- 20. License
Working with jOOQ and Flyway using Testcontainers
This guide shows how to create a Micronaut application with jOOQ support
Authors: Sergio del Amo
Micronaut Version: 4.6.3
1. About this Guide
In this guide, you will learn how to
-
Create a Micronaut application with jOOQ support
-
Generate jOOQ code using Testcontainers, Flyway and Maven Plugin
-
Implement basic database operations using jOOQ
-
Implement logic to load complex object graph using jOOQ
-
Test the jOOQ persistence layer using Testcontainers
-
Simplify testing with Micronaut Test Resources
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 (e.g. IntelliJ IDEA)
-
JDK 21 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.
-
Download and unzip the source
4. What we are going to achieve in this guide
We are going to create a Micronaut project using jOOQ together with PostgreSQL. We will create our database tables by using Flyway Database migrations. We will configure testcontainers-jooq-codegen-maven-plugin to generate jOOQ code using Testcontainers and Flyway migration scripts.
We will implement our persistence layer repositories using jOOQ to manage Users, Posts, and Comments. Then we will test the repositories using Micronaut testing support and Testcontainers PostgreSQL module.
5. Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
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
.
If you use Micronaut Launch, select Micronaut Application as application type and add flyway
, jooq
, postgres
, testcontainers
, and assertj
features.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
6. Getting Started
jOOQ (jOOQ Object Oriented Querying) is a popular open-source library that provides a fluent API for building typesafe SQL queries.
In order to leverage the benefits of TypeSafe DSL provided by jOOQ, we need to generate Java code from our database tables, views, and other objects, which will allow us to interact with the database using a fluent and intuitive API.
To learn more about how jOOQ code-generator will help you, please read Why You Should Use jOOQ With Code Generation. |
In production-grade applications, it is highly recommended to use a database migration tool such as Flyway or Liquibase to apply any changes to the database.
So, the usual process of building and testing the application by generating jOOQ java code from the database is:
-
Create an instance of database using Testcontainers
-
Apply Flyway or Liquibase database migrations
-
Run jOOQ code-generator to generate Java code from the database objects.
-
Run integration tests
The jOOQ code generation can be automated as part of the Maven build process using the testcontainers-jooq-codegen-maven-plugin.
You can also use the official jOOQ code generator maven plugin by using jooq-codegen-maven, groovy-maven-plugin and flyway-maven-plugin together as described in https://blog.jooq.org/using-testcontainers-to-generate-jooq-code/. |
With jOOQ, the database comes first. So, let’s start with creating our database structure using Flyway migration scripts.
7. Configuration
7.1. Data Source Configuration
datasources.default.db-type=postgres
datasources.default.driver-class-name=org.postgresql.Driver
Please note that the configuration does not specify the JDBC URL, username, or password. We will supply them dynamically in our tests or via Test Resources.
7.2. Flyway Configuration
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.
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.
8. Create Flyway database migration scripts
In our sample application, we have users, posts and comments tables. Let’s create our first migration script following the Flyway naming convention.
Create src/main/resources/db/migration/V1__create_tables.sql
file as follows:
create table users
(
id bigserial not null,
name varchar not null,
email varchar not null,
created_at timestamp,
updated_at timestamp,
primary key (id),
constraint user_email_unique unique (email)
);
create table posts
(
id bigserial not null,
title varchar not null,
content varchar not null,
created_by bigint references users (id) not null,
created_at timestamp,
updated_at timestamp,
primary key (id)
);
create table comments
(
id bigserial not null,
name varchar not null,
content varchar not null,
post_id bigint references posts (id) not null,
created_at timestamp,
updated_at timestamp,
primary key (id)
);
ALTER SEQUENCE users_id_seq RESTART WITH 101;
ALTER SEQUENCE posts_id_seq RESTART WITH 101;
ALTER SEQUENCE comments_id_seq RESTART WITH 101;
Note that at the end of the SQL script, we have set our database sequence values to start with 101 so that we can insert some sample data along with primary key values for testing.
9. Configure jOOQ code generation using Maven plugin
Configure the testcontainers-jooq-codegen-maven-plugin in pom.xml
as follows:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
...
<properties>
...
<testcontainers-jooq-codegen-maven-plugin.version>0.0.2</testcontainers-jooq-codegen-maven-plugin.version>
</properties>
...
<build>
<plugins>
...
<plugin>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-jooq-codegen-maven-plugin</artifactId>
<version>${testcontainers-jooq-codegen-maven-plugin.version}</version>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.6.0</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>generate-jooq-sources</id>
<goals>
<goal>generate</goal>
</goals>
<phase>generate-sources</phase>
<configuration>
<database>
<type>POSTGRES</type>
<containerImage>postgres:15.3-alpine</containerImage>
</database>
<flyway>
<locations>
filesystem:src/main/resources/db/migration
</locations>
</flyway>
<jooq>
<generator>
<database>
<includes>.*</includes>
<excludes>flyway_schema_history</excludes>
<inputSchema>public</inputSchema>
</database>
<target>
<packageName>example.micronaut.jooq</packageName>
<directory>target/generated-sources/jooq</directory>
</target>
</generator>
</jooq>
</configuration>
</execution>
</executions>
</plugin>
...
</plugins>
</build>
</project>
Let’s understand the plugin configuration.
-
As we are using PostgreSQL database, we have configured the postgres JDBC driver and Testcontainers postgresql libraries as dependencies of the plugin.
-
Under
<configuration>/<database>
section, we have configured the type of the database, POSTGRES, that we want to use it for our code generation, and specified the Docker image name,postgres:15.3-alpine
, which will be used to create the database instance. -
Under
<configuration>/<flyway>
section we have specified the location of Flyway migration scripts path. -
We have also configured the packageName and target location for the generated code. You can configure all the configuration options supported by the official jooq-code-generator plugin.
The plugin uses Testcontainers to spin up an instance of PostgreSQL container, apply Flyway migrations and then generate the java code using jOOQ code generation tool.
With this configuration in place, now if you run ./mvnw package
then you can find
the generated code under the target/generated-sources/jooq
directory.
10. Create model classes
We may want to create our own model classes to represent the data structures that we want to return for various use-cases. Imagine we are building a REST API, and we may want to return responses with only a subset of column values from our tables.
So, let’s create User, Post and Comment classes as follows:
package example.micronaut.domain;
public record User(
Long id,
String name,
String email
) {}
package example.micronaut.domain;
import java.time.LocalDateTime;
import java.util.List;
public record Post(
Long id,
String title,
String content,
User createdBy,
List<Comment> comments,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
package example.micronaut.domain;
import java.time.LocalDateTime;
public record Comment(
Long id,
String name,
String content,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
11. Implementing basic database operations using jOOQ
Let’s implement methods to create a new user and get user by email using jOOQ as follows:
package example.micronaut.domain;
import static example.micronaut.jooq.tables.Users.USERS;
import static org.jooq.Records.mapping;
import java.time.LocalDateTime;
import java.util.Optional;
import jakarta.inject.Singleton;
import org.jooq.DSLContext;
@Singleton (1)
class UserRepository {
private final DSLContext dsl;
UserRepository(DSLContext dsl) { (2)
this.dsl = dsl;
}
public User createUser(User user) {
return this.dsl
.insertInto(USERS)
.set(USERS.NAME, user.name())
.set(USERS.EMAIL, user.email())
.set(USERS.CREATED_AT, LocalDateTime.now())
.returningResult(USERS.ID, USERS.NAME, USERS.EMAIL)
.fetchOne(mapping(User::new));
}
public Optional<User> getUserByEmail(String email) {
return this.dsl
.select(USERS.ID, USERS.NAME, USERS.EMAIL)
.from(USERS)
.where(USERS.EMAIL.equalIgnoreCase(email))
.fetchOptional(mapping(User::new));
}
public void deleteUser(Long id) {
dsl.deleteFrom(USERS).where(USERS.ID.eq(id)).execute();
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
2 | Use constructor injection to inject a bean of type org.jooq.DSLContext . Micronaut SQL jOOQ integration registers DSLContext singletons so that you can inject them into your application. |
You can see jOOQ DSL looks very similar to SQL but written in Java. By using jOOQ generated code, we can keep our code in-sync with the database structure and also benefit from the type safety.
For example, the where condition where(USERS.EMAIL.equalIgnoreCase(email))
expects a String for
the email value. If you try to pass any non-string value like where(USERS.EMAIL.equalIgnoreCase(123))
then it will give you a compiler error preventing you from making mistakes at the compilation time itself rather than at runtime.
12. Tests
13. Testcontainers Dependencies
The generated application contains the following Testcontainers dependencies:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
13.1. Test Seed Data
Before writing the tests, let’s create an SQL script to set up the test data by
creating src/test/resources/db/testmigration/V99__test_data.sql
file as follows:
DELETE FROM comments;
DELETE FROM posts;
DELETE FROM users;
INSERT INTO users(id, name, email) VALUES
(1, 'Siva', 'siva@gmail.com'),
(2, 'Oleg', 'oleg@gmail.com');
INSERT INTO posts(id, title, content, created_by, created_at) VALUES
(1, 'Post 1 Title', 'Post 1 content', 1, CURRENT_TIMESTAMP),
(2, 'Post 2 Title', 'Post 2 content', 2, CURRENT_TIMESTAMP);
INSERT INTO comments(id, name, content, post_id, created_at) VALUES
(1, 'Ron', 'Comment 1', 1, CURRENT_TIMESTAMP),
(2, 'James', 'Comment 2', 1, CURRENT_TIMESTAMP),
(3, 'Robert', 'Comment 3', 2, CURRENT_TIMESTAMP);
Load the test seed data by adding the following configuration in src/test/resources/application-test.properties
.
flyway.datasources.default.locations[0]=classpath:db/migration
flyway.datasources.default.locations[1]=classpath:db/testmigration
13.2. AbstractTest class
Create an abstract class to simplify the dynamic configuration of the database connection
package example.micronaut.domain;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.test.support.TestPropertyProvider;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import java.util.Map;
public abstract class AbstractTest implements TestPropertyProvider { (1)
@Container (2)
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15.3-alpine");
@Override
public @NonNull Map<String, String> getProperties() { (1)
if (!postgres.isRunning()) {
postgres.start();
}
return Map.of("datasources.default.url", postgres.getJdbcUrl(),
"datasources.default.username", postgres.getUsername(),
"datasources.default.password", postgres.getPassword());
}
}
1 | When you need dynamic properties definition, implement the TestPropertyProvider interface. Override the method .getProperties() and return the properties you want to expose to the application. |
2 | Shared containers are defined as static fields in a top level test class and have to be annotated with @Container . |
13.3. Write repository test
In order to test the repository, we need to have a running Postgres database instance.
We will extend from AbstractTest
to easily start a Postgres database and write the tests
as follows:
package example.micronaut.domain;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@MicronautTest(startApplication = false) (1)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) (2)
@Testcontainers(disabledWithoutDocker = true) (3)
class UserRepositoryTest extends AbstractTest {
@Inject
UserRepository repository;
@Test
void shouldCreateUserSuccessfully() {
User user = new User(null, "John", "john@gmail.com");
User savedUser = repository.createUser(user);
assertThat(savedUser.id()).isNotNull();
assertThat(savedUser.name()).isEqualTo("John");
assertThat(savedUser.email()).isEqualTo("john@gmail.com");
repository.deleteUser(savedUser.id());
}
@Test
void shouldGetUserByEmail() {
User user = repository.getUserByEmail("siva@gmail.com").orElseThrow();
assertThat(user.id()).isEqualTo(1L);
assertThat(user.name()).isEqualTo("Siva");
assertThat(user.email()).isEqualTo("siva@gmail.com");
}
}
1 | 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. |
2 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
3 | Disable test if Docker not present. |
The tests invoke the UserRepository methods and verified the expected return values.
14. Fetching complex object trees using jOOQ
So far we have seen using jOOQ to perform very basic database operations. But jOOQ shines when it comes to querying the database with complex queries, stored procedures, etc.
In our database model, we have Many-To-One relationship from Post-to-User, One-To-Many relationship from Post-to-Comment.
Let us see how we can get a Post
for a given postId
along with the created user and its comments
using jOOQ powerful MULTISET feature using a single query.
package example.micronaut.domain;
import static example.micronaut.jooq.Tables.COMMENTS;
import static example.micronaut.jooq.tables.Posts.POSTS;
import static org.jooq.Records.mapping;
import static org.jooq.impl.DSL.multiset;
import static org.jooq.impl.DSL.row;
import static org.jooq.impl.DSL.select;
import java.util.Optional;
import jakarta.inject.Singleton;
import org.jooq.DSLContext;
@Singleton (1)
class PostRepository {
private final DSLContext dsl;
PostRepository(DSLContext dsl) {
this.dsl = dsl;
}
public Optional<Post> getPostById(Long id) {
return this.dsl
.select(
POSTS.ID,
POSTS.TITLE,
POSTS.CONTENT,
row(POSTS.users().ID, POSTS.users().NAME, POSTS.users().EMAIL)
.mapping(User::new)
.as("createdBy"),
multiset(
select(
COMMENTS.ID,
COMMENTS.NAME,
COMMENTS.CONTENT,
COMMENTS.CREATED_AT,
COMMENTS.UPDATED_AT
)
.from(COMMENTS)
.where(POSTS.ID.eq(COMMENTS.POST_ID))
)
.as("comments")
.convertFrom(r -> r.map(mapping(Comment::new))),
POSTS.CREATED_AT,
POSTS.UPDATED_AT)
.from(POSTS)
.where(POSTS.ID.eq(id))
.fetchOptional(mapping(Post::new));
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
We have used jOOQ’s Nested records support for loading the ManyToOne association of Post-to-User and MULTISET feature for loading OneToMany association of Post-to-Comments.
To learn more about jOOQ’s MULTISET feature.
From jOOQ 3.19, fetching the associations using MULTISET is further simplified using implicit path correlations feature. |
We can write integration test for PostRepository
as follows:
package example.micronaut.domain;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
@MicronautTest(startApplication = false) (1)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) (2)
@Testcontainers(disabledWithoutDocker = true) (3)
class PostRepositoryTest extends AbstractTest {
@Test
void shouldGetPostById(PostRepository repository) {
Optional<Post> postOptional = repository.getPostById(1L);
assertTrue(postOptional.isPresent());
Post post = postOptional.get();
assertThat(post.id()).isEqualTo(1L);
assertThat(post.title()).isEqualTo("Post 1 Title");
assertThat(post.content()).isEqualTo("Post 1 content");
assertThat(post.createdBy().id()).isEqualTo(1L);
assertThat(post.createdBy().name()).isEqualTo("Siva");
assertThat(post.createdBy().email()).isEqualTo("siva@gmail.com");
assertThat(post.comments()).hasSize(2);
}
}
1 | 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. |
2 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
3 | Disable test if Docker not present. |
15. Testing the Application
To run the tests:
./mvnw test
You should see all tests PASS. You can also notice that after tests are executed, the containers are stopped and removed automatically.
16. Testing with Test Resources
We are going to simplify testing with Micronaut Test Resources:
Micronaut Test Resources adds support for managing external resources which are required during development or testing.
16.1. Removing Testcontainers Dependencies
Remove the Testcontainers dependencies from your build files.
16.2. Configure Test Resources
You can enable test resources support simply by setting the property micronaut.test.resources.enabled (either in your POM or via the command-line).
16.3. Test Resources PostgreSQL
When the application is started locally — either under test or by running the application — the resolution of the
properties datasources.default.url
, datasources.default.username
, and datasources.default.password
is detected. The Test Resources service starts a local PostgreSQL docker container and injects the required properties.
16.4. Simpler Test with Test Resources
Thanks to Test Resources, we can simplify the test, no need for an abstract class, as follows:
package example.micronaut.domain;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@MicronautTest(startApplication = false) (1)
class UserRepositoryTest {
@Inject
UserRepository repository;
@Test
void shouldCreateUserSuccessfully() {
User user = new User(null, "John", "john@gmail.com");
User savedUser = repository.createUser(user);
assertThat(savedUser.id()).isNotNull();
assertThat(savedUser.name()).isEqualTo("John");
assertThat(savedUser.email()).isEqualTo("john@gmail.com");
repository.deleteUser(savedUser.id());
}
@Test
void shouldGetUserByEmail() {
User user = repository.getUserByEmail("siva@gmail.com").orElseThrow();
assertThat(user.id()).isEqualTo(1L);
assertThat(user.name()).isEqualTo("Siva");
assertThat(user.email()).isEqualTo("siva@gmail.com");
}
}
1 | 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 . |
If you run the test, you will see a PostgreSQL container being started by Test Resources through integration with Testcontainers to provide throwaway containers for testing.
16.5. Micronaut Test Resources Goals
zero-configuration: without adding any configuration, test resources should be spawned and the application configured to use them. Configuration is only required for advanced use cases.
classpath isolation: use of test resources shouldn’t leak into your application classpath, nor your test classpath
compatible with GraalVM native: if you build a native binary, or run tests in native mode, test resources should be available
easy to use: the Micronaut build plugins for Gradle and Maven should handle the complexity of figuring out the dependencies for you
extensible: you can implement your own test resources, in case the built-in ones do not cover your use case
technology agnostic: while lots of test resources use Testcontainers under the hood, you can use any other technology to create resources
17. Summary
The Testcontainers library helped us to generate java code from the database using jOOQ code generator tool, and we are able to write tests by using the same type of database, PostgreSQL, that we use in production as opposed to using mocks or in-memory databases.
As we are always generating the code from the current state of the database, we can ensure that our code is in sync with the database changes, and we are free to do any code refactoring and still ensure that the application is working as expected.
18. Next Steps
Learn more about:
19. Help with the Micronaut Framework
The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.
20. 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…). |