Upgrade to Pro — share decks privately, control downloads, hide ads and more …

The Persistence Heavyweight Championship: JPA v...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

The Persistence Heavyweight Championship: JPA vs. jOOQ

"In the blue corner: the undisputed heavyweight industry standard, the king of the ORMs, weighing in with a decade of dominance and millions of Stack Overflow answers... JPA!

In the red corner: the lean, type-safe challenger, the Sultan of SQL, the elite defender of the database... jOOQ!"

This session isn't just a talk; it’s a technical title fight. We’re going glove-to-glove on the issues that matter: the N+1 knockout, the "Criteria API" clinch, the power of code generation, and raw runtime performance. After every round, the referee (the audience) will vote to decide who landed the winning blow. Who will bring home the belt, and who will be left on the canvas? Your vote decides the champion.

Avatar for Catherine

Catherine

April 22, 2026

More Decks by Catherine

Other Decks in Programming

Transcript

  1. @Entity @Table(name = "suppliers") public class Supplier { @Id @GeneratedValue(strategy

    = GenerationType.IDENTITY) private Long id; @OneToMany(mappedBy = "supplier", cascade = CascadeType.ALL, orphanRemoval = false) private List<Product> products = new ArrayList<>(); } write domain models define connections with annotations The DB assigns the entity’s primary key during insert
  2. public interface ProductRepository extends JpaRepository<Product, Long> { } @Service @Transactional

    public class ProductService { public Optional<Product> findById(Long productId) { return productRepository.findById(productId); } } Schema generation, SQL generation and execution, DB connection, fetching and mapping DONE FOR YOU
  3. Just generate everything from DB • PRODUCT — holds PRODUCT.NAME,

    PRODUCT.ID etc. • ProductRecord - fetch/insert update into DB • ProductDao — CRUD without SQL • Product — POJO • IProduct — interface implemented by them all optional
  4. <plugin> <groupId>org.jooq</groupId> <artifactId>jooq-codegen-maven</artifactId> <version>3.21.1</version> <executions> <execution> <id>generate-jooq</id> <phase>generate-sources</phase> <goals> <goal>generate</goal>

    </goals> </execution> </executions> <configuration> <jdbc> <driver>org.postgresql.Driver</driver> <url>jdbc:postgresql://localhost:5432/yourdb</url> <user>youruser</user>
  5. </goals> </execution> </executions> <configuration> <jdbc> <driver>org.postgresql.Driver</driver> <url>jdbc:postgresql://localhost:5432/yourdb</url> <user>youruser</user> <password>yourpassword</password> </jdbc>

    <generator> <database> <name>org.jooq.meta.postgres.PostgresDatabase</name> <includes>.*</includes> <excludes/> <inputSchema>public</inputSchema> </database> <target>
  6. - Hibernate Processor generates the JPA static metamodel - Can

    validate HQL, JPQL, and JDQL queries - Type-safe everything: Criteria queries, entity graphs, dynamic sorting/filtering, etc.
  7. @StaticMetamodel(Product.class) @Generated("org.hibernate.processor.HibernateProcessor") public abstract class Product_ { public static final

    String ID = "id"; public static volatile EntityType<Product> class_; public static volatile SingularAttribute<Product, Long> id;
  8. public List<Product> findByName(String name) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Product>

    cq = cb.createQuery(Product.class); Root<Product> product = cq.from(Product.class); cq.select(product) .where(cb.equal(product.get(Product_.name), name)); return entityManager.createQuery(cq).getResultList(); }
  9. Dynamic Queries with Criteria API public List<Product> searchProducts(String supplierName, String

    categoryName, int offset, int limit) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Product> cq = cb.createQuery(Product.class); Root<Product> product = cq.from(Product.class);
  10. Predicate predicate = cb.conjunction(); if (supplierName != null && !supplierName.isBlank())

    { Join<Product, Supplier> supplier = product.join(Product_.supplier, JoinType.LEFT); predicate = cb.and(predicate, cb.equal(supplier.get(Supplier_.name), supplierName)); } if (categoryName != null && !categoryName.isBlank()) { Join<Product, Category> category = product.join(Product_.category, JoinType.LEFT); predicate = cb.and(predicate, cb.equal(category.get(Category_.name), categoryName)); }
  11. @NamedQuery( name = "brokenOrderQuery", query = "select o from CustomerOrder

    o where o.noSuchField = :id" ) public class CompileTimeQueryValidationDemo { } Query Validation
  12. Guardrails: - FetchType.LAZY explicitly for all associations with JOIN FETCH

    in the query - @BatchSize annotation for fetching in batches
  13. CriteriaQuery<CustomerOrder> cq = cb.createQuery(CustomerOrder.class); Root<CustomerOrder> order = cq.from(CustomerOrder.class); Fetch<CustomerOrder, OrderItem>

    itemFetch = order.fetch(CustomerOrder_.items); cq.select(order) .distinct(true) .where(cb.equal(order.get(CustomerOrder_.id), orderId));
  14. You - Load an aggregate, - Make in-memory changes, -

    Commit once Hibernate’s write-side superpower As Lukas Eder, creator of jOOQ From: https://blog.jooq.org/jooq-vs-hibernate-when-to-choose-which/
  15. public record OrderEditRequest( ShipmentAddressRequest address, Map<Long, Integer> newProductIdsWithQuantities, List<String> productUpisToRemove,

    Map<String, Integer> quantityUpdates, String couponCode, BigDecimal couponDiscountAmount ) { }
  16. @Transactional public void reviseOrder(Long orderId, OrderEditRequest request) { CustomerOrder order

    = repository.fetchOrderGraphForEdit(orderId); ShipmentAddressRequest address = request.address(); Country newCountry = repository.findCountryByCode(address.countryCode()); // Update shipping address order.changeShipping(address.city(), address.street(), address.postalCode(), newCountry);
  17. // Remove existing items -> orphanRemoval deletes rows List<String> productUpisToRemove

    = request.productUpisToRemove() == null ? List.of() : request.productUpisToRemove(); for (String productUpi : productUpisToRemove) { order.removeItemByProductUpi(productUpi); }
  18. // Add new items -> cascade persist inserts rows Map<Long,

    Integer> newProducts = request.newProductIdsWithQuantities() == null ? Map.of() : request.newProductIdsWithQuantities(); for (Map.Entry<Long, Integer> entry : newProducts.entrySet()) { Long productId = entry.getKey(); Integer quantity = entry.getValue(); order.addItem(entityManager.getReference(Product.class, productId), quantity); }
  19. // Change quantity of existing items Map<String, Integer> quantityUpdates =

    request.quantityUpdates() == null ? Map.of() : request.quantityUpdates(); for (Map.Entry<String, Integer> entry : quantityUpdates.entrySet()) { String productUpi = entry.getKey(); Integer quantity = entry.getValue(); if (quantity == 0) { order.removeItemByProductUpi(productUpi); } else { order.updateItemQuantity(productUpi, quantity); } }
  20. // Add coupon -> cascade persist if (request.couponCode() != null)

    { order.addCoupon(request.couponCode(), request.couponDiscountAmount()); } // Recalculate derived business value after country / items / coupon changes order.recalculateTotal(); // That's it! No need to explicitly save updated objects } If smth changes in the DB → Optimistic locking via @Version
  21. public void reviseOrder(Long orderId, OrderEditRequest request) { // 2. Update

    shipping address in-place — no order graph needed ShipmentAddressRequest address = request.address(); int updated = dsl.update(CUSTOMER_ORDER) .set(CUSTOMER_ORDER.CITY, address.city()) .set(CUSTOMER_ORDER.STREET, address.street()) .set(CUSTOMER_ORDER.POSTAL_CODE, address.postalCode()) .set(CUSTOMER_ORDER.COUNTRY_ID, newCountryId) .where(CUSTOMER_ORDER.ID.eq(orderId)) .execute(); if (updated == 0) { throw new IllegalArgumentException("Order not found: " + orderId); }
  22. // 3. Remove items by UPI List<String> upisToRemove = request.productUpisToRemove()

    == null ? List.of() : request.productUpisToRemove(); if (!upisToRemove.isEmpty()) { dsl.delete(ORDER_ITEM) .where(ORDER_ITEM.ORDER_ID.eq(orderId)) .and(ORDER_ITEM.PRODUCT_UPI.in(upisToRemove)) .execute(); }
  23. // 4. Insert new items Map<Long, Integer> newProducts = request.newProductIdsWithQuantities()

    == null ? Map.of() : request.newProductIdsWithQuantities(); if (!newProducts.isEmpty()) { var insertStep = dsl.insertInto(ORDER_ITEM, ORDER_ITEM.ORDER_ID, ORDER_ITEM.PRODUCT_ID, ORDER_ITEM.QUANTITY); newProducts.entrySet().stream() .forEach(e -> insertStep.values(orderId, e.getKey(), e.getValue())); insertStep.execute(); }
  24. if (!upisToUpdate.isEmpty()) { var quantityCase = DSL.case_(ORDER_ITEM.PRODUCT_UPI); for (var entry

    : upisToUpdate) { quantityCase = quantityCase.when(entry.getKey(), DSL.val(entry.getValue())); } dsl.update(ORDER_ITEM) .set(ORDER_ITEM.QUANTITY, quantityCase.end()) .where(ORDER_ITEM.ORDER_ID.eq(orderId)) .and(ORDER_ITEM.PRODUCT_UPI.in( upisToUpdate.stream().map(Map.Entry::getKey).toList())) .execute(); }
  25. if (!upisToDelete.isEmpty()) { dsl.delete(ORDER_ITEM) .where(ORDER_ITEM.ORDER_ID.eq(orderId)) .and(ORDER_ITEM.PRODUCT_UPI.in(upisToDelete)) .execute(); } for (var

    entry : upisToUpdate) { dsl.update(ORDER_ITEM) .set(ORDER_ITEM.QUANTITY, entry.getValue()) .where(ORDER_ITEM.ORDER_ID.eq(orderId)) .and(ORDER_ITEM.PRODUCT_UPI.eq(entry.getKey())) .execute(); }
  26. // 6. Replace coupon — delete existing, insert new if

    present dsl.delete(ORDER_COUPON) .where(ORDER_COUPON.ORDER_ID.eq(orderId)) .execute(); if (request.couponCode() != null) { dsl.insertInto(ORDER_COUPON, ORDER_COUPON.ORDER_ID, ORDER_COUPON.CODE, ORDER_COUPON.DISCOUNT_AMOUNT) .values(orderId, request.couponCode(), request.couponDiscountAmount()) .execute(); }
  27. - Caching - Stateless session - Projection fetching Complex tool.

    Complex ≠ fragile. Performance Features
  28. - It takes some time to construct jOOQ queries -

    It takes some time to render SQL strings - It takes some time to bind values to prepared statements Cache things Use connection pooling
  29. Static metamodel is refactor-safe against domain changes jakarta.persistence.schema-generation.database.action=validate + integration

    tests to avoid compiling with schema mismatches DB migration tooling, schema diff checks for DB/entities mismatch Domain-first code generation + compile-time checks rename supplier → vendor, regenerate metamodel – compiler detects broken queries.
  30. Change your DB incompatibly and see how your application doesn’t

    even compile. Because it shouldn’t. And you don’t need plugins for it!
  31. - Funded/sponsored for ~20 years - Corporate contributors: Red Hat,

    Oracle, MongoDB, etc. - Multiple books - Stack Overflow: - hibernate: 95K questions - jpa: 52K questions - spring-data-jpa: 23K questions Project Maturity
  32. - Apache 2.0 license → can use it in commercial

    products for free with ANY database supported by Hibernate - Support with SLA and EOL is available from Red Hat Licensing and Support
  33. What we didn’t mention: 1. Read-side queries 2. Multiset 3.

    Custom SQL operations 4. Query Validator 5. Blaze Persistence