Hibernate #

Writing correct SQL for every CRUD operation in an enterprise application is repetitive, error-prone work that buries business code under SQL strings and JDBC boilerplate. Hibernate is the most mature ORM (Object-Relational Mapping) solution in the Java ecosystem — it transparently maps Java objects to database tables, manages connections, handles transactions, and generates optimized SQL. Developers can focus on writing business logic in a familiar object language without thinking about the SQL details of every operation. But Hibernate isn’t magic — it has a mental model that needs to be understood well. Concepts like the persistence context, lazy loading, dirty checking, and N+1 queries are traps that often cause subtle bugs and performance problems in production. Understanding how Hibernate works under the hood is the key to using it effectively, not just knowing how to use its annotations.

Hibernate Architecture #

Before writing code, understand the two core concepts that control everything in Hibernate: SessionFactory and Session.

SessionFactory and Session #

SessionFactory is a heavyweight object created once at application startup. It stores all mapping metadata, connection pool configuration, and the second-level cache. An application usually has one SessionFactory.

Session is a lightweight object representing one unit of work — one conversation between the application and the database. It manages the persistence context: the set of entities that Hibernate is “watching” within a session.

flowchart TD
    APP[Java Application]

    subgraph SF[SessionFactory — created once at startup]
        META["Mapping Metadata\nentity → table"]
        POOL["Connection Pool\nHikariCP"]
        L2["Second-Level Cache\nEhCache / Redis"]
    end

    subgraph S[Session — per unit of work]
        PC["Persistence Context\nidentity map"]
        L1["First-Level Cache\nper session"]
    end

    APP -->|open session| S
    SF -->|provides| S
    S -->|query / flush| DB["(Database)"]

The Persistence Context and Entity States #

This is the most important concept in Hibernate. Every entity you work with is in one of four states:

stateDiagram-v2
    [*] --> Transient: new Product()

    Transient --> Persistent: session.persist() / session.save()
    Persistent --> Detached: session.close() / session.evict()
    Persistent --> Removed: session.remove() / session.delete()
    Detached --> Persistent: session.merge()
    Removed --> [*]: flush() + commit()

    note right of Persistent: Hibernate watches changes\n(dirty checking)\nautomatic SQL on flush
    note right of Detached: Not watched\nchanges aren't saved automatically
    note right of Transient: Not connected\nto the database at all
// Transient — a new object, unknown to Hibernate
Product product = new Product();
product.setName("Laptop");

// Persistent — managed by the persistence context
session.persist(product);
product.setPrice(new BigDecimal("15000000")); // this change is SAVED AUTOMATICALLY on flush!

// session.flush() + session.getTransaction().commit()
// Hibernate detects the price change and executes an UPDATE automatically

// Detached — after the session closes
session.close();
product.setStock(10); // this change is NOT saved — nobody is watching

// Persistent again — merge() returns it to the managed state
Session newSession = sessionFactory.openSession();
Product managed = newSession.merge(product); // an UPDATE SQL is executed

Setting Up Dependencies #

<!-- Maven — standalone Hibernate ORM (without Spring) -->
<dependencies>
    <dependency>
        <groupId>org.hibernate.orm</groupId>
        <artifactId>hibernate-core</artifactId>
        <version>6.5.2.Final</version>
    </dependency>

    <!-- PostgreSQL database driver -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.3</version>
    </dependency>

    <!-- HikariCP connection pool -->
    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
        <version>5.1.0</version>
    </dependency>
</dependencies>

The configuration file src/main/resources/META-INF/persistence.xml:

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="3.0"
    xmlns="https://jakarta.ee/xml/ns/persistence"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

    <persistence-unit name="onlinestore" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>

        <!-- Register all entity classes -->
        <class>com.example.model.Product</class>
        <class>com.example.model.Category</class>
        <class>com.example.model.Order</class>

        <properties>
            <!-- Database connection -->
            <property name="jakarta.persistence.jdbc.driver"
                      value="org.postgresql.Driver"/>
            <property name="jakarta.persistence.jdbc.url"
                      value="jdbc:postgresql://localhost:5432/onlinestore"/>
            <property name="jakarta.persistence.jdbc.user" value="postgres"/>
            <property name="jakarta.persistence.jdbc.password" value="secret"/>

            <!-- Hibernate settings -->
            <property name="hibernate.dialect"
                      value="org.hibernate.dialect.PostgreSQLDialect"/>
            <property name="hibernate.hbm2ddl.auto" value="validate"/>
            <!-- none | validate | update | create | create-drop -->

            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
            <property name="hibernate.use_sql_comments" value="true"/>

            <!-- Connection pool via HikariCP -->
            <property name="hibernate.hikari.maximumPoolSize" value="10"/>
            <property name="hibernate.hikari.minimumIdle" value="2"/>
            <property name="hibernate.hikari.connectionTimeout" value="30000"/>

            <!-- Batch operations -->
            <property name="hibernate.jdbc.batch_size" value="25"/>
            <property name="hibernate.order_inserts" value="true"/>
            <property name="hibernate.order_updates" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

Entity Mapping #

A Basic Entity #

package com.example.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products",
       indexes = {
           @Index(name = "idx_products_name", columnList = "name"),
           @Index(name = "idx_products_category", columnList = "category_id")
       })
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
                    generator = "product_seq")
    @SequenceGenerator(name = "product_seq",
                       sequenceName = "product_id_seq",
                       allocationSize = 50) // fetch 50 IDs at once — more efficient than IDENTITY
    private Long id;

    @NotBlank
    @Size(max = 100)
    @Column(nullable = false, length = 100)
    private String name;

    @Lob // for long text
    @Column(columnDefinition = "TEXT")
    private String description;

    @NotNull
    @DecimalMin("0.01")
    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal price;

    @Min(0)
    @Column(nullable = false)
    private int stock;

    @Column(nullable = false)
    private boolean active = true;

    // Enum mapping
    @Enumerated(EnumType.STRING) // store as a string, not a number
    @Column(nullable = false, length = 20)
    private ProductStatus status = ProductStatus.DRAFT;

    // Automatic timestamps
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    // Getters and setters
    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public int getStock() { return stock; }
    public void setStock(int stock) { this.stock = stock; }
    public boolean isActive() { return active; }
    public void setActive(boolean active) { this.active = active; }
    public ProductStatus getStatus() { return status; }
    public void setStatus(ProductStatus status) { this.status = status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

public enum ProductStatus {
    DRAFT, ACTIVE, INACTIVE, DELETED
}

Embedded and Embeddable #

To group fields that logically belong together without creating a separate table:

// @Embeddable — an object embedded into another entity
@Embeddable
public class Address {

    @Column(nullable = false, length = 200)
    private String street;

    @Column(nullable = false, length = 50)
    private String city;

    @Column(nullable = false, length = 10)
    private String zipCode;

    @Column(nullable = false, length = 50)
    private String province;

    // getters and setters...
}

@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    // @Embedded — use @AttributeOverrides if column names need changing
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street", column = @Column(name = "shipping_street")),
        @AttributeOverride(name = "city", column = @Column(name = "shipping_city")),
        @AttributeOverride(name = "zipCode", column = @Column(name = "shipping_zip_code")),
        @AttributeOverride(name = "province", column = @Column(name = "shipping_province"))
    })
    private Address shippingAddress;

    // getters and setters...
}

Relationships Between Entities #

OneToMany and ManyToOne #

The most common relationship: one Category has many Products, each Product belongs to one Category.

@Entity
@Table(name = "categories")
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    private String name;

    // mappedBy refers to the field on the ManyToOne side
    // cascade: operations on Category are propagated to its Products
    // orphanRemoval: Products removed from the Category are automatically deleted from the DB
    @OneToMany(mappedBy = "category",
               cascade = CascadeType.ALL,
               orphanRemoval = true,
               fetch = FetchType.LAZY) // LAZY = default for collections, NOT loaded when querying Category
    private java.util.List<Product> products = new java.util.ArrayList<>();

    // Helper methods to keep both sides of the relationship consistent
    public void addProduct(Product product) {
        this.products.add(product);
        product.setCategory(this); // also set the ManyToOne side
    }

    public void removeProduct(Product product) {
        this.products.remove(product);
        product.setCategory(null);
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public java.util.List<Product> getProducts() { return products; }
}

// Add to the Product entity:
@ManyToOne(fetch = FetchType.LAZY) // LAZY = default for single entities
@JoinColumn(name = "category_id", nullable = false)
private Category category;

public Category getCategory() { return category; }
public void setCategory(Category category) { this.category = category; }

ManyToMany #

A many-to-many relationship: one Order can have many Products, one Product can be in many Orders.

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private java.time.LocalDateTime orderDate;

    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal total;

    // The relationship owner (owns the join table)
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
        name = "order_product",               // join table name
        joinColumns = @JoinColumn(name = "order_id"),
        inverseJoinColumns = @JoinColumn(name = "product_id")
    )
    private java.util.Set<Product> products = new java.util.HashSet<>();
    // Use a Set, not a List, for ManyToMany — avoids duplicates and performance issues

    public void addProduct(Product p) { products.add(p); }
    public void removeProduct(Product p) { products.remove(p); }

    // getters and setters...
}
For ManyToMany relationships that need to store extra data in the join table (like item quantity or the price at purchase time), don’t use @ManyToMany directly. Create an intermediary OrderItem entity with @ManyToOne relationships to both Order and Product. This is far more flexible and easier to query.

OneToOne #

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    // OneToOne — the profile is loaded separately (LAZY)
    @OneToOne(mappedBy = "user",
              cascade = CascadeType.ALL,
              fetch = FetchType.LAZY,
              optional = true)
    private UserProfile profile;

    // getters and setters...
}

@Entity
@Table(name = "user_profiles")
public class UserProfile {

    @Id
    private Long id; // same ID as User

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId // use the ID from User as the UserProfile ID
    @JoinColumn(name = "user_id")
    private User user;

    private String bio;
    private String photoUrl;

    // getters and setters...
}

Querying — JPQL and the Criteria API #

JPQL (Jakarta Persistence Query Language) #

JPQL is similar to SQL but operates on Java entities, not database tables.

import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;

public class ProductRepository {

    private final EntityManager em;

    public ProductRepository(EntityManager em) {
        this.em = em;
    }

    // Basic query
    public java.util.List<Product> allActiveProducts() {
        return em.createQuery(
            "SELECT p FROM Product p WHERE p.active = true ORDER BY p.name",
            Product.class
        ).getResultList();
    }

    // Query with parameters — ALWAYS use named parameters, not string concatenation
    public java.util.Optional<Product> findByName(String name) {
        // ✗ ANTI-PATTERN: string concatenation — vulnerable to SQL injection
        // em.createQuery("SELECT p FROM Product p WHERE p.name = '" + name + "'");

        // ✓ CORRECT: named parameter
        return em.createQuery(
                "SELECT p FROM Product p WHERE lower(p.name) = lower(:name)",
                Product.class)
            .setParameter("name", name)
            .getResultStream()
            .findFirst();
    }

    // Query with JOIN FETCH — the solution to the N+1 problem
    public java.util.List<Product> allWithCategory() {
        return em.createQuery(
            // JOIN FETCH loads the Category in the same query
            "SELECT p FROM Product p JOIN FETCH p.category c ORDER BY c.name, p.name",
            Product.class
        ).getResultList();
    }

    // Pagination
    public java.util.List<Product> withPagination(int page, int pageSize) {
        return em.createQuery("SELECT p FROM Product p ORDER BY p.id", Product.class)
            .setFirstResult(page * pageSize)
            .setMaxResults(pageSize)
            .getResultList();
    }

    // Aggregations
    public long countActiveProducts() {
        return em.createQuery(
            "SELECT COUNT(p) FROM Product p WHERE p.active = true",
            Long.class
        ).getSingleResult();
    }

    public java.math.BigDecimal averagePrice() {
        return em.createQuery(
            "SELECT AVG(p.price) FROM Product p WHERE p.active = true",
            java.math.BigDecimal.class
        ).getSingleResult();
    }

    // Named Query — defined on the entity, can be cached
    // (add to the Product class: @NamedQuery(name = "Product.active", query = "..."))
    public java.util.List<Product> allActiveViaNamedQuery() {
        return em.createNamedQuery("Product.active", Product.class).getResultList();
    }

    // Bulk Update/Delete — more efficient than loading entities one by one
    public int deactivateOutOfStock() {
        return em.createQuery(
            "UPDATE Product p SET p.active = false WHERE p.stock = 0"
        ).executeUpdate();
    }
}

The Criteria API #

The Criteria API enables type-safe queries built programmatically — useful for dynamic queries where WHERE conditions vary depending on input.

import jakarta.persistence.criteria.*;

public class ProductCriteriaRepository {

    private final EntityManager em;

    public ProductCriteriaRepository(EntityManager em) {
        this.em = em;
    }

    // Dynamic query based on available filters
    public java.util.List<Product> searchDynamic(String name, BigDecimal minPrice,
                                                  BigDecimal maxPrice, Boolean active) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Product> cq = cb.createQuery(Product.class);
        Root<Product> root = cq.from(Product.class);

        java.util.List<Predicate> predicates = new java.util.ArrayList<>();

        // Add conditions only if the parameter isn't null
        if (name != null && !name.isBlank()) {
            predicates.add(cb.like(cb.lower(root.get("name")),
                "%" + name.toLowerCase() + "%"));
        }
        if (minPrice != null) {
            predicates.add(cb.greaterThanOrEqualTo(root.get("price"), minPrice));
        }
        if (maxPrice != null) {
            predicates.add(cb.lessThanOrEqualTo(root.get("price"), maxPrice));
        }
        if (active != null) {
            predicates.add(cb.equal(root.get("active"), active));
        }

        cq.where(predicates.toArray(new Predicate[0]));
        cq.orderBy(cb.asc(root.get("name")));

        return em.createQuery(cq).getResultList();
    }
}

The N+1 Query Problem #

N+1 is Hibernate’s most common performance problem. It happens when you load N entities, then run 1 additional query per entity to load a relationship — N+1 queries total to the database.

public void n1ProblemExample(EntityManager em) {

    // ✗ ANTI-PATTERN: the N+1 problem
    // Query 1: SELECT * FROM categories → returns 50 categories
    java.util.List<Category> allCategories = em.createQuery(
        "SELECT c FROM Category c", Category.class
    ).getResultList();

    for (Category c : allCategories) {
        // Queries 2..51: SELECT * FROM products WHERE category_id = ?
        // (lazy loading triggers a new query for EVERY category)
        System.out.println(c.getName() + ": " + c.getProducts().size() + " products");
    }
    // Total: 1 + 50 = 51 queries!
}

public void n1SolutionJoinFetch(EntityManager em) {

    // ✓ SOLUTION 1: JOIN FETCH — load everything at once in 1 query
    java.util.List<Category> categories = em.createQuery(
        "SELECT DISTINCT c FROM Category c LEFT JOIN FETCH c.products",
        Category.class
    ).getResultList();
    // Total: 1 query with a JOIN

    for (Category c : categories) {
        // products are already loaded — no additional queries
        System.out.println(c.getName() + ": " + c.getProducts().size() + " products");
    }
}

public void n1SolutionEntityGraph(EntityManager em) {

    // ✓ SOLUTION 2: @EntityGraph — more flexible than JOIN FETCH
    jakarta.persistence.EntityGraph<Category> graph =
        em.createEntityGraph(Category.class);
    graph.addAttributeNodes("products"); // load the "products" attribute at once

    java.util.List<Category> categories = em.createQuery(
        "SELECT c FROM Category c", Category.class)
        .setHint("jakarta.persistence.loadgraph", graph)
        .getResultList();
}

public void n1SolutionBatchSize(EntityManager em) {
    // ✓ SOLUTION 3: @BatchSize on the entity — load in batches, not one by one
    // Add to the entity: @BatchSize(size = 20) above the collection
    // Hibernate loads 20 categories at once when lazy loading is triggered
    // Reduces from N+1 to N/20 + 1 queries
}
flowchart TD
    subgraph N1[The N+1 Problem]
        Q1[Query 1: SELECT categories] --> R1[50 categories]
        R1 --> Q2[Query 2: products of category-1]
        R1 --> Q3[Query 3: products of category-2]
        R1 --> QN[... Query 51: products of category-50]
        style Q2 stroke:#ef4444,stroke-width:2px
        style Q3 stroke:#ef4444,stroke-width:2px
        style QN stroke:#ef4444,stroke-width:2px
    end

    subgraph SOLVED[The JOIN FETCH Solution]
        QJ[Query 1: SELECT c JOIN FETCH c.products] --> RJ["50 categories\n+ all products"]
        style QJ stroke:#22c55e,stroke-width:2px
    end

Transactions and Session Management #

import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityTransaction;
import jakarta.persistence.Persistence;

public class TransactionDemo {

    private final EntityManagerFactory emf;

    public TransactionDemo() {
        this.emf = Persistence.createEntityManagerFactory("onlinestore");
    }

    // The standard pattern for write operations with transactions
    public <T> T runInTransaction(java.util.function.Function<EntityManager, T> operation) {
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        try {
            tx.begin();
            T result = operation.apply(em);
            tx.commit();
            return result;
        } catch (Exception e) {
            if (tx.isActive()) {
                tx.rollback();
            }
            throw new RuntimeException("Transaction failed: " + e.getMessage(), e);
        } finally {
            em.close(); // REQUIRED — don't leak EntityManagers
        }
    }

    // Usage example
    public Product saveProduct(String name, BigDecimal price) {
        return runInTransaction(em -> {
            Product product = new Product();
            product.setName(name);
            product.setPrice(price);
            em.persist(product);
            return product;
        });
    }

    // Stock transfer between products — both in one transaction
    public void transferStock(Long fromId, Long toId, int amount) {
        runInTransaction(em -> {
            Product from = em.find(Product.class, fromId);
            Product to = em.find(Product.class, toId);

            if (from == null || to == null) {
                throw new IllegalArgumentException("Product not found");
            }
            if (from.getStock() < amount) {
                throw new IllegalStateException("Insufficient stock");
            }

            from.setStock(from.getStock() - amount);
            to.setStock(to.getStock() + amount);
            // No need for em.merge() — the entities are managed, dirty checking works automatically

            return null;
        });
    }
}

The Second-Level Cache #

Hibernate supports two cache levels. The first-level cache (per Session) is always active. The second-level cache (across Sessions) requires a provider like Ehcache or Redis:

<!-- Add to pom.xml -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
    <version>6.5.2.Final</version>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.10.8</version>
    <classifier>jakarta</classifier>
</dependency>
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

// Enable the second-level cache for this entity
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
// READ_ONLY     → for data that never changes (fastest)
// READ_WRITE    → for data that changes occasionally (safe, slight overhead)
// NONSTRICT_READ_WRITE → data that rarely changes, can be temporarily stale
@Table(name = "categories")
public class Category {
    // ...

    @OneToMany(mappedBy = "category", fetch = FetchType.LAZY)
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // cache collections too
    private java.util.List<Product> products;
}
# persistence.xml — enable the second-level cache
<property name="hibernate.cache.use_second_level_cache" value="true"/>
<property name="hibernate.cache.use_query_cache" value="true"/>
<property name="hibernate.cache.region.factory_class"
          value="org.hibernate.cache.jcache.JCacheRegionFactory"/>
<property name="hibernate.javax.cache.provider"
          value="org.ehcache.jsr107.EhcacheCachingProvider"/>

Batch Operations — Mass Inserts and Updates #

To save or update thousands of records, do it in batches so you don’t run out of memory:

public void saveInBulk(EntityManager em, java.util.List<Product> newProducts) {
    int batchSize = 25; // must match hibernate.jdbc.batch_size in the config

    EntityTransaction tx = em.getTransaction();
    tx.begin();

    try {
        for (int i = 0; i < newProducts.size(); i++) {
            em.persist(newProducts.get(i));

            // Flush and clear every batch — prevents the persistence context from ballooning
            if ((i + 1) % batchSize == 0) {
                em.flush();  // send the SQL to the database
                em.clear();  // remove all entities from the cache — free memory
            }
        }
        em.flush(); // flush the last remaining batch
        tx.commit();

    } catch (Exception e) {
        if (tx.isActive()) tx.rollback();
        throw e;
    }

    System.out.printf("Successfully saved %d products in batches%n", newProducts.size());
}

When to Use Hibernate and When Not To #

USE HIBERNATE WHEN:
  ✓ A complex domain model with many relationships between entities
  ✓ You want CRUD productivity without writing manual SQL for every operation
  ✓ The database schema evolves often — lazy loading and dirty checking help a lot
  ✓ You need portability across databases (PostgreSQL, MySQL, Oracle, etc.)
  ✓ You're already using Spring Boot or Quarkus — Hibernate is already integrated
  ✓ You need query caching and second-level caching without manual implementation

CONSIDER ALTERNATIVES WHEN:
  ✗ Queries are very complex and database-specific → jOOQ or raw JDBC is more appropriate
  ✗ Mass insert/update performance is critical → JDBC batch or PostgreSQL COPY is faster
  ✗ The schema is already tightly controlled and stable → JDBC template is more predictable
  ✗ The team is more comfortable with SQL than JPQL → jOOQ offers type-safe SQL
  ✗ Small microservices with one or two tables → Hibernate's overhead isn't worth it

Summary #

  • The persistence context is Hibernate’s heart — entities loaded in one Session are automatically watched. Changes to managed entity fields automatically produce UPDATE SQL on flush, without explicitly calling save() or update().
  • Entity states to understand: Transient (new, unknown to Hibernate), Persistent (in a session, watched), Detached (session closed), and Removed (marked for deletion). Mistaking states causes changes to not be saved.
  • Lazy loading is the safe default — collections (@OneToMany, @ManyToMany) aren’t loaded unless accessed. Use JOIN FETCH or @EntityGraph when you know you’ll access a relationship, to avoid N+1 queries.
  • The N+1 problem is Hibernate’s main performance enemy — always detect it by enabling hibernate.show_sql=true in development and checking the number of queries generated per request.
  • Use GenerationType.SEQUENCE with a large allocationSize (50-100) instead of IDENTITY for mass inserts — IDENTITY forces a flush per insert, preventing batching.
  • Batch inserts require em.flush() + em.clear() every N records — without clear, the persistence context keeps growing and eventually causes an OutOfMemoryError.
  • The second-level cache is very effective for rarely-changing data (reference tables, configuration, master data) — the same entity from different requests doesn’t need to be re-queried from the database.
  • Don’t use @ManyToMany for join tables with additional attributes — create an intermediary entity with two @ManyToOne relationships, which is far more flexible and can store data like item quantity or price at transaction time.

← Previous: Quarkus   Next: Selenium →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact