Logo

dev-resources.site

for different kinds of informations.

JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems

Published at
1/11/2025
Categories
java
jooq
hibernate
database
Author
kirekov
Categories
4 categories in total
java
open
jooq
open
hibernate
open
database
open
Author
7 person written this
kirekov
open
JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems

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:

  1. JOOQ allows you to verify everything at compile time, whereas Hibernate does not!
  2. Hibernate generates strange and not always optimal queries, while with JOOQ, everything is transparent!
  3. Hibernate entities are mutable, which is bad. JOOQ allows all entities to be immutable (hello, functional programming)!
  4. 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:

  1. What is Transaction Script?
  2. What is the Domain Model pattern?
  3. What specific problems do Hibernate and JOOQ solve?
  4. Why isn't one a replacement for the other, and how can they coexist?

Article meme cover

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:

  1. We count how many talks the speaker has already submitted.
  2. We check if the maximum allowable number of submitted talks is exceeded.
  3. 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:

  1. The SQL being executed is straightforward and predictable. It’s easy to tweak it for performance improvements if needed.
  2. We only fetch the necessary data from the database.
  3. With JOOQ, this code can be written more simply, concisely, and with static typing!

Cons:

  1. It’s impossible to test the business logic with unit tests alone. You’ll need integration tests (and quite a few of them).
  2. If the domain is complex, this approach can quickly lead to spaghetti code.
  3. 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:

  1. Domain objects are completely decoupled from implementation details (i.e., the database). This makes them easy to test with regular unit tests.
  2. 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.
  3. 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).
  4. 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:

  1. The code remains highly transparent, just like in the Transaction Script approach.
  2. No more issues with testing business logic only through integration tests. These are needed only for repository implementations (and maybe a few E2E scenarios).
  3. 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:

  1. Domain objects may need to support inheritance.
  2. A group of fields can be combined into a separate Value Object (Embedded in JPA/Hibernate).
  3. Some fields shouldn’t be loaded every time you fetch a domain object, but only when accessed, to improve performance (lazy loading).
  4. There can be complex relationships between objects (one-to-many, many-to-many, etc.).
  5. 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:

  1. The entities won’t have any relationships: no ManyToOne, no OneToMany. Just the database columns, which makes writing business logic much harder.
  2. Entities are generated individually. You can’t organize them into an inheritance hierarchy.
  3. 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

  1. JDBI
  2. Transaction Script
  3. Domain Model
  4. My article – Rich Domain Model with Spring Boot and Hibernate
  5. Repository pattern
  6. Value Object
  7. JPA Embedded
  8. JPA DynamicUpdate
  9. CQRS
  10. Lukas Eder: To DAO or not to DAO
database Article's
30 articles in total
Favicon
Why Successful Companies Don't Have DBAs
Favicon
How Supabase Made Me Rethink App Development (And Honestly, I’m Obsessed)
Favicon
Developing a project using Java Spring Framework, JSON, JPA and PostgreSQL
Favicon
Query Data with DynamoDB
Favicon
Let's take a quick look at Drizzle ORM
Favicon
Simplify Python-Informix Connections with wbjdbc
Favicon
Building an Intelligent SQL Query Assistant with Neon, .NET, Azure Functions, and Azure OpenAI service
Favicon
TypeScript Discord Bot Handler
Favicon
How to Fix the “Record to Delete Does Not Exist” Error in Prisma
Favicon
Extract structured data using Python's advanced techniques
Favicon
Key Component of a Manufacturing Data Lakehouse
Favicon
Enabling Database Backup and Restore to S3 for SQL Server in AWS RDS: A Step-by-Step Guide
Favicon
Firebase Alternatives to Consider in 2025
Favicon
Building the Foundations: A Beginner’s Guide to HTML
Favicon
Why top AI architects are DITCHING relationalDBs for knowledge graphs
Favicon
Intelligent PDF Data Extraction and database creation
Favicon
What you should know about CIDR in clear terms
Favicon
Data Privacy Challenges in Cloud Environments
Favicon
open source multi tenant cloud database
Favicon
Diesel vs SQLx in Raw and ORM Modes
Favicon
Identifying and Resolving Blocking Sessions in Oracle Database
Favicon
Show query window at startup in SQL Server Management Studio
Favicon
How to Set Custom Status Bar Colors in SSMS to Differentiate Environments
Favicon
JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems
Favicon
Top 20 Unique Database Project Ideas For Students
Favicon
Day 13 of My Android Adventure: Crafting a Custom WishList App with Sir Denis Panjuta
Favicon
TimescaleDB in 2024: Making Postgres Faster
Favicon
Auditing SQL Server Database Users, Logins, and Activity: A Comprehensive Guide
Favicon
Primeiros Passos no PostgreSQL: Um Guia Completo para Iniciantes
Favicon
Find logged Microsoft SQL Server Messages

Featured ones: