How to define a repository with Jakarta Data and Hibernate


Take your skills to the next level!

The Persistence Hub is the place to be for every Java developer. It gives you access to all my premium video courses, monthly Java Persistence News, monthly coding problems, and regular expert sessions.


Repositories are a commonly used pattern for persistence layers. They abstract from the underlying data store or persistence framework and hide the technical details from your business code. Using Jakarta Data, you can easily define such a repository as an interface, and you’ll get an implementation of it based on Jakarta Persistence or Jakarta NoSQL.

As you will see in this article, Jakarta Data imposes only a few constraints when defining a repository. You are not obligated to use standard methods or extend any interfaces, nor do you need to create the repository for a specific aggregate or entity class. This gives you all the flexibility you need to create repositories that fit your application’s domain and data store.

When it comes to designing your repository, there are only two fundamental principles to keep in mind:

  1. The repository and the entities it fetches are stateless. There are no caches, and if you change an entity, you have to call a repository method to persist in the modification.
  2. Fetching the same entity object multiple times will retrieve different objects representing the same data in your data store.

If you want to use Jakarta Data repositories with a Jakarta NoSQL implementation, all this might sound totally normal to you. But that’s not the case if you are used to Jakarta’s Persistence stateful model. So, please keep these principles in mind when using Jakarta Data’s repositories.

Jakarta Data repositories with Hibernate

For this article, I will use Hibernate ORM as my Jakarta Data implementation. As mentioned in my getting started guide, you define a Jakarta Data repository as an interface. Starting with version 6.6, Hibernate’s metamodel generator creates the required repository implementations at compile time.

The generated code uses Hibernate’s proprietary StatelessSession. In contrast to a normal Session and JPA’s EntityManager, the StatelessSession doesn’t manage the lifecycle of an entity object, provides no support for implicit lazy fetching, and doesn’t use any caches. That makes it a perfect fit to implement a Jakarta Data repository.

All the generated repository implementations can be easily found in your project’s target/generated-sources folder. Hibernate’s metamodel generator stores them in the same package as the repository interface and generates their name by appending an ‘_’ to the name of the repository interface.

So, for my repository interface com.thorben.janssen.repository.ChessPlayerRepository, Hibernate generates the class com.thorben.janssen.repository.ChessPlayerRepository_ as its implementation.

Minimal repository

As mentioned earlier, there are almost no requirements for defining a repository. The simplest one is an interface with a @Repository annotation.

@Repository
public interface ChessPlayerRepository { }

Even though this is a Jakarta Data repository, it doesn’t provide any functionality. You can change that by extending one of Jakarta Data’s standard repositories or adding your own repository methods.

Extend a standard repository

Jakarta Data defines BasicRepository<Entity, Id> and CrudRepository<Entity, Id> interfaces as standard repositories. Both interfaces define a set of methods to read and write entities to your data store. In addition to these methods, you can define finder and query methods to retrieve the data in the way and form your business code needs.

@Repository
public interface ChessPlayerRepository extends CrudRepository<ChessPlayer, Long> { }

The BasicRepository interface defines the methods:

  • <S extends T> S save(S entity)
  • <S extends T>List<S> saveAll(List<S> entities);
  • Optional<T> findById(@By(ID) K id)
  • Stream<T> findAll()
  • Page<T> findAll(PageRequest pageRequest, Order<T> sortBy)
  • void deleteById(@By(ID) K id)
  • void delete(T entity)
  • void deleteAll(List<? extends T> entities)

The CrudRepository extends the BasicRepository and adds the methods:

  • <S extends T> S insert(S entity)
  • <S extends T> List<S> insertAll(List<S> entities)
  • <S extends T> S update(S entity)
  • <S extends T> List<S> updateAll(List<S> entities)

As you can see, both interfaces define a set of methods that can be useful to implement your business code. However, most applications will only use some of them. You need to decide if you want to extend the BasicRepository or CrudRepository and inherit all of them or if you prefer to define a custom repository that better fits your needs.

Custom lifecycle methods

By defining your own methods instead of extending one of Jakarta Data’s standard repository interfaces, you can design your repository to suit your specific needs. This flexibility allows you to define only the methods you want to use, keeping your repositories lean and clean.

And don’t worry. Defining these methods on your repository interface doesn’t mean you have to implement them. Jakarta Data provides you the implementation if you annotate your methods with one of the following annotations:

  • @Insert – Persist a new entity. When using Hibernate ORM, this calls StatelessSession.insert().
  • @Update – Update an existing entity. When using Hibernate ORM, this calls StatelessSession.update().
  • @Delete – Delete an existing entity. When using Hibernate ORM, this calls StatelessSession.delete().
  • @Save – Persist a new or update an existing entity. When using Hibernate ORM, this calls StatelessSession.insert() or StatelessSession.upsert().

Here, you can see an example of my ChessTournamentRepository definition using some of these annotations to define my own repository.

@Repository
public interface ChessTournamentRepository {
    
    @Insert
    void insert(ChessTournament tournament);

    @Insert
    void insertAll(List<ChessTournament> tournaments);

    @Update
    void update(ChessTournament tournament);

    @Delete
    void delete(ChessTournament tournament);

}

Queries

Jakarta Data defines 2 ways to specify queries. You can either implement a simple find method or define a custom query.

Let’s take a closer look at the find methods, and I’ll keep the section about custom queries short because the Jakarta Data Query Language is worth an entire article on its own, which I will publish next week.

Find methods

Find methods are your best bet if you’re looking for a simple query. They’re designed for simple queries that return one or more entity objects matching a set of input parameters. To define them, all you need to do is annotate a repository method with Jakarta Data’s @Find annotation.

Your Jakarta Data implementation then uses the method parameters and the method’s return type to generate the query statement. It expects that every method parameter has a matching entity attribute and generates an equal predicate for each of them. These attributes have to match by name and type.

The method name doesn’t have to follow any pattern. In contrast to other persistence frameworks, like Spring Data JPA, it also doesn’t define your query statement. It’s just a method name and has to follow standard Java conventions.

Here, you can see an example that defines the findTournament method. This method returns the ChessTournament entity objects with a matching name and edition number.

@Repository
public interface ChessTournamentRepository {

    @Find
    ChessTournament findTournament(String name, Integer edition);
	
}

When executing this method, Hibernate executes the following SQL statement. As you can see, it generated the expected equal predicate for both method parameters and combined them using an and predicate.

13:55:11,347 DEBUG [org.hibernate.SQL] - 
    select
        ct1_0.id,
        ct1_0.edition,
        ct1_0.name,
        ct1_0.version 
    from
        ChessTournament ct1_0 
    where
        ct1_0.name=? 
        and ct1_0.edition=?

OK, I think I already know your next question. What if my method parameters don’t match the entity attributes?

That depends on how they differ.

Your method parameters and entity attributes always have to have the same type. If only their name differs, you can add a @Param annotation to your method parameter and specify the entity attribute’s name.

I use that in the following example to map the method parameter year to the entity attribute edition.

@Repository
public interface ChessTournamentRepository {

    @Find
    Optional<ChessTournament> findByNameAndYear(String name, @Param("edition") Integer year);

}

And if you call this repository method, you can see in the log output that Hibernate executed a query with an equal predicate for the name and edition column.

13:59:10,171 DEBUG [org.hibernate.SQL] - 
    select
        ct1_0.id,
        ct1_0.edition,
        ct1_0.name,
        ct1_0.version 
    from
        ChessTournament ct1_0 
    where
        ct1_0.name=? 
        and ct1_0.edition=?

Ok, these finder methods are easy to define, but you often need more than a few equal predicates. In that case, you need to define a custom query.

Custom queries

Jakarta Data’s @Query annotation lets you provide the JDQL statement you want your repository method to execute. JDQL stands for Jakarta Data Query Language. It’s a subset of the well-known Jakarta Persistence Query Language (JPQL), and we’ll discuss it in more detail in next week’s article.

JDQL is based on your domain model. This means you reference your entity and attribute names in your query statement instead of table and column names.

A standard JDQL statement is independent of the underlying persistence framework. So, you can execute the same query using a Jakarta Persistence implementation, like Hibernate, or a Jakarta NoSQL implementation.

When you use bind parameters in your query, I recommend using named bind parameters. They are identified by a ‘:‘, followed by the parameter name. Your Jakarta Data implementation expects a method parameter with the same name and a compatible type for every named bind parameter. When executing the query, it then automatically sets the provided method parameter values as bind parameter values.

In the following code snippet, you can see an example of such a bind parameter. When executing the query, Hibernate will set the value of the method parameter String name as the value of the bind parameter :name.

@Repository
public interface ChessTournamentRepository {

    @Query("WHERE name like :name")
    List<ChessTournament> findTournaments(String name);

}

This example also shows another JDQL feature. I provided an incomplete query statement without a SELECT and FROM clause. When you don’t specify it, Hibernate expects that the repository method returns an entity and uses it to generate the SELECT and FROM clauses.

Let’s run this query and check which statement Hibernate executes.

15:39:05,152 DEBUG [org.hibernate.SQL] - 
    select
        ct1_0.id,
        ct1_0.edition,
        ct1_0.name,
        ct1_0.version 
    from
        ChessTournament ct1_0 
    where
        ct1_0.name like ? escape ''

As you can see, Hibernate generated an SQL statement that selects all columns mapped by the ChessTournament entity class and limits the query result to all records with a name similar to the provided bind parameter.

Conclusion

Jakarta Data standardizes repositories for Jakarta Persistence and Jakarta NoSQL. You can define them as an interface that extends one of the standard interfaces or define the repository completely yourself.

As you saw in this article, both options are easy to implement and require additional query methods that fetch the information your business logic needs. The only difference is that the standard BasicRepository and CrudRepository define standard methods to persist new entities, update and delete existing ones, and fetch them by their identifier. You have to decide for your application if you want to use all of these methods or if you want to define your own.

In both cases, your Jakarta Data implementation will generate the required implementations. Due to that, it almost takes no effort to define a custom repository that perfectly fits your needs.