Skip to main content
the lies your orm tells you

Migrating Critical Paths Off Hibernate

5 min read Chapter 29 of 30

Migrating Critical Paths Off Hibernate

You have decided that some of your data access should not use Hibernate. The question is how to migrate without rewriting your entire persistence layer.

The Lie

You chose Hibernate for the whole application. Changing now requires a major rewrite.

The Reality

Hibernate, jOOQ, and JDBC Template can share the same DataSource and participate in the same Spring-managed transactions. You can migrate one repository method at a time, one endpoint at a time. No big bang required.

The Evidence

Adding jOOQ alongside Hibernate in a Spring Boot application:

<!-- pom.xml: Add jOOQ alongside existing Hibernate dependencies -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jooq</artifactId>
</dependency>

Spring Boot auto-configures jOOQ’s DSLContext using the same DataSource as Hibernate. Both share the connection pool. Both participate in Spring’s @Transactional boundaries.

// Both Hibernate and jOOQ in the same service, same transaction
@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;  // Hibernate

    @Autowired
    private DSLContext dsl;  // jOOQ

    @Transactional
    public OrderConfirmation placeOrder(OrderRequest request) {
        // Hibernate: create the order (entity lifecycle, cascading)
        Order order = new Order();
        order.setCustomerId(request.customerId());
        request.items().forEach(item -> {
            OrderItem oi = new OrderItem();
            oi.setProductId(item.productId());
            oi.setQuantity(item.quantity());
            oi.setUnitPrice(item.unitPrice());
            order.addItem(oi);
        });
        orderRepository.save(order);

        // jOOQ: update inventory with atomic decrement
        // (no entity loading, no version conflicts)
        for (ItemRequest item : request.items()) {
            int updated = dsl.update(INVENTORY)
                .set(INVENTORY.QUANTITY,
                    INVENTORY.QUANTITY.minus(item.quantity()))
                .where(INVENTORY.PRODUCT_ID.eq(item.productId()))
                .and(INVENTORY.QUANTITY.ge(item.quantity()))
                .execute();

            if (updated == 0) {
                throw new InsufficientStockException(
                    item.productId());
            }
        }

        return new OrderConfirmation(order.getId());
    }
}

Both Hibernate and jOOQ operations participate in the same transaction. If the inventory update fails, the order creation is rolled back. Spring coordinates this because both use the same DataSource and PlatformTransactionManager.

The Fix

Step 1: Identify Migration Candidates

Profile your application to find the code paths where Hibernate costs the most:

// Enable Hibernate statistics temporarily
@Component
public class HibernateStatsLogger {

    @Autowired
    private EntityManagerFactory emf;

    @Scheduled(fixedRate = 60_000)
    public void logStats() {
        Statistics stats = emf.unwrap(SessionFactory.class)
            .getStatistics();

        log.info("""
            Hibernate stats:
              Queries: {} ({}ms avg)
              Entities loaded: {}
              Entities updated: {}
              Collections loaded: {}
              Query cache hit ratio: {}/{}
            """,
            stats.getQueryExecutionCount(),
            stats.getQueryExecutionMaxTime(),
            stats.getEntityLoadCount(),
            stats.getEntityUpdateCount(),
            stats.getCollectionLoadCount(),
            stats.getQueryCacheHitCount(),
            stats.getQueryCacheMissCount());
    }
}

Candidates for migration, ordered by typical impact:

  1. List/search endpoints that load entities just to map to DTOs
  2. Reporting queries that aggregate data in Java instead of SQL
  3. Bulk operations that persist entities in loops
  4. High-contention updates where you fight optimistic locking

Step 2: Migrate Read Paths First

Read paths are the safest to migrate. They do not modify data. They do not interact with the persistence context. They can be switched one endpoint at a time.

// Before: Hibernate entity load + mapping
@Transactional(readOnly = true)
public List<ProductListDTO> searchProducts(String query) {
    List<Product> products = productRepository
        .findByNameContaining(query);
    return products.stream()
        .map(ProductListDTO::from)
        .toList();
}

// After: jOOQ projection
public List<ProductListDTO> searchProducts(String query) {
    return dsl.select(
            PRODUCTS.ID,
            PRODUCTS.NAME,
            PRODUCTS.PRICE,
            PRODUCTS.CATEGORY)
        .from(PRODUCTS)
        .where(PRODUCTS.NAME.containsIgnoreCase(query))
        .fetchInto(ProductListDTO.class);
}

No @Transactional needed for jOOQ read queries (auto-commit is fine for single SELECT statements). No persistence context. No entity overhead. The SQL is explicit and visible.

Step 3: Keep Hibernate for Writes

Entity creation and modification benefit from Hibernate’s dirty tracking, cascade behavior, and lifecycle callbacks. Migrating writes off Hibernate is higher risk and lower reward.

// Keep Hibernate for writes where its features add value
@Transactional
public void updateProduct(Long id, ProductUpdateRequest request) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));

    product.setName(request.name());
    product.setPrice(request.price());
    // Dirty tracking detects the change.
    // @PreUpdate callback fires.
    // @Version increments automatically.
    // Hibernate generates the UPDATE.
}

Step 4: Test Both Paths

When migrating a read endpoint, run both implementations in parallel temporarily:

// Temporary: compare results during migration
@Transactional(readOnly = true)
public List<ProductListDTO> searchProducts(String query) {
    List<ProductListDTO> hibernateResults = productRepository
        .findByNameContaining(query).stream()
        .map(ProductListDTO::from)
        .toList();

    List<ProductListDTO> jooqResults = dsl.select(
            PRODUCTS.ID, PRODUCTS.NAME,
            PRODUCTS.PRICE, PRODUCTS.CATEGORY)
        .from(PRODUCTS)
        .where(PRODUCTS.NAME.containsIgnoreCase(query))
        .fetchInto(ProductListDTO.class);

    if (!hibernateResults.equals(jooqResults)) {
        log.warn("Result mismatch for query '{}': " +
            "hibernate={} rows, jooq={} rows",
            query, hibernateResults.size(), jooqResults.size());
    }

    return jooqResults;  // Switch to jOOQ results
}

Remove the comparison code after a validation period. This is temporary instrumentation, not production architecture.

The Cost Model

Migration effort per code path:

Path TypeMigration EffortRiskPerformance Gain
List endpoint (10 fields)30 minutesLow2-5x memory reduction
Search with filters1-2 hoursLow2-5x memory reduction
Reporting query1-3 hoursLow10-100x memory reduction
Bulk insert/update2-4 hoursMedium5-20x throughput
Write with cascade4-8 hoursHighMarginal

Start with reporting queries. They offer the highest return with the lowest risk. A single reporting endpoint migrated from entity loading to jOOQ aggregation can reduce memory usage from 100 MB to 1 KB and response time from seconds to milliseconds.

The goal is not to eliminate Hibernate. The goal is to use Hibernate where it adds value and use simpler tools where Hibernate’s abstraction is fighting you. Most applications end up with a 70/30 split: Hibernate for writes and simple reads, jOOQ or JDBC Template for projections and analytics.