Hibernate & JPA Interview Guide: From Entities to Performance

ยท21 min read
hibernatejpajavadatabaseorminterview-preparation

Every Java backend application persists data. And in the Java ecosystem, that usually means JPA with Hibernate. The ORM abstraction lets you think in objects rather than SQL tables - but that abstraction can bite you when you don't understand what's happening underneath.

Interviewers know this. They'll ask about lazy loading, then probe whether you've actually debugged a LazyInitializationException in production. They'll ask about the N+1 problem, expecting you to describe how you've identified and fixed it. Surface-level JPA knowledge crumbles under these questions.

This guide covers JPA and Hibernate from fundamentals to performance optimization - the depth interviewers expect from serious Java backend developers.


JPA Fundamentals

JPA is a specification; Hibernate is the most popular implementation. Understanding this distinction matters for interview discussions.

The JPA Specification

JPA defines:

  • Annotations for mapping objects to tables (@Entity, @Table, @Column)
  • EntityManager interface for persistence operations
  • JPQL query language
  • Transaction management integration
  • Lifecycle callbacks

Hibernate implements all of this, plus adds proprietary features. When possible, stick to JPA annotations for portability - but know that most projects use Hibernate-specific features.

Entity Mapping Basics

@Entity
@Table(name = "users")
public class User {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(name = "email", nullable = false, unique = true)
    private String email;
 
    @Column(name = "full_name", length = 100)
    private String fullName;
 
    @Enumerated(EnumType.STRING)
    @Column(name = "status")
    private UserStatus status;
 
    @Column(name = "created_at")
    private LocalDateTime createdAt;
 
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
 
    // Getters and setters required by JPA
}

Key annotations:

AnnotationPurpose
@EntityMarks class as JPA entity
@TableSpecifies table name (defaults to class name)
@IdMarks primary key field
@GeneratedValueAuto-generation strategy for ID
@ColumnColumn mapping and constraints
@EnumeratedEnum storage (STRING or ORDINAL)
@TemporalDate/time type (legacy, use java.time)
@TransientExclude field from persistence

ID Generation Strategies

// IDENTITY: Database auto-increment (MySQL, PostgreSQL SERIAL)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Pros: Simple, database handles it
// Cons: Batch inserts less efficient (needs round-trip for each ID)
 
// SEQUENCE: Database sequence (PostgreSQL, Oracle)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "user_sequence", allocationSize = 50)
private Long id;
// Pros: Batch-friendly (can pre-allocate IDs)
// Cons: Not all databases support sequences
 
// TABLE: Simulated sequence using a table
@GeneratedValue(strategy = GenerationType.TABLE)
private Long id;
// Pros: Portable across databases
// Cons: Performance overhead, potential contention
 
// AUTO: Let Hibernate decide based on database
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
// Hibernate picks the best strategy for your database

Interview question: "Why might IDENTITY strategy hurt batch insert performance?"

With IDENTITY, the database generates the ID during INSERT. Hibernate needs that ID immediately to manage the entity, so it can't batch multiple INSERTs into a single statement - each insert requires a round-trip to get the generated ID.

EntityManager and Persistence Context

The EntityManager is your interface to JPA:

@Repository
public class UserRepository {
 
    @PersistenceContext
    private EntityManager em;
 
    public User save(User user) {
        if (user.getId() == null) {
            em.persist(user);  // INSERT - entity becomes managed
            return user;
        } else {
            return em.merge(user);  // UPDATE - returns managed copy
        }
    }
 
    public User findById(Long id) {
        return em.find(User.class, id);  // Returns managed entity or null
    }
 
    public void delete(User user) {
        em.remove(em.contains(user) ? user : em.merge(user));
    }
}

The persistence context is a first-level cache that tracks entities:

@Transactional
public void demonstratePersistenceContext() {
    // Load user - entity is now "managed"
    User user1 = em.find(User.class, 1L);
 
    // Load same user again - returns SAME INSTANCE (cached)
    User user2 = em.find(User.class, 1L);
    assert user1 == user2;  // true - same object reference
 
    // Changes to managed entity are tracked
    user1.setEmail("new@example.com");
    // No explicit save needed - dirty checking detects the change
    // At transaction commit, UPDATE is executed automatically
}

Entity States

Understanding entity lifecycle states is crucial:

    +--------+     persist()    +---------+
    |  NEW   | ---------------> | MANAGED |
    +--------+                  +---------+
                                   |  ^
                          remove() |  | merge()
                                   v  |
                               +---------+
                               | REMOVED |
                               +---------+

    Transaction ends / clear()
           |
           v
    +----------+
    | DETACHED |
    +----------+
// NEW - just created, not associated with persistence context
User user = new User();
user.setEmail("test@example.com");
 
// MANAGED - associated with persistence context, changes tracked
em.persist(user);  // Now managed
 
// DETACHED - was managed, but persistence context closed
// (e.g., after transaction ends or em.detach())
User detachedUser = user;  // After transaction commits
 
// Re-attach with merge() - returns a NEW managed instance
User managedAgain = em.merge(detachedUser);
assert managedAgain != detachedUser;  // Different objects!
 
// REMOVED - scheduled for deletion
em.remove(managedAgain);

Interview question: "What happens if you modify a detached entity?"

Nothing automatically. Detached entities aren't tracked by the persistence context. You must explicitly call merge() to reattach and persist changes. This is a common source of bugs - developers modify entities after the transaction ends, expecting changes to save.


Entity Relationships

Relationships are where JPA complexity explodes. Getting them right is essential.

Relationship Types

@ManyToOne - Many children to one parent (most common):

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)  // Default is EAGER - override!
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
}

@OneToMany - One parent to many children:

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Order> orders = new ArrayList<>();
 
    // Helper methods for bidirectional consistency
    public void addOrder(Order order) {
        orders.add(order);
        order.setUser(this);
    }
 
    public void removeOrder(Order order) {
        orders.remove(order);
        order.setUser(null);
    }
}

@OneToOne - One-to-one relationship:

@Entity
public class User {
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private UserProfile profile;
}
 
@Entity
public class UserProfile {
    @Id
    private Long id;  // Shared primary key
 
    @OneToOne
    @MapsId  // Uses User's ID as this entity's ID
    @JoinColumn(name = "user_id")
    private User user;
}

@ManyToMany - Many-to-many with join table:

@Entity
public class Student {
    @ManyToMany
    @JoinTable(
        name = "student_courses",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private Set<Course> courses = new HashSet<>();
}
 
@Entity
public class Course {
    @ManyToMany(mappedBy = "courses")
    private Set<Student> students = new HashSet<>();
}

Bidirectional vs Unidirectional

Interview question: "When would you use a unidirectional relationship?"

Unidirectional - Only one side knows about the relationship:

// Only Order knows about User
@Entity
public class Order {
    @ManyToOne
    private User user;
}
 
// User doesn't have orders collection
@Entity
public class User {
    // No @OneToMany orders
}

Use unidirectional when:

  • You rarely navigate from parent to children
  • The child collection would be huge
  • You want simpler entity design

Bidirectional - Both sides know about the relationship:

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}
 
@Entity
public class Order {
    @ManyToOne
    private User user;
}

Use bidirectional when:

  • You frequently need to navigate both directions
  • You need cascade operations from parent
  • The collection size is manageable

The "mappedBy" Attribute

mappedBy indicates the owning side of a bidirectional relationship:

// User side - inverse (not owning)
@OneToMany(mappedBy = "user")  // "user" refers to Order.user field
private List<Order> orders;
 
// Order side - owning (has the foreign key)
@ManyToOne
@JoinColumn(name = "user_id")  // This table has the FK column
private User user;

The owning side controls the relationship in the database. Setting the relationship on the inverse side alone won't persist it:

// WRONG - won't create the relationship in DB
user.getOrders().add(order);
// The FK column in orders table won't be set!
 
// RIGHT - set on owning side
order.setUser(user);
// FK column now populated
 
// BEST - set both sides for object consistency
order.setUser(user);
user.getOrders().add(order);

Cascade Types

Cascade propagates operations from parent to children:

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Order> orders;
Cascade TypePropagates
PERSISTpersist() - insert children with parent
MERGEmerge() - update children with parent
REMOVEremove() - delete children with parent
REFRESHrefresh() - reload children with parent
DETACHdetach() - detach children with parent
ALLAll of the above

OrphanRemoval - Delete children removed from collection:

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders;
 
// With orphanRemoval = true
user.getOrders().remove(order);
// Order is deleted from database, not just unlinked

Interview question: "What's the difference between CascadeType.REMOVE and orphanRemoval?"

CascadeType.REMOVE deletes children when the parent is deleted. orphanRemoval deletes children when they're removed from the parent's collection, even if the parent isn't deleted. OrphanRemoval is stricter - the child can't exist without the parent.


Querying

JPA provides multiple ways to query data. Know when to use each.

JPQL (Java Persistence Query Language)

JPQL queries entities, not tables:

// Basic query
String jpql = "SELECT u FROM User u WHERE u.status = :status";
List<User> users = em.createQuery(jpql, User.class)
    .setParameter("status", UserStatus.ACTIVE)
    .getResultList();
 
// Join query
String jpql = """
    SELECT o FROM Order o
    JOIN o.user u
    WHERE u.email = :email
    AND o.status = :status
    ORDER BY o.createdAt DESC
    """;
 
// Aggregate functions
String jpql = "SELECT COUNT(o), SUM(o.total) FROM Order o WHERE o.user.id = :userId";
Object[] result = em.createQuery(jpql, Object[].class)
    .setParameter("userId", userId)
    .getSingleResult();
Long count = (Long) result[0];
BigDecimal total = (BigDecimal) result[1];

Named Queries

Define queries on the entity for reuse:

@Entity
@NamedQueries({
    @NamedQuery(
        name = "User.findByStatus",
        query = "SELECT u FROM User u WHERE u.status = :status"
    ),
    @NamedQuery(
        name = "User.findByEmailDomain",
        query = "SELECT u FROM User u WHERE u.email LIKE :domain"
    )
})
public class User {
    // ...
}
 
// Usage
List<User> users = em.createNamedQuery("User.findByStatus", User.class)
    .setParameter("status", UserStatus.ACTIVE)
    .getResultList();

Criteria API

Type-safe queries built programmatically:

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);
 
// Build predicates dynamically
List<Predicate> predicates = new ArrayList<>();
 
if (status != null) {
    predicates.add(cb.equal(user.get("status"), status));
}
if (email != null) {
    predicates.add(cb.like(user.get("email"), "%" + email + "%"));
}
if (minAge != null) {
    predicates.add(cb.greaterThanOrEqualTo(user.get("age"), minAge));
}
 
cq.where(predicates.toArray(new Predicate[0]));
cq.orderBy(cb.desc(user.get("createdAt")));
 
List<User> results = em.createQuery(cq).getResultList();

With Metamodel (compile-time type safety):

// Generated metamodel class User_
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);
 
cq.where(cb.equal(user.get(User_.status), status));  // Type-safe!
// User_.status is a generated constant, not a string

Native Queries

When you need raw SQL:

// Native SQL query
String sql = """
    SELECT u.* FROM users u
    WHERE u.created_at >= NOW() - INTERVAL '30 days'
    AND u.status = 'ACTIVE'
    ORDER BY u.created_at DESC
    LIMIT 100
    """;
 
List<User> users = em.createNativeQuery(sql, User.class)
    .getResultList();
 
// With result mapping for projections
@SqlResultSetMapping(
    name = "UserSummaryMapping",
    classes = @ConstructorResult(
        targetClass = UserSummary.class,
        columns = {
            @ColumnResult(name = "id", type = Long.class),
            @ColumnResult(name = "email", type = String.class),
            @ColumnResult(name = "order_count", type = Long.class)
        }
    )
)
 
String sql = """
    SELECT u.id, u.email, COUNT(o.id) as order_count
    FROM users u
    LEFT JOIN orders o ON o.user_id = u.id
    GROUP BY u.id, u.email
    """;
 
List<UserSummary> summaries = em.createNativeQuery(sql, "UserSummaryMapping")
    .getResultList();

Projections

Return only what you need:

// DTO projection with JPQL
String jpql = """
    SELECT new com.example.UserDTO(u.id, u.email, u.fullName)
    FROM User u
    WHERE u.status = :status
    """;
 
List<UserDTO> dtos = em.createQuery(jpql, UserDTO.class)
    .setParameter("status", UserStatus.ACTIVE)
    .getResultList();
 
// Interface projection (Spring Data JPA)
public interface UserEmailProjection {
    Long getId();
    String getEmail();
}
 
@Query("SELECT u.id as id, u.email as email FROM User u WHERE u.status = :status")
List<UserEmailProjection> findEmailsByStatus(@Param("status") UserStatus status);

Interview question: "When would you use a projection instead of fetching full entities?"

Use projections for:

  • Read-only data (reports, lists, dropdowns)
  • When you need only a few fields from large entities
  • API responses that don't expose all entity fields
  • Performance optimization (less data transferred)

Fetch full entities when:

  • You need to modify and save them
  • You need related entities via lazy loading
  • Business logic requires the full object

Fetching Strategies

Fetching strategy determines when and how related entities load. Getting this wrong causes the most common JPA performance issues.

Lazy vs Eager Loading

// LAZY - loads when accessed
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders;
// SQL: SELECT * FROM users WHERE id = ?
// orders not loaded yet
 
user.getOrders().size();  // NOW triggers query
// SQL: SELECT * FROM orders WHERE user_id = ?
 
// EAGER - loads immediately with parent
@ManyToOne(fetch = FetchType.EAGER)
private User user;
// SQL: SELECT * FROM orders o JOIN users u ON o.user_id = u.id WHERE o.id = ?
// user loaded with order

Default fetch types:

RelationshipDefaultReason
@ManyToOneEAGERUsually need the parent
@OneToOneEAGERUsually need the related entity
@OneToManyLAZYCollection could be huge
@ManyToManyLAZYCollection could be huge

Best practice: Make everything LAZY, then explicitly fetch what you need.

@ManyToOne(fetch = FetchType.LAZY)  // Override default EAGER
@JoinColumn(name = "user_id")
private User user;

The N+1 Problem

The most common JPA performance issue:

// This code has N+1 problem
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
    .getResultList();
// SQL: SELECT * FROM orders (1 query)
 
for (Order order : orders) {
    System.out.println(order.getUser().getEmail());
    // SQL: SELECT * FROM users WHERE id = ? (N queries!)
}
// Total: 1 + N queries

If you load 100 orders, you execute 101 queries. With 1000 orders, 1001 queries.

JOIN FETCH

Solve N+1 with JOIN FETCH:

String jpql = "SELECT o FROM Order o JOIN FETCH o.user";
List<Order> orders = em.createQuery(jpql, Order.class)
    .getResultList();
// SQL: SELECT o.*, u.* FROM orders o JOIN users u ON o.user_id = u.id
// Single query, users loaded with orders
 
for (Order order : orders) {
    System.out.println(order.getUser().getEmail());  // No additional query
}

Multiple JOIN FETCH:

// Fetch multiple relationships
String jpql = """
    SELECT o FROM Order o
    JOIN FETCH o.user
    JOIN FETCH o.items
    WHERE o.status = :status
    """;
 
// WARNING: Multiple collection fetches can cause cartesian product
// If order has 3 items and 2 payments:
// JOIN FETCH o.items JOIN FETCH o.payments
// Returns 6 rows per order (3 * 2), duplicating data

@EntityGraph

Declarative fetching without modifying queries:

@Entity
@NamedEntityGraph(
    name = "Order.withUserAndItems",
    attributeNodes = {
        @NamedAttributeNode("user"),
        @NamedAttributeNode("items")
    }
)
public class Order {
    // ...
}
 
// Usage with EntityManager
EntityGraph<?> graph = em.getEntityGraph("Order.withUserAndItems");
Map<String, Object> hints = Map.of("javax.persistence.fetchgraph", graph);
Order order = em.find(Order.class, orderId, hints);
 
// Usage with Spring Data JPA
@EntityGraph(value = "Order.withUserAndItems")
List<Order> findByStatus(OrderStatus status);
 
// Ad-hoc entity graph
@EntityGraph(attributePaths = {"user", "items"})
List<Order> findByUserId(Long userId);

Batch Fetching

Reduce N+1 without changing queries:

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 25)  // Hibernate-specific
    private List<Order> orders;
}
 
// Without @BatchSize: 100 users = 100 queries for orders
// With @BatchSize(25): 100 users = 4 queries (25 users per query)
// SQL: SELECT * FROM orders WHERE user_id IN (?, ?, ?, ... 25 params)

Global batch size in configuration:

spring.jpa.properties.hibernate.default_batch_fetch_size=25

Interview question: "How do you identify N+1 problems in an existing application?"

  1. Enable SQL logging: spring.jpa.show-sql=true or logging.level.org.hibernate.SQL=DEBUG
  2. Watch for repeated similar queries
  3. Use tools like datasource-proxy to count queries per request
  4. Profile with Hibernate statistics: hibernate.generate_statistics=true
  5. Test with realistic data volumes - N+1 often invisible with 5 records, obvious with 500

Transactions & Locking

JPA transactions integrate with Spring's @Transactional. Locking prevents concurrent modification issues.

@Transactional Basics

@Service
public class OrderService {
 
    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(request);
        return orderRepository.save(order);
        // Transaction commits automatically on method success
        // Rolls back on RuntimeException
    }
 
    @Transactional(readOnly = true)
    public List<Order> findOrders(OrderCriteria criteria) {
        // readOnly hint enables optimizations:
        // - No dirty checking
        // - Possible replica routing
        return orderRepository.findByCriteria(criteria);
    }
 
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAuditEvent(AuditEvent event) {
        // Runs in NEW transaction
        // Commits even if caller's transaction rolls back
        auditRepository.save(event);
    }
}

Propagation types:

TypeBehavior
REQUIRED (default)Join existing or create new
REQUIRES_NEWAlways create new, suspend existing
SUPPORTSJoin if exists, non-transactional otherwise
NOT_SUPPORTEDSuspend existing, run non-transactional
MANDATORYMust have existing, throw if none
NEVERMust not have existing, throw if present
NESTEDNested transaction with savepoint

Optimistic Locking

Assumes conflicts are rare, checks at commit time:

@Entity
public class Product {
    @Id
    private Long id;
 
    @Version  // Optimistic lock column
    private Long version;
 
    private String name;
    private BigDecimal price;
    private Integer quantity;
}

How it works:

// Transaction 1
Product p1 = em.find(Product.class, 1L);  // version = 5
p1.setQuantity(p1.getQuantity() - 1);
 
// Transaction 2 (concurrent)
Product p2 = em.find(Product.class, 1L);  // version = 5
p2.setQuantity(p2.getQuantity() - 1);
 
// Transaction 1 commits first
// SQL: UPDATE products SET quantity = ?, version = 6 WHERE id = 1 AND version = 5
// Success! Version incremented to 6
 
// Transaction 2 tries to commit
// SQL: UPDATE products SET quantity = ?, version = 6 WHERE id = 1 AND version = 5
// Fails! Version is now 6, not 5
// Throws OptimisticLockException

Handle the exception:

@Transactional
public void updateQuantity(Long productId, int delta) {
    try {
        Product product = productRepository.findById(productId).orElseThrow();
        product.setQuantity(product.getQuantity() + delta);
    } catch (OptimisticLockException e) {
        // Retry or inform user
        throw new ConcurrentModificationException("Product was modified by another user");
    }
}

Pessimistic Locking

Acquires database locks immediately:

// Lock for update
Product product = em.find(Product.class, 1L, LockModeType.PESSIMISTIC_WRITE);
// SQL: SELECT * FROM products WHERE id = 1 FOR UPDATE
 
// Other transactions block until this transaction commits
 
// With Spring Data JPA
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);

Lock modes:

ModeSQLUse Case
PESSIMISTIC_READFOR SHAREAllow concurrent reads, block writes
PESSIMISTIC_WRITEFOR UPDATEBlock all access
PESSIMISTIC_FORCE_INCREMENTFOR UPDATE + version incrementForce version update

Interview question: "When would you use pessimistic over optimistic locking?"

Pessimistic locking for:

  • High contention (many concurrent updates to same rows)
  • Short transactions where lock time is minimal
  • When conflict resolution is complex/expensive
  • Financial transactions requiring serialization

Optimistic locking for:

  • Low contention (conflicts rare)
  • Long-running transactions (don't hold locks)
  • Web applications (users may abandon sessions)
  • When retry on conflict is acceptable

Isolation Levels

@Transactional(isolation = Isolation.READ_COMMITTED)
public void process() {
    // ...
}
LevelDirty ReadsNon-Repeatable ReadsPhantom Reads
READ_UNCOMMITTEDYesYesYes
READ_COMMITTEDNoYesYes
REPEATABLE_READNoNoYes
SERIALIZABLENoNoNo

Most applications use READ_COMMITTED (the default for most databases) and handle edge cases with explicit locking.


Performance Optimization

JPA can be fast or painfully slow. These optimizations make the difference.

First-Level Cache (Persistence Context)

The persistence context is automatic - same entity loaded twice returns the same instance:

@Transactional
public void demonstrateFirstLevelCache() {
    User user1 = em.find(User.class, 1L);  // Database query
    User user2 = em.find(User.class, 1L);  // Cache hit, no query
 
    assert user1 == user2;  // Same instance
}

Be aware of memory with large result sets:

// Problem: Loading 100k entities fills the persistence context
List<User> users = userRepository.findAll();  // 100k entities in memory
 
// Solution 1: Clear periodically
int batchSize = 1000;
for (int i = 0; i < users.size(); i++) {
    processUser(users.get(i));
    if (i % batchSize == 0) {
        em.flush();
        em.clear();  // Detach all entities, free memory
    }
}
 
// Solution 2: Stateless session (Hibernate-specific)
Session session = em.unwrap(Session.class);
StatelessSession stateless = session.getSessionFactory().openStatelessSession();
// No persistence context, no caching, no dirty checking

Second-Level Cache

Shared cache across sessions/transactions:

// Enable in configuration
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
 
// Mark entity as cacheable
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
    // ...
}

Cache strategies:

StrategyUse Case
READ_ONLYImmutable data (countries, config)
READ_WRITERead-mostly, occasional updates
NONSTRICT_READ_WRITEEventual consistency OK
TRANSACTIONALFull transaction support (JTA required)

Query Cache

Cache query results (use sparingly):

spring.jpa.properties.hibernate.cache.use_query_cache=true
 
// In query
List<Product> products = em.createQuery("SELECT p FROM Product p WHERE p.category = :cat", Product.class)
    .setParameter("cat", category)
    .setHint("org.hibernate.cacheable", true)
    .getResultList();

Query cache is invalidated when ANY entity of the queried type changes - often counterproductive.

Batch Operations

JDBC batching for bulk inserts/updates:

// Configuration
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
 
// Code
@Transactional
public void batchInsert(List<Product> products) {
    for (int i = 0; i < products.size(); i++) {
        em.persist(products.get(i));
        if (i % 50 == 0) {
            em.flush();
            em.clear();
        }
    }
}

Bulk updates bypass entity management:

// JPA bulk update - much faster than loading entities
int updated = em.createQuery("""
    UPDATE Product p SET p.price = p.price * 1.1
    WHERE p.category = :category
    """)
    .setParameter("category", category)
    .executeUpdate();
 
// WARNING: Bypasses persistence context and cache
// Managed entities may have stale data
em.clear();  // Clear after bulk operations

Connection Pooling

HikariCP is the default with Spring Boot:

spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.connection-timeout=20000
spring.datasource.hikari.max-lifetime=1200000

Common Pitfalls

These issues appear constantly in real applications and interviews.

LazyInitializationException

@Transactional
public User getUser(Long id) {
    return userRepository.findById(id).orElseThrow();
}
 
// Calling code
User user = userService.getUser(1L);
// Transaction ended, session closed
 
user.getOrders().size();  // LazyInitializationException!
// Can't load orders - no session

Solutions:

// Solution 1: Fetch eagerly in the query
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
 
// Solution 2: @EntityGraph
@EntityGraph(attributePaths = {"orders"})
Optional<User> findById(Long id);
 
// Solution 3: Keep transaction open longer (carefully!)
@Transactional(readOnly = true)
public UserDTO getUserWithOrders(Long id) {
    User user = userRepository.findById(id).orElseThrow();
    // Access orders within transaction
    return new UserDTO(user, user.getOrders());
}
 
// Solution 4: DTO projection (best for read-only)
@Query("SELECT new com.example.UserDTO(u.id, u.email) FROM User u WHERE u.id = :id")
Optional<UserDTO> findDtoById(@Param("id") Long id);

Cascade Gotchas

// Problem: Unexpected deletions
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders;
 
user.setOrders(newOrdersList);  // Old orders deleted!
 
// Problem: CascadeType.REMOVE deletes more than expected
@ManyToOne(cascade = CascadeType.ALL)  // Don't cascade REMOVE on ManyToOne!
private User user;
 
orderRepository.delete(order);  // Deletes the User too!
 
// Best practice: Be explicit about cascades
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Order> orders;

equals() and hashCode()

Entities need proper equals/hashCode for collections:

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    // WRONG: Using database ID
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order)) return false;
        Order order = (Order) o;
        return Objects.equals(id, order.id);
    }
    // Problem: id is null before persist, breaks Sets
 
    // BETTER: Use business key
    @Column(unique = true)
    private String orderNumber;
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order)) return false;
        Order order = (Order) o;
        return Objects.equals(orderNumber, order.orderNumber);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(orderNumber);
    }
}

Detached Entity Merge Issues

// Problem: Merge can lose changes
Order detached = getDetachedOrder();
detached.setStatus(OrderStatus.SHIPPED);
 
// Meanwhile, another transaction changed the order
Order managed = orderRepository.findById(detached.getId()).orElseThrow();
managed.setTotal(newTotal);
orderRepository.save(managed);
 
// Now merge the detached - overwrites the total change!
orderRepository.save(detached);  // merge() inside
 
// Solution: Load, update specific fields
@Transactional
public void updateStatus(Long orderId, OrderStatus status) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.setStatus(status);
    // Only status changes, other fields untouched
}

Related Resources

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides