Posts

JDBC vs ORM vs jOOQ: Choosing the Right Java Database Stack

Catherine Edelveis

12

JDBC, JPA, Hibernate, jOOQ. Are these five different ways to connect to a database? Not exactly. They're not competing tools. They're layers, and the funny thing is that most of the time, you're using several of them without tracking which one is doing what.

As I was writing this article, a metaphor, which makes the stack clearer, came to my mind. Communicating with a database is like communicating in a foreign language.

  • If you speak the language yourself, you write every sentence directly. That's JDBC.
  • If you hire a translator, you speak your native language and let them handle the SQL. That's ORM. JPA, Hibernate, and Blaze Persistence live in this category.
  • If you hire a corrector instead, someone who lets you write in the foreign language but checks your work for mistakes before it goes out, that's jOOQ.

The choice between them isn't about preference. It's about how much control you need, and what you're willing to trade for it.

In this article, we will look at all these concepts, their pros and cons, and when to use which.

JDBC: Write It Yourself

JDBC or Java Database Connectivity has been part of Java since version 1.1. It's the low-level API for communicating with relational databases, and everything else in this article eventually calls it.

With JDBC, you write SQL yourself, manage connections yourself, and map result rows to objects yourself:

public class BaseRepository<T> {

    protected final JdbcTemplate jdbc;
    protected final RowMapper<T> mapper;

    public BaseRepository(JdbcTemplate jdbc, RowMapper<T> mapper) {
        this.jdbc = jdbc;
        this.mapper = mapper;
    }

    protected Optional<T> findOne(String query, Object... params) {
        T result = jdbc.queryForObject(query, mapper, params);
        return Optional.ofNullable(result);
    }

    protected List<T> findMany(String query, Object... params) {
        return jdbc.query(query, mapper, params);
    }

    protected boolean delete(String query, long id) {
        int rowsDeleted = jdbc.update(query, id);
        return rowsDeleted > 0;
    }

    protected void update(String query, Object... params) {
        int rowsUpdated = jdbc.update(query, params);
        if (rowsUpdated == 0) {
            throw new InternalServerException("Couldn't update data");
        }
    }

    protected long insert(String query, Object... params) {
        GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
        jdbc.update(connection -> {
            PreparedStatement ps = connection
                    .prepareStatement(query, Statement.RETURN_GENERATED_KEYS);
            for (int idx = 0; idx < params.length; idx++) {
                ps.setObject(idx + 1, params[idx]);
            }
            return ps;}, keyHolder);

        Long id = keyHolder.getKeyAs(Long.class);
        if (id != null) {
            return id;
        } else {
            throw new InternalServerException("Couldn't save data");
        }
    }
}

As you can see from the snippet above, relying on JDBC only will make the code quite verbose. Every query is a string, and every mapping is manual.

But the payoff is complete transparency. There’s no generated SQL to hunt in logs and no framework behavior to work around. Essentially, what you see is what you get, or rather, what you wrote is what you run.

JDBC (or Spring Data JDBC, which adds a thin convenience layer on top) is the right call for a small, focused service with a stable set of queries and minimal dependencies. However, it gets expensive quickly once you have dozens of entities and relationships.

ORM and JPA: Hire a Translator

Sometimes you don't want to write SQL. You have a domain model, you have Java objects, and you'd rather manipulate those objects and let something else figure out the database part.

That's ORM, Object-Relational Mapping. The framework translates your object operations into SQL. Most of the time, you don't even see the queries.

JPA, Java Persistence API, is the specification for how that translation should work. It defines the annotations, the interfaces, and the contract between your entities and the ORM tool:

@PersistenceContext
private EntityManager em;

@Transactional
public void save(Product product) {
    em.persist(product);
}
@Entity
@Table(name = "products")
public class Product {

    @Id
    @Column(name = "id", nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(cascade =
            {CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
    @JoinColumn(name="supplier_id")
    private Supplier supplier;
}

Note that JPA doesn't do any actual work: it's a set of interfaces and rules. To use it, you need an implementation. The one almost everyone uses is Hibernate.

Hibernate: A Very Good Translator (Mostly)

Hibernate is the most widely used JPA implementation. When developers say "I'm using JPA," they mean Hibernate roughly 90% of the time.

On top of the standard JPA APIs, Hibernate adds its own native API (Session, SessionFactory), extra mapping annotations, SQL dialect support for PostgreSQL, Oracle, MySQL, and many others, plus some cherry-on-the-pie features.

For example, caching is one of these features. The first-level cache stores entities in use within a specific session. This cache is on by default. The second-level cache keeps frequently accessed data in memory across sessions, so the database gets fewer hits for anything read repeatedly. Enable it with @Cacheable and a CacheConcurrencyStrategy:

@Entity
@Table(name = "products")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
}

With Hibernate, standard one-to-many and many-to-many relationships are a solved problem. You annotate your entities, Hibernate writes the joins. It plugs into Spring, Quarkus, and Micronaut cleanly. Auditing, validation, and event hooks are built in or attached without much ceremony. For a domain-driven application with a conventional relational model and standard CRUD, Hibernate earns its place.

But don’t get excited over Hibernate too early. Gavin King, Hibernate's creator, put the nuance plainly: "Just because you are using Hibernate, doesn't mean you have to use it for everything."

So, let’s look at cases where Hibernate struggles.

Where Hibernate Struggles

The N+1 problem is Hibernate's most famous trap. You fetch a list of parent records, then access a related collection on each one:

public void printSuppliersWithProducts() {

    // First query: get all suppliers
    List<Supplier> suppliers = em.createQuery(
            "select s from Supplier s", Supplier.class
    ).getResultList();

    // For each supplier, accessing products triggers another SELECT
    for (Supplier supplier : suppliers) {

        // This triggers a separate query per supplier
        List<Product> products = supplier.getProducts();

        System.out.println("Supplier: " + supplier.getName());
        for (Product product : products) {
            System.out.println("  Product: " + product.getName());
        }
    }
}

50 suppliers. 51 queries. The application feels fine in development with 5 rows and surfaces the problem in production with 5,000.

This isn't a Hibernate bug; rather, it's the consequence of the ORM trade-off. The translator doesn't know you were going to ask about the children until you ask.

Beyond N+1, Hibernate has some other ceilings. Window functions like RANK() and running totals require falling back to native SQL queries. Anti-joins like "give me all rows with no match in this other set" are technically possible but awkward. JSON column mapping doesn't fit the entity model. Partial composite key references trip Hibernate up.

Then, there's equals/hashCode. Implementing these correctly for JPA entities is a discipline of its own. Get them wrong and you get subtle bugs in collections, session state, and second-level cache behavior. In addition, a toString() can trigger lazy loading on every field.

jOOQ: Hire a Corrector Instead

jOOQ or Java Object Oriented Querying takes the opposite bet from Hibernate. Instead of abstracting SQL away, it gives you a way to write SQL in Java with full type safety.

The model is database-first. jOOQ generates Java classes from your existing schema: tables, columns, enums, and foreign keys, all as typed Java objects. You reference PRODUCT.ID, not the string "product.id". If you rename a column in the schema and regenerate, any query referencing the old name stops compiling.

In short, the database schema is the source of truth. So, the errors surface at build time.

Under the hood, jOOQ translates the DSL back to SQL and executes it via JDBC.

The queries where jOOQ's approach pays off most clearly are the ones Hibernate turns into a series of problems. One good example of such a query is fetching a parent record with multiple nested child collections.

This is Hibernate's N+1 problem one query per parent record, for however many parents you have. jOOQ's MULTISET takes a different approach:

Field<List<BookingDto>> bookings =
        multiset(
                select(
                        BOOKING.ID,
                        BOOKING.STATUS,
                        BOOKING.CREATED_AT,
                        FACILITY.NAME,
                        APPOINTMENT_SLOT.STARTS_AT,
                        STAFF.HANDLE
                )
                        .from(BOOKING)
                        .join(APPOINTMENT_SLOT).on(APPOINTMENT_SLOT.ID.eq(BOOKING.APPOINTMENT_SLOT_ID))
                        .join(FACILITY).on(FACILITY.ID.eq(APPOINTMENT_SLOT.FACILITY_ID))
                        .leftJoin(STAFF).on(STAFF.ID.eq(BOOKING.STAFF_ID))
                        .where(BOOKING.TRIAGE_CASE_ID.eq(TRIAGE_CASE.ID))
                        .orderBy(APPOINTMENT_SLOT.STARTS_AT.asc(), BOOKING.CREATED_AT.asc())
        ).as("bookings")
                .convertFrom(rs -> rs.map(r -> new BookingDto(
                        r.get(BOOKING.ID),
                        r.get(BOOKING.STATUS).toString(),
                        r.get(BOOKING.CREATED_AT),
                        r.get(FACILITY.NAME),
                        r.get(APPOINTMENT_SLOT.STARTS_AT),
                        r.get(STAFF.HANDLE)
                )));

MULTISET collects the results of a correlated subquery into a typed nested collection, a List<BookingDto> on the parent record, in a single database round-trip. There's no session state to manage, no lazy loading proxy watching your field accesses, and no queries running behind the scenes that you didn't write.

The database does what databases are good at. jOOQ maps the structured result back to typed Java collections.

This pattern extends to multiple levels of nesting (children of children) without the query count growing.

In addition to that, jOOQ handles window functions, CTEs, conditional expressions, and most of what Hibernate sends to native SQL territory. All of that is type-safe and in the same fluent DSL.

Pick Your Remedy

As usual, no single tool wins across all use cases, so the decision is yours to make based on your project.

For a small service with a handful of stable queries, JDBC is the most honest choice.

Hibernate earns its keep in domain-driven applications where the relations are conventional and most operations are standard CRUD. You get mapping, caching, and framework integration without writing SQL for the common cases.

When SQL is central to the application (reporting queries, window functions, nested payloads, database-specific features) jOOQ is the right choice. Type-safe SQL with compile-time feedback is a real advantage when query complexity reaches the point where assembling strings becomes a liability.

The good news is that you can take the best of two worlds and use Hibernate and jOOQ in the same project. Hibernate will handle the CRUD and complex writes, jOOQ will handle complex reads.

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading