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:
- JOOQ allows you to verify everything at compile time, whereas Hibernate does not!
- Hibernate generates strange and not always optimal queries, while with JOOQ, everything is transparent!
- Hibernate entities are mutable, which is bad. JOOQ allows all entities to be immutable (hello, functional programming)!
- JOOQ doesn’t involve any “magic” with annotations!
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. Does that sound funny? Yet, not so long ago, such discussions were quite serious.
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:
- What is Transaction Script?
- What is the Domain Model pattern?
- What specific problems do Hibernate and JOOQ solve?
- Why isn’t one a replacement for the other, and how can they coexist?
Transaction Script
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);
}
}
Enter fullscreen mode Exit fullscreen mode
In this code:
- We count how many talks the speaker has already submitted.
- We check if the maximum allowable number of submitted talks is exceeded.
- If everything is okay, we create a new talk with the status
SUBMITTED
.
There is a potential race condition here, but for simplicity, we’ll not focus on that.
Pros of this approach:
- The SQL being executed is straightforward and predictable. It’s easy to tweak it for performance improvements if needed.
- We only fetch the necessary data from the database.
- With JOOQ, this code can be written more simply, concisely, and with static typing!
Cons:
- It’s impossible to test the business logic with unit tests alone. You’ll need integration tests (and quite a few of them).
- If the domain is complex, this approach can quickly lead to spaghetti code.
- There’s a risk of code duplication, which could lead to unexpected bugs as the system evolves.
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.
Domain Model
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:
@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
}
}
Enter fullscreen mode Exit fullscreen mode
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:
public interface SpeakerRepository {
Speaker findById(Long id);
void save(Speaker speaker);
}
Enter fullscreen mode Exit fullscreen mode
Then the SpeakerService
can be implemented this way:
@Service
@RequiredArgsConstructor
public class SpeakerService {
private final SpeakerRepository repo;
public TalkSubmittedResult submitTalk(Long speakerId, String title) {
Speaker speaker = repo.findById(speakerId);
Talk talk = speaker.submitTalk(title);
repo.save(speaker);
return new TalkSubmittedResult(talk.getId());
}
}
Enter fullscreen mode Exit fullscreen mode
Pros of the Domain Model:
- Domain objects are completely decoupled from implementation details (i.e., the database). This makes them easy to test with regular unit tests.
- Business logic is centralized within the domain objects. This greatly reduces the risk of logic spreading across the application, unlike in the Transaction Script approach.
- If desired, domain objects can be made fully immutable, which increases safety when working with them (you can pass them to any method without worrying about accidental modifications).
- Fields in domain objects can be replaced with Value Objects, which not only improves readability but also ensures the validity of fields at the time of assignment (you can’t create a Value Object with invalid content).
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?
Manual mapping
To implement SpeakerRepository
I also use JDBI:
@AllArgsConstructor
@Repository
public class JdbiSpeakerRepository implements SpeakerRepository {
private final Jdbi jdbi;
@Override
public Speaker findById(Long id) {
return jdbi.inTransaction(handle -> {
return handle.select("SELECT * FROM speaker s LEFT JOIN talk t ON t.speaker_id = s.id WHERE id = :id")
.bind("id", speakerId)
.mapTo(Speaker.class) // mapping is out of scope for simplicity
.execute();
});
}
@Override
public void save(Speaker speaker) {
jdbi.inTransaction(handle -> {
// A complex logic checking:
// 1. Whether a speaker is already present
// 2. Generating UPDATE/INSERT/DELETE staments
// 3. Possibly optimistic locking implementation
// 4. etc
});
}
}
Enter fullscreen mode Exit fullscreen mode
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:
- The code remains highly transparent, just like in the Transaction Script approach.
- No more issues with testing business logic only through integration tests. These are needed only for repository implementations (and maybe a few E2E scenarios).
- The mapping code is right in front of us. No Hibernate magic is involved. Found a bug? Locate the right line and fix it.
The Need for Hibernate
Things get much more interesting in the real world, where you might encounter scenarios like these:
- Domain objects may need to support inheritance.
- A group of fields can be combined into a separate Value Object (Embedded in JPA/Hibernate).
- Some fields shouldn’t be loaded every time you fetch a domain object, but only when accessed, to improve performance (lazy loading).
- There can be complex relationships between objects (one-to-many, many-to-many, etc.).
- You need to include only the fields that have changed in the
UPDATE
statement because other fields rarely change, and there’s no point in sending them over the network (DynamicUpdate annotation).
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.
Goals of JOOQ and Hibernate
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.
What About DAOs in JOOQ?
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:
- The entities won’t have any relationships: no ManyToOne, no OneToMany. Just the database columns, which makes writing business logic much harder.
- Entities are generated individually. You can’t organize them into an inheritance hierarchy.
- The fact that entities are generated along with the DAOs means you can’t modify them as you wish. For example, replacing a field with a Value Object, adding a relationship to another entity, or grouping fields into an Embeddable won’t be possible because regenerating the entities will overwrite your changes. Yes, you can configure the generator to create entities slightly differently, but the customization options are limited (and not as convenient as writing the code yourself).
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 labour-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.
Conclusion
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!
Resources
- JDBI
- Transaction Script
- Domain Model
- My article – Rich Domain Model with Spring Boot and Hibernate
- Repository pattern
- Value Object
- JPA Embedded
- JPA DynamicUpdate
- CQRS
- Lukas Eder: To DAO or not to DAO
原文链接:JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems
暂无评论内容