Skip to content

Data Access with Micronaut Jakarta Data

Micronaut is a modern, JVM-based framework designed for building cloud-native microservices and serverless applications. Previously, we explored how to create RESTful backend applications with various Micronaut Data modules, including Data JPA, Data JDBC, Data R2dbc, and Data MongoDB.

With the release of Micronaut 4.9, support for the Jakarta Data specification (introduced in Jakarta EE 11) is now available, providing a standardized alternative to traditional data persistence approaches. In previous articles, we explored how to integrate Jakarta Data with Spring and Quarkus. In this guide, we'll focus on using Jakarta Data within a Micronaut application to handle data access.

Generating the Project Skeleton

Navigate to the Micronaut Launch page and select the following options to generate your project skeleton:

  • Micronaut Version: 4.9.1 (latest stable version at the time of writing)
  • Java Version: 21
  • Language: Java
  • Build Tools: Gradle Kotlin
  • Test Framework: JUnit

Keep the other options at their defaults.

Next, click the FEATURES button and add these essential dependencies: Jakarta Data, Lombok, Reactor, Data JPA, HttpClient, Postgres, and TestContainers in the dialog.

Then click the GENERATE button to download the generated archive, extract the files to your local system, and import the project into your favorite IDE, such as IntelliJ IDEA.

[!NOTE] There is a typo in the generated build.gradle.kts in the current version. For more details, see: https://github.com/micronaut-projects/micronaut-starter/issues/2827. Simply change implementation("jakarta.data:jakarta-data-api") to implementation("jakarta.data:jakarta.data-api:1.0.1") to resolve this issue temporarily.

Additionally, add Lombok to the testCompileOnly and testAnnotationProcessor scopes, and organize the dependencies for clarity. You can check the final modified build script here.

Integrating Jakarta Data

The Jakarta Data specification does not prescribe how entities should be defined. In Micronaut, the entity definitions still rely on the existing conventions and approaches provided by Micronaut Data.

Let's start by creating a simple Jakarta Persistence @Entity class and an @Embeddable class:

// Customer.java
@Introspected
@Entity
@Table(name = "CUSTOMERS")
@Serdeable
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Customer {
    @Id
    @GeneratedValue(generator = "uuid")
    @GenericGenerator(name = "uuid", strategy = "uuid2")
    UUID id;
    String name;
    Integer age;
    @Embedded Address address;
    @Version Long version;

    public static Customer of(String name, Integer age, Address address) {
        return new Customer(null, name, age, address, null);
    }
}

// Address.java
@Introspected
@Embeddable
@Serdeable
public record Address(
        String street,
        String city,
        String zip) {

    public static Address of(String street, String city, String zip) {
        return new Address(street, city, zip);
    }
}

In the above code fragments:

  • Lombok annotations automatically generate setters, getters, equals, hashCode, and a no-argument constructor, all of which are required for Jakarta Persistence Entity classes.
  • @Introspected is necessary for Ahead-of-Time (AOT) compilation, especially if you plan to build a native image of your application.
  • @Serdeable (from the Micronaut Serde module) provides portable serialization and deserialization support compatible with formats like Jackson and BSON.
  • The @Entity and @Table annotations on the Customer class designate it as a Jakarta Persistence entity and specify the corresponding database table. The @Embeddable annotation on the Address class marks it as a component that can be embedded within other entities, such as the address field in Customer, which is annotated with @Embedded. The @Id annotation identifies the primary key field, while @GeneratedValue and @GenericGenerator configure the ID generation strategy. The @Version field enables optimistic locking for transactional consistency.

Jakarta Data introduces a completely new Repository abstraction to simplify data access. At its core is the top-level DataRepository interface, which is extended by BasicRepository and CrudRepository for general CRUD operations.

You can define a CustomerRepository interface as shown below:

@Repository
public interface CustomerRepository extends CrudRepository<Customer, UUID> {
}

[!NOTE] Ensure that both @Repository and CrudRepository are imported from the jakarta.data package.

To build the project, run this command in a terminal window:

./gradlew build

Once the build completes, navigate to the build/classes directory. In addition to the CustomerRepository class, you will find a generated CustomerRepository$Intercepted file. You can open this file in your IDE (such as IntelliJ IDEA), where it will be decompiled into readable Java source code.

@Generated
class CustomerRepository$Intercepted implements CustomerRepository, Introduced {
    private final Interceptor[][] $interceptors;
    private final ExecutableMethod[] $proxyMethods;

    public List<Object> updateAll(List<Object> entities) {
        return (List)(new MethodInterceptorChain(this.$interceptors[1], this, this.$proxyMethods[1], new Object[]{entities})).proceed();
    }

    public Object update(Object entity) {
        return (new MethodInterceptorChain(this.$interceptors[2], this, this.$proxyMethods[2], new Object[]{entity})).proceed();
    }

    public List<Object> insertAll(List<Object> entities) {
        return (List)(new MethodInterceptorChain(this.$interceptors[3], this, this.$proxyMethods[3], new Object[]{entities})).proceed();
    }
    // ...
}

As you can see, all methods defined in BasicRepository and CrudRepository are translated into concrete implementations within the generated class.

Additionally, two helper classes - $CustomerRepository$Intercepted$Definition and $CustomerRepository$Intercepted$Definition$Exec are generated to facilitate the registration of CustomerRepository into the Micronaut Bean context.

The Jakarta Data Repository abstraction also supports derived queries by method names, pagination, and custom queries using the @Query annotation. For example:

@Repository
public interface CustomerRepository extends CrudRepository<Customer, UUID> {
    Optional<Customer> findByName(String name);

    Page<Customer> findByAddressCityLike(String cityLike, PageRequest pageRequest);

    List<Customer> findByAddressZip(String zip, Order<Customer> order);

    @Query("where name like :name")
    @OrderBy("name")
    Customer[] byNameLike(@Param("name") String customerName);
}

These methods mirror the familiar patterns from Micronaut Data and Spring Data Repository abstractions, so you can use them naturally and intuitively in your code.

For example, invoking a repository method looks just like a regular Java call:

customerRepository.findByAddressCityLike("New%", PageRequest.of(1, 10, true));

[!WARNING] Jakarta Data uses 1-based pagination, so page numbers start at 1 instead of 0. As a developer, I had been familiar with 0-based pagination in projects for several years. It is better to let developers make the decision. For details, check the related discussion: jakarta/data#941.

Another compelling aspect of Jakarta Data is its support for lifecycle-based methods that automatically infer the entity type from method parameters or return types. This allows you to define flexible, free-form interfaces for performing simple CRUD operations on your entities, without being tied to a specific repository abstraction.

@Repository
public interface CustomerDao {
    @Find
    @OrderBy("name")
    List<Customer> findAll();

    @Find
    Optional<Customer> findById(@By(ID) UUID id);

    // @Find
    // List<Customer> findByCity(@By("address.city") String city, Limit limit, Sort<?>... sort);

    @Insert
    Customer save(Customer data);

    @Update
    void update(Customer data);

    @Delete
    void delete(Customer data);
}

Please note that some methods that worked well in the Hibernate Jakarta Data implementation are currently problematic in the Micronaut Jakarta Data implementation. For more details, refer to micronaut-data#3487. Additionally, invoking the underlying data store handler within custom default methods is not yet possible, as discussed in micronaut-data#3490.

You can explore the complete example project on GitHub, which also demonstrates testing against a real database using Testcontainers.

JDBC Support

Unlike the Jakarta Data implementation Hiberante, which is heavily dependent on Hibernate's StatelessSession. Micronaut Data extends Jakarta Data support to all its data modules, including JDBC and MongoDB.

To use Jakarta Data with JDBC, simply select Data JDBC instead of Data JPA when generating your project skeleton.

In your CustomerRepository interface, annotate it with both @Repository from Jakarta Data and @JdbcRepository from Micronaut Data:

@Repository
@JdbcRepository
public interface CustomerRepository extends CrudRepository<Customer, UUID> {
    // ...
}

You can define the Customer entity using the standard Micronaut Data annotations:

// Customer.java
@Introspected
@MappedEntity(value = "customers")
@Serdeable
public record Customer(
        @Id @AutoPopulated UUID id,
        String name,
        Integer age,
        @Relation(EMBEDDED) Address address,
        @Version Long version
) {
    public static Customer of(String name, Integer age, Address address) {
        return new Customer(null, name, age, address, null);
    }
}

// Address.java
@Introspected
@Embeddable
@Serdeable
public record Address(
        @MappedProperty("street") String street,
        @MappedProperty("city") String city,
        @MappedProperty("zip") String zip
) {
    public static Address of(String street, String city, String zip) {
        return new Address(street, city, zip);
    }
}

Micronaut Data Jdbc allows you to define entities with the Jakarta Persistence API. If you prefer to use Jakarta Persistence annotations, add the jakarta.persistence-api dependency to your project.

You can find the complete example project updated for JDBC.

MongoDB Support

Similarly, to use Jakarta Data with MongoDB, select Data MongoDB instead of Data JPA when generating your project, and remove Postgres from the feature list.

Micronaut Data MongoDB also reuses the same data annotations to manage entities. However, by default, it does not support UUID as an ID type; use a String or MongoDB-specific ObjectId instead.

// Customer.java
@Introspected
@MappedEntity(value = "customers")
@Serdeable
public record Customer(
        @Id @AutoPopulated String id,
        // ...
) {
    // ...
}

Annotate your CustomerRepository interface with both @Repository and @MongoRepository:

@Repository
@MongoRepository
public interface CustomerRepository extends CrudRepository<Customer, String> {
    // ...
}

You can explore the complete example project updated for MongoDB.

Summary

With Micronaut 4.9, support for the Jakarta Data specification introduces a standardized way to handle data access, providing an alternative approach for working with both relational databases and NoSQL stores.