Skip to main content
the lies your orm tells you

When to Stop Using Hibernate

7 min read Chapter 28 of 30

When to Stop Using Hibernate

The Lie

Hibernate is the standard Java persistence framework. Every serious Java application uses it. If you have problems, you are using it wrong.

The Reality

Hibernate solves a specific problem: mapping Java objects to relational tables with transparent persistence, identity management, dirty tracking, and lazy loading. When your application needs these features, Hibernate saves significant development time.

When your application fights these features, Hibernate becomes overhead. Every chapter of this book documents a way Hibernate’s abstractions leak, generate unexpected queries, consume unexpected memory, or hold connections longer than necessary. If most of your data access patterns work against Hibernate’s model, you are paying the cost of an abstraction you are not using.

Hybrid Architecture: Hibernate + jOOQ

The diagram shows the recommended split: Hibernate handles writes where its entity lifecycle features (dirty tracking, cascade, optimistic locking) add real value, while jOOQ or JDBC Template handles reads, lists, and reporting queries that only load data to discard it. Both tools share the same DataSource and HikariCP pool, and both participate in Spring-managed transactions. The 70/30 annotation at the bottom reflects typical real-world usage in read-heavy CRUD applications.

Signs Hibernate is costing more than it gives:

  1. Most queries are projections or reports. You load entities to read 3 of 25 fields. You never call save() on them. The persistence context tracks entities you will never modify.

  2. Bulk operations dominate. You batch-insert thousands of rows. You bulk-update with SET clauses. You delete by criteria. Hibernate’s entity lifecycle model adds overhead to every one of these operations.

  3. You write native SQL for most complex queries. Your application uses window functions, CTEs, lateral joins, and database-specific features that JPQL cannot express. You are using Hibernate as a worse version of JDBC with XML configuration.

  4. N+1 and lazy loading are recurring production issues. You have added @EntityGraph, JOIN FETCH, and batch fetch annotations everywhere, fighting the default behavior that Hibernate was designed around.

  5. Your team spends more time debugging Hibernate than writing business logic. Understanding flush timing, persistence context behavior, and proxy initialization takes deep framework knowledge. If your team does not have that knowledge, every Hibernate decision is a guess.

The Evidence

Compare Hibernate and jOOQ for a reporting query:

// Hibernate: Load entities, extract data in Java
@Transactional(readOnly = true)
public List<MonthlySalesReport> generateReport(int year) {
    List<Order> orders = orderRepository
        .findByCreatedAtYear(year);  // Full entity load

    // 50,000 Order entities loaded into persistence context
    // Each with snapshots (even readOnly stores entity refs)
    // Memory: ~100 MB
    // GC pressure: significant

    return orders.stream()
        .collect(Collectors.groupingBy(
            o -> o.getCreatedAt().getMonth(),
            Collectors.summarizingDouble(
                o -> o.getTotal().doubleValue())))
        .entrySet().stream()
        .map(e -> new MonthlySalesReport(
            e.getKey(), e.getValue().getSum(),
            e.getValue().getCount()))
        .toList();
}
// jOOQ: Write the SQL, get typed results
public List<MonthlySalesReport> generateReport(int year) {
    return dsl.select(
            month(ORDERS.CREATED_AT).as("month"),
            sum(ORDERS.TOTAL).as("revenue"),
            count().as("order_count"))
        .from(ORDERS)
        .where(year(ORDERS.CREATED_AT).eq(year))
        .groupBy(month(ORDERS.CREATED_AT))
        .orderBy(month(ORDERS.CREATED_AT))
        .fetchInto(MonthlySalesReport.class);

    // Generated SQL:
    // select extract(month from created_at) as month,
    //        sum(total) as revenue,
    //        count(*) as order_count
    // from orders
    // where extract(year from created_at) = ?
    // group by extract(month from created_at)
    // order by extract(month from created_at)
    //
    // Result: 12 rows. ~1 KB. No entity management.
}
// Spring JDBC Template: Manual mapping, full control
public List<MonthlySalesReport> generateReport(int year) {
    return jdbcTemplate.query("""
        SELECT extract(month FROM created_at) AS month,
               SUM(total) AS revenue,
               COUNT(*) AS order_count
        FROM orders
        WHERE extract(year FROM created_at) = ?
        GROUP BY extract(month FROM created_at)
        ORDER BY 1
        """,
        (rs, rowNum) -> new MonthlySalesReport(
            Month.of(rs.getInt("month")),
            rs.getBigDecimal("revenue"),
            rs.getLong("order_count")),
        year);
}

The jOOQ and JDBC Template versions produce the same SQL, return the same 12 rows, use ~1 KB of memory, and create no persistence context. The Hibernate version loads 50,000 entities, uses ~100 MB, and performs the aggregation in Java that the database could have done in milliseconds.

The Fix

You do not have to choose one data access layer for the entire application.

The Hybrid Architecture

Use Hibernate for what it does well (CRUD with entity lifecycle management) and a lighter tool for what it does not (reads, reporting, bulk operations).

// Hibernate for writes: entity lifecycle, dirty tracking, cascading
@Service
public class OrderWriteService {

    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public Order createOrder(OrderRequest request) {
        Order order = new Order();
        order.setCustomerId(request.customerId());
        order.setStatus(OrderStatus.PENDING);

        for (ItemRequest item : request.items()) {
            OrderItem orderItem = new OrderItem();
            orderItem.setProductId(item.productId());
            orderItem.setQuantity(item.quantity());
            orderItem.setUnitPrice(item.unitPrice());
            order.addItem(orderItem);  // Cascade handles persistence
        }

        return orderRepository.save(order);
    }
}

// jOOQ for reads: projections, aggregations, window functions
@Service
public class OrderReadService {

    @Autowired
    private DSLContext dsl;

    public Page<OrderListDTO> findOrders(OrderFilter filter,
                                          Pageable pageable) {
        var query = dsl.select(
                ORDERS.ID,
                ORDERS.STATUS,
                ORDERS.TOTAL,
                ORDERS.CREATED_AT,
                CUSTOMERS.NAME.as("customer_name"))
            .from(ORDERS)
            .join(CUSTOMERS)
                .on(CUSTOMERS.ID.eq(ORDERS.CUSTOMER_ID));

        if (filter.status() != null) {
            query = query.where(
                ORDERS.STATUS.eq(filter.status().name()));
        }
        if (filter.minTotal() != null) {
            query = query.where(
                ORDERS.TOTAL.ge(filter.minTotal()));
        }

        int total = dsl.fetchCount(query);
        var results = query
            .orderBy(ORDERS.CREATED_AT.desc())
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .fetchInto(OrderListDTO.class);

        return new PageImpl<>(results, pageable, total);
    }
}

Decision Matrix

Use CaseRecommendationWhy
Single entity CRUDHibernateDirty tracking, cascading, lifecycle callbacks
Form submission (create/update)HibernateValidation, optimistic locking, audit
List/search endpointsjOOQ or JDBC TemplateProjections, no entity overhead
Reporting/analyticsjOOQ or native SQLAggregations, window functions
Bulk import/exportJDBC batch or COPYHibernate adds per-entity overhead
Event sourcingSkip ORM entirelyAppend-only writes, projection reads
Microservice with 5 tablesSpring JDBC TemplateHibernate’s complexity is not warranted

When Hibernate Is Still the Right Choice

Hibernate excels when your application:

  • Performs primarily CRUD operations on individual entities or small graphs
  • Relies on optimistic locking and entity lifecycle callbacks
  • Benefits from the persistence context’s identity guarantee (load once, modify anywhere, flush automatically)
  • Has a team with deep Hibernate knowledge
  • Needs database portability (rare, but it happens)

If this describes more than half your data access, keep Hibernate. Supplement it with projections and native queries for the read-heavy paths.

When to Walk Away

If you find yourself adding @EntityGraph to every repository method, writing Hibernate.initialize() calls throughout your service layer, using StatelessSession for performance, and wrapping every read query in DTO projections, you have rebuilt half of JDBC inside Hibernate’s framework. At that point, you are paying for Hibernate’s complexity without using its features. A lighter tool gives you the same result with less ceremony and fewer surprises.

The Cost Model

FactorHibernatejOOQSpring JDBC Template
Startup time2-10s (metamodel, proxy generation)1-3s (code generation)< 1s
Memory per query (1000 rows)~2 MB (entities + context)~200 KB (POJOs)~200 KB (mapped objects)
Learning curveSteep (flush, proxy, cache, fetch)Moderate (SQL-centric)Low (SQL + RowMapper)
Type safetyMetamodel (optional)Code-generated (built-in)None
SQL feature coverage~70% via JPQL~95%100%
Write convenienceHigh (dirty tracking, cascade)Low (explicit SQL)Low (explicit SQL)
Debug transparencyLow (generated SQL, proxy magic)High (SQL is the API)High (SQL is the API)

The decision is not ideological. It is economic. Count the hours your team spends fighting Hibernate versus using it. If the fighting exceeds the using, the abstraction is no longer paying for itself.