I've originally written this article in Russian. So, if you're native speaker, you can read it by this link.
In the past year or so, I've come across articles and talks suggesting that JOOQ is a modern and superior alternative to Hibernate. The arguments typically include:
Let me state upfront that I consider JOOQ an excellent library (specifically a library, not a framework like Hibernate). It excels at its task — working with SQL in a statically typed manner to catch most errors at compile time.
However, when I hear the argument that Hibernate's time has passed and we should now write everything using JOOQ, it sounds to me like saying the era of relational databases is over and we should only use NoSQL now. Sounds funny? Yet, not so long ago, such discussions were quite serious.
I believe the issue lies in a misunderstanding of the core problems these two tools address. In this article, I aim to clarify these questions. We will explore:
The simplest and most intuitive way to work with a database is the Transaction Script pattern. In brief, you organize all your business logic as a set of SQL commands combined into a single transaction. Typically, each method in a class represents a business operation and is confined to one transaction.
Suppose we're developing an application that allows speakers to submit their talks to a conference (for simplicity, we'll only record the talk's title). Following the Transaction Script pattern, the method for submitting a talk might look like this (using JDBI for SQL):
@Service @RequiredArgsConstructor public class TalkService { private final Jdbi jdbi; public TalkSubmittedResult submitTalk(Long speakerId, String title) { var talkId = jdbi.inTransaction(handle -> { // Count the number of accepted talks by the speaker var acceptedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // Check if the speaker is experienced var experienced = acceptedTalksCount >= 10; // Determine the maximum allowable number of submitted talks var maxSubmittedTalksCount = experienced ? 5 : 3; var submittedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // If the maximum number of submitted talks is exceeded, throw an exception if (submittedTalksCount >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount); } return handle.createUpdate( "INSERT INTO talk (speaker_id, status, title) " + "VALUES (:id, 'SUBMITTED', :title)" ).bind("id", speakerId) .bind("title", title) .executeAndReturnGeneratedKeys("id") .mapTo(Long.class) .one(); }); return new TalkSubmittedResult(talkId); } }
In this code:
There is a potential race condition here, but for simplicity, we'll not focus on that.
Pros of this approach:
Cons:
This approach is valid and makes sense if your service has very simple logic that isn’t expected to become more complex over time. However, domains are often larger. Therefore, we need an alternative.
The idea of the Domain Model pattern is that we no longer tie our business logic directly to SQL commands. Instead, we create domain objects (in the context of Java, classes) that describe behavior and store data about domain entities.
In this article, we won’t discuss the difference between anemic and rich models. If you're interested, I’ve written a detailed piece on that topic.
Business scenarios (services) should use only these objects and avoid being tied to specific database queries.
Of course, in reality, we may have a mix of interactions with domain objects and direct database queries to meet performance requirements. Here, we’re discussing the classic approach to implementing the Domain Model, where encapsulation and isolation are not violated.
For example, if we’re talking about the entities Speaker and Talk, as mentioned earlier, the domain objects might look like this:
@Service @RequiredArgsConstructor public class TalkService { private final Jdbi jdbi; public TalkSubmittedResult submitTalk(Long speakerId, String title) { var talkId = jdbi.inTransaction(handle -> { // Count the number of accepted talks by the speaker var acceptedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // Check if the speaker is experienced var experienced = acceptedTalksCount >= 10; // Determine the maximum allowable number of submitted talks var maxSubmittedTalksCount = experienced ? 5 : 3; var submittedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // If the maximum number of submitted talks is exceeded, throw an exception if (submittedTalksCount >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount); } return handle.createUpdate( "INSERT INTO talk (speaker_id, status, title) " + "VALUES (:id, 'SUBMITTED', :title)" ).bind("id", speakerId) .bind("title", title) .executeAndReturnGeneratedKeys("id") .mapTo(Long.class) .one(); }); return new TalkSubmittedResult(talkId); } }
Here, the Speaker class contains the business logic for submitting a talk. The database interaction is abstracted away, allowing the domain model to focus on business rules.
Supposing this repository interface:
@AllArgsConstructor public class Speaker { private Long id; private String firstName; private String lastName; private List<Talk> talks; public Talk submitTalk(String title) { boolean experienced = countTalksByStatus(Status.ACCEPTED) >= 10; int maxSubmittedTalksCount = experienced ? 3 : 5; if (countTalksByStatus(Status.SUBMITTED) >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException( "Submitted talks count is maximum: " + maxSubmittedTalksCount); } Talk talk = Talk.newTalk(this, Status.SUBMITTED, title); talks.add(talk); return talk; } private long countTalksByStatus(Talk.Status status) { return talks.stream().filter(t -> t.getStatus().equals(status)).count(); } } @AllArgsConstructor public class Talk { private Long id; private Speaker speaker; private Status status; private String title; private int talkNumber; void setStatus(Function<Status, Status> fnStatus) { this.status = fnStatus.apply(this.status); } public enum Status { SUBMITTED, ACCEPTED, REJECTED } }
Then the SpeakerService can be implemented this way:
public interface SpeakerRepository { Speaker findById(Long id); void save(Speaker speaker); }
Pros of the Domain Model:
In short, there are plenty of advantages. However, there is one important challenge. Interestingly, in books on Domain-Driven Design, which often promote the Domain Model pattern, this problem is either not mentioned at all or only briefly touched upon.
The problem is how do you save domain objects to the database and then read them back? In other words, how do you implement a repository?
Nowadays, the answer is obvious. Just use Hibernate (or even better, Spring Data JPA) and save yourself the trouble. But let’s imagine we’re in a world where ORM frameworks haven’t been invented. How would we solve this problem?
To implement SpeakerRepository I also use JDBI:
@Service @RequiredArgsConstructor public class TalkService { private final Jdbi jdbi; public TalkSubmittedResult submitTalk(Long speakerId, String title) { var talkId = jdbi.inTransaction(handle -> { // Count the number of accepted talks by the speaker var acceptedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // Check if the speaker is experienced var experienced = acceptedTalksCount >= 10; // Determine the maximum allowable number of submitted talks var maxSubmittedTalksCount = experienced ? 5 : 3; var submittedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // If the maximum number of submitted talks is exceeded, throw an exception if (submittedTalksCount >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount); } return handle.createUpdate( "INSERT INTO talk (speaker_id, status, title) " + "VALUES (:id, 'SUBMITTED', :title)" ).bind("id", speakerId) .bind("title", title) .executeAndReturnGeneratedKeys("id") .mapTo(Long.class) .one(); }); return new TalkSubmittedResult(talkId); } }
The approach is simple. For each repository, we write a separate implementation that works with the database using any SQL library (like JOOQ or JDBI).
At first glance (and maybe even the second), this solution might seem quite good. Consider this:
Things get much more interesting in the real world, where you might encounter scenarios like these:
On top of that, you’ll need to maintain the mapping code as your business logic and domain objects evolve.
If you try to handle each of these points on your own, you’ll eventually find yourself (surprise!) writing your Hibernate-like framework — or more likely, a much simpler version of it.
JOOQ addresses the lack of static typing when writing SQL queries. This helps reduce the number of errors at the compilation stage. With code generation directly from the database schema, any updates to the schema will immediately show where the code needs to be fixed (it simply won’t compile).
Hibernate solves the problem of mapping domain objects to a relational database and vice versa (reading data from the database and mapping it to domain objects).
Therefore, it doesn’t make sense to argue that Hibernate is worse or JOOQ is better. These tools are designed for different purposes. If your application is built around the Transaction Script paradigm, JOOQ is undoubtedly the ideal choice. But if you want to use the Domain Model pattern and avoid Hibernate, you’ll have to deal with the joys of manual mapping in custom repository implementations. Of course, if your employer is paying you to build yet another Hibernate killer, no questions there. But most likely, they expect you to focus on business logic, not infrastructure code for object-to-database mapping.
By the way, I believe the combination of Hibernate and JOOQ works well for CQRS. You have an application (or a logical part of it) that executes commands, like CREATE/UPDATE/DELETE operations — this is where Hibernate fits perfectly. On the other hand, you have a query service that reads data. Here, JOOQ is brilliant. It makes building complex queries and optimizing them much easier than with Hibernate.
It’s true. JOOQ allows you to generate DAOs that contain standard queries for fetching entities from the database. You can even extend these DAOs with your methods. Moreover, JOOQ will generate entities that can be populated using setters, similar to Hibernate, and passed to the insert or update methods in the DAO. Isn’t that like Spring Data?
For simple cases, this can indeed work. However, it’s not much different from manually implementing a repository. The problems are similar:
So, if you want to build a complex domain model, you’ll have to do it manually. Without Hibernate, the responsibility for mapping will fall entirely on you. Sure, using JOOQ is more pleasant than JDBI, but the process will still be labor-intensive.
Even Lukas Eder, the creator of JOOQ, mentions in his blog that DAOs were added to the library because it’s a popular pattern, not because he necessarily recommends using them.
Thank you for reading the article. I’m a big fan of Hibernate and consider it an excellent framework. However, I understand that some may find JOOQ more convenient. The main point of my article is that Hibernate and JOOQ are not rivals. These tools can coexist even within the same product if they bring value.
If you have any comments or feedback on the content, I’d be happy to discuss them. Have a productive day!
The above is the detailed content of JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems. For more information, please follow other related articles on the PHP Chinese website!