As a Java backend developer, you are likely familiar with the Repository
pattern and the related facilities provided by popular frameworks such as Spring Data, Quarkus ORM Panache, and Micronaut Data. Each framework has its own advantages and limitations.
Jakarta Data is a new Jakarta EE specification that aims to create universal interfaces for accessing both relational and non-relational databases.
Note
Jakarta Data 1.0 is planned to be included in the upcoming Jakarta EE 11.
Currently, popular Jakarta Persistence providers, including Hibernate and Eclipse Link, have implemented this specification in its early stages (since Jakarta Data 1.0 has not been released yet).
In this post, we will use the latest Hibernate version to integrate Jakarta Data into a Spring application.
Generate a simple Spring web project via Spring Initializr.
- Language: Java 21
- Dependencies: Web, ORM, Lombok, Postgres
- Build: Maven
Or just create a simple Maven Java project.
Note
Check out the sample code used to demonstrate Jakarta Data in this post.
Add the following dependencies to your project.
// ...
<hibernate.version>6.6.0.Alpha1</hibernate.version>
<dependencies>
// ...
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<version>${hibernate.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
<version>1.0.0-RC1</version>
</dependency>
</dependencies>
Declare a Hibernate StatelessSession
as a bean.
@Configuration
public class DataConfig {
@Bean
public StatelessSession statelessSession(LocalContainerEntityManagerFactoryBean entityManagerFactoryBean) {
return entityManagerFactoryBean.getObject().unwrap(SessionFactory.class).openStatelessSession();
}
}
Unlike Spring Data JPA, which depends on general JPA stateful persistence, Hibernate implements Jakarta Data using StatelessSession
, meaning there is no first-level cache. Every change applied to the database will be flushed immediately.
Note
For more information about Jakarta Data support in Hibernate, read the new Hibernate Data Repositories.
Create a simple @Entity
class for testing purposes.
@Entity
@Table(name = "posts")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Post implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
UUID id;
@Column(name = "title")
private String title;
@Column(name = "content")
private String content;
@Enumerated(EnumType.STRING)
@Builder.Default
private Status status = Status.DRAFT;
@Column(name = "created_at")
@CreationTimestamp
private LocalDateTime createdAt;
}
In the above code, the @Data
, @Builder
, @NoArgsConstructor
, and @AllArgsConstructor
annotations are from the Lombok project, which modifies the compiled Post.class
to generate getters/setters, equals
/hashCode
, an inner builder class, a static build method, and two constructors at compile time.
The @CreationTimestamp
annotation is from Hibernate and sets the current timestamp when inserting an entity instance. Other annotations are from Jakarta Persistence and are simple and easy to understand.
Jakarta Data also provides a series of Repository
interfaces: DataRepository
, BasicRepository
, CrudRepository
, PageableRepository
, etc. If you have experience with Spring Data JPA, you should be familiar with the Repository
interface inheritance in Spring Data projects.
Create a Repository
interface for the Post
entity class we just created. Extend it from CrudRepository
, which has a collection of built-in methods similar to the popular Spring Data CrudRepository
.
@jakarta.data.repository.Repository
public interface PostRepository extends CrudRepository<Post, UUID> {
}
Annotate PostRepository
with @Repository
to indicate it is a Jakarta Data Repository interface.
Note
The @Repository
annotation here is from the package jakarta.data.repository
. Do not use the one provided by Spring.
The Jakarta Data specification requires implementors to process the Repository
at compile time. The Hibernate annotation processor (from the hibernate-jpamodelgen
Maven module) will scan the @Repository
interface and generate the implementation class for the interface.
To make Lombok and other compiler annotation processors work seamlessly, configure them in order under the configuration/annotationProcessorPaths
node of the Maven compiler plugin.
<build>
<finalName>demo</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</annotationProcessorPath>
<annotationProcessorPath>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<version>${hibernate.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</plugin>
// ...
Next, open a terminal window, switch to the project root, and run the following command to compile the entire project.
mvn clean compile
After the compilation is complete, explore the generated code in the target/generated-sources/annotations
folder under the project root.
Note
If this folder is not recognized by your IDE, add it manually as a Source Set.
Besides the generated meta models for the JPA entity classes, there is a new PostRepository_.java
in the com.example.demo.repository
package.
Open it in your editor. It should look like this:
@Generated("org.hibernate.processor.HibernateProcessor")
public class PostRepository_ implements PostRepository {
/**
* Find {@link Post} by {@link Post#id id}.
*
* @see com.example.demo.repository.PostRepository#deleteById(UUID)
**/
@Override
public void deleteById(@Nonnull UUID id) {
if (id == null) throw new IllegalArgumentException("Null id");
var _builder = session.getFactory().getCriteriaBuilder();
var _query = _builder.createCriteriaDelete(Post.class);
var _entity = _query.from(Post.class);
_query.where(
_builder.equal(_entity.get(Post_.id), id)
);
try {
session.createMutationQuery(_query)
.executeUpdate();
}
catch (NoResultException exception) {
throw new EmptyResultException(exception.getMessage(), exception);
}
catch (NonUniqueResultException exception) {
throw new jakarta.data.exceptions.NonUniqueResultException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
protected @Nonnull StatelessSession session;
@Inject
public PostRepository_(@Nonnull StatelessSession session) {
this.session = session;
}
public @Nonnull StatelessSession session() {
return session;
}
@Override
public void delete(@Nonnull Post entity) {
if (entity == null) throw new IllegalArgumentException("Null entity");
try {
session.delete(entity);
}
catch (StaleStateException exception) {
throw new OptimisticLockingFailureException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
@Override
public void deleteAll(@Nonnull List<? extends Post> entities) {
if (entities == null) throw new IllegalArgumentException("Null entities");
try {
for (var _entity : entities) {
session.delete(_entity);
}
}
catch (StaleStateException exception) {
throw new OptimisticLockingFailureException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
/**
* Find {@link Post}.
*
* @see com.example.demo.repository.PostRepository#findAll(PageRequest,Order)
**/
@Override
public Page<Post> findAll(PageRequest pageRequest, Order<Post> sortBy) {
var _builder = session.getFactory().getCriteriaBuilder();
var _query = _builder.createQuery(Post.class);
var _entity = _query.from(Post.class);
_query.where(
);
var _orders = new ArrayList<org.hibernate.query.Order<? super Post>>();
for (var _sort : sortBy.sorts()) {
_orders.add(by(Post.class, _sort.property(),
_sort.isAscending() ? ASCENDING : DESCENDING,
_sort.ignoreCase()));
}
try {
long _totalResults =
pageRequest.requestTotal()
? session.createSelectionQuery(_query)
.getResultCount()
: -1;
var _results = session.createSelectionQuery(_query)
.setFirstResult((int) (pageRequest.page()-1) * pageRequest.size())
.setMaxResults(pageRequest.size())
.setOrder(_orders)
.getResultList();
return new PageRecord(pageRequest, _results, _totalResults);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
@Override
public Post update(@Nonnull Post entity) {
if (entity == null) throw new IllegalArgumentException("Null entity");
try {
session.update(entity);
return entity;
}
catch (StaleStateException exception) {
throw new OptimisticLockingFailureException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
@Override
public Post save(@Nonnull Post entity) {
if (entity == null) throw new IllegalArgumentException("Null entity");
try {
session.upsert(entity);
return entity;
}
catch (StaleStateException exception) {
throw new OptimisticLockingFailureException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
/**
* Find {@link Post}.
*
* @see com.example.demo.repository.PostRepository#findAll()
**/
@Override
public Stream<Post> findAll() {
var _builder = session.getFactory().getCriteriaBuilder();
var _query = _builder.createQuery(Post.class);
var _entity = _query.from(Post.class);
_query.where(
);
try {
return session.createSelectionQuery(_query)
.getResultStream();
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
@Override
public List updateAll(@Nonnull List entities) {
if (entities == null) throw new IllegalArgumentException("Null entities");
try {
for (var _entity : entities) {
session.update(_entity);
}
return entities;
}
catch (StaleStateException exception) {
throw new OptimisticLockingFailureException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
@Override
public List saveAll(@Nonnull List entities) {
if (entities == null) throw new IllegalArgumentException("Null entities");
try {
for (var _entity : entities) {
session.upsert(_entity);
}
return entities;
}
catch (StaleStateException exception) {
throw new OptimisticLockingFailureException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
@Override
public List insertAll(@Nonnull List entities) {
if (entities == null) throw new IllegalArgumentException("Null entities");
try {
for (var _entity : entities) {
session.insert(_entity);
}
return entities;
}
catch (ConstraintViolationException exception) {
throw new EntityExistsException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
/**
* Find {@link Post} by {@link Post#id id}.
*
* @see com.example.demo.repository.PostRepository#findById(UUID)
**/
@Override
public Optional<Post> findById(@Nonnull UUID id) {
if (id == null) throw new IllegalArgumentException("Null id");
try {
return ofNullable(session.get(Post.class, id));
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
@Override
public Post insert(@Nonnull Post entity) {
if (entity == null) throw new IllegalArgumentException("Null entity");
try {
session.insert(entity);
return entity;
}
catch (ConstraintViolationException exception) {
throw new EntityExistsException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
}
As you can see, there is a constructor injection that depends on a Hibernate StatelessSession
bean.
public class PostRepository_ implements PostRepository {
// ...
@Inject
public PostRepository_(@Nonnull StatelessSession session) {
this.session = session;
}
In the Jakarta EE/CDI environment, the Jakarta Data @Repository
can be recognized as CDI beans directly. In a Spring application, we have to declare it as a Spring @Bean
in the configuration.
@Configuration
public class DataConfig {
// ...
@Bean
public PostRepository postRepository(StatelessSession statelessSession) {
return new PostRepository_(statelessSession);
}
// ...
}
Now you can inject a PostRepository
into other beans freely.
@Autowired
PostRepository posts;
var data = List.of(
Post.builder().title("test").content("content").status(Status.PENDING_MODERATION).build(),
Post.builder().title("test1").content("content1").build()
);
data.forEach(this.posts::insert);
var results = posts.findAll();
assertThat(results.toList().size()).isEqualTo(2);
Spring Data JPA allows you to create custom derived queries through a method naming convention. For example, to query all posts into a List
by a provided status parameter, you can simply add a method like the following to the Repository interface.
public interface PostRepository<Post, UUID> extends JpaRepository {
List<Post> findByStatus(Status status);
}
In the Jakarta Data world, it provides a collection of annotations (@Query
, @Save
, @Insert
, @Update
, @Delete
, @Find
, @GroupBy
, @OrderBy
, etc.) to achieve this customization.
@jakarta.data.repository.Repository
public interface PostRepository extends CrudRepository<Post, UUID> {
@Find
@OrderBy("createdAt")
List<Post> byStatus(Status status);
}
After the project is compiled, it will generate the implementation like this:
/**
* Find {@link Post} by {@link Post#status status}.
*
* @see com.example.demo.repository.PostRepository#byStatus(Status)
**/
@Override
public List<Post> byStatus(Status status) {
var _builder = session.getFactory().getCriteriaBuilder();
var _query = _builder.createQuery(Post.class);
var _entity = _query.from(Post.class);
_query.where(
status==null
? _entity.get(Post_.status).isNull()
: _builder.equal(_entity.get(Post_.status), status)
);
var _orders = new ArrayList<org.hibernate.query.Order<? super Post>>();
_orders.add(by(Post.class, "createdAt", ASCENDING, false));
try {
return session.createSelectionQuery(_query)
.setOrder(_orders)
.getResultList();
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
The following is an example of using it:
var resultsByKeyword = posts.byStatus(Status.PENDING_MODERATION);
assertThat(resultsByKeyword.size()).isEqualTo(1);
More freely, Jakarta Data allows you to use these annotations to build your repository without extending Repository interfaces.
Create a simple interface Blogger
and annotate it with @Repository
.
@Repository
public interface Blogger {
@Query("""
SELECT p.id, p.title FROM Post p
WHERE p.status = 'PUBLISHED'
ORDER BY p.createdAt DESC
""")
List<PostSummary> allPublishedPosts();
@Insert
Post newPost(Post post);
}
Compile the project again, and it will generate a Blogger_
implementation class.
@Generated("org.hibernate.processor.HibernateProcessor")
public class Blogger_ implements Blogger {
static final String ALL_PUBLISHED_POSTS = "SELECT p.id, p.title FROM Post p\nWHERE p.status = 'PUBLISHED'\nORDER BY p.createdAt DESC\n";
/**
* Execute the query {@value #ALL_PUBLISHED_POSTS}.
*
* @see com.example.demo.Blogger#allPublishedPosts()
**/
@Override
public List<PostSummary> allPublishedPosts() {
try {
return session.createSelectionQuery(ALL_PUBLISHED_POSTS, PostSummary.class)
.getResultList();
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
protected @Nonnull StatelessSession session;
@Inject
public Blogger_(@Nonnull StatelessSession session) {
this.session = session;
}
public @Nonnull StatelessSession session() {
return session;
}
@Override
public Post newPost(@Nonnull Post post) {
if (post == null) throw new IllegalArgumentException("Null post");
try {
session.insert(post);
return post;
}
catch (ConstraintViolationException exception) {
throw new EntityExistsException(exception.getMessage(), exception);
}
catch (PersistenceException exception) {
throw new DataException(exception.getMessage(), exception);
}
}
}
Declare it as a Spring @Bean
in the configuration as well.
// in DataConfig.java
@Bean
public Blogger blogger(StatelessSession statelessSession) {
return new Blogger_(statelessSession);
}
The following insert and query example uses this Blogger
instead.
var data = Post.builder().title("test").content("test content").status(Status.DRAFT).build();
var saved = blogger.newPost(data);
assertThat(this.posts.findById(saved.getId()).isPresent()).isTrue();
saved.setStatus(Status.PUBLISHED);
posts.update(saved);
List<PostSummary> allPublished = blogger.allPublishedPosts();
assertThat(allPublished.size()).isEqualTo(1);
To experience more Jakarta Data features yourself, please check the complete PostRepositoryTest
.
When I prepared the sample code, I tried to add a @BeforeEach
hook method as follows to clean up the sample data for every test.
@SneakyThrows
@BeforeEach
public void setup() {
var deleted = posts.deleteAll();
log.debug("deleted posts: {}", deleted);
}
And add a deleteAll
method as below into the PostRepository
interface.
@Delete
@Transactional
long deleteAll();
When compiling the project and running the tests, it will throw a Jakarta Data TransactionException
.
I have tried to configure HibernateTransactionManager
and JpaTransactionManager
respectively, but neither resolves the issue. Spring still does not provide transaction support for Hibernate StatelessSession
, see issue spring-framework#7184. A possible solution is adding Spring Transaction support for Hibernate StatelessSession
from scratch, see the useful example code from this gist.
I consulted this question in the Zulip Hibernate users channel, and a Hibernate expert provided an extremely simple solution to overcome this barrier temporarily: just add the property hibernate.allow_update_outside_transaction=true
to the Hibernate configuration.
Check out the complete sample project on my GitHub and explore the new Jakarta Data specification yourself.