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:
| Annotation | Purpose |
|---|---|
@Entity | Marks class as JPA entity |
@Table | Specifies table name (defaults to class name) |
@Id | Marks primary key field |
@GeneratedValue | Auto-generation strategy for ID |
@Column | Column mapping and constraints |
@Enumerated | Enum storage (STRING or ORDINAL) |
@Temporal | Date/time type (legacy, use java.time) |
@Transient | Exclude 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 databaseInterview 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 Type | Propagates |
|---|---|
| PERSIST | persist() - insert children with parent |
| MERGE | merge() - update children with parent |
| REMOVE | remove() - delete children with parent |
| REFRESH | refresh() - reload children with parent |
| DETACH | detach() - detach children with parent |
| ALL | All 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 unlinkedInterview 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 stringNative 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 orderDefault fetch types:
| Relationship | Default | Reason |
|---|---|---|
| @ManyToOne | EAGER | Usually need the parent |
| @OneToOne | EAGER | Usually need the related entity |
| @OneToMany | LAZY | Collection could be huge |
| @ManyToMany | LAZY | Collection 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 queriesIf 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=25Interview question: "How do you identify N+1 problems in an existing application?"
- Enable SQL logging:
spring.jpa.show-sql=trueorlogging.level.org.hibernate.SQL=DEBUG - Watch for repeated similar queries
- Use tools like datasource-proxy to count queries per request
- Profile with Hibernate statistics:
hibernate.generate_statistics=true - 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:
| Type | Behavior |
|---|---|
| REQUIRED (default) | Join existing or create new |
| REQUIRES_NEW | Always create new, suspend existing |
| SUPPORTS | Join if exists, non-transactional otherwise |
| NOT_SUPPORTED | Suspend existing, run non-transactional |
| MANDATORY | Must have existing, throw if none |
| NEVER | Must not have existing, throw if present |
| NESTED | Nested 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 OptimisticLockExceptionHandle 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:
| Mode | SQL | Use Case |
|---|---|---|
| PESSIMISTIC_READ | FOR SHARE | Allow concurrent reads, block writes |
| PESSIMISTIC_WRITE | FOR UPDATE | Block all access |
| PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE + version increment | Force 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() {
// ...
}| Level | Dirty Reads | Non-Repeatable Reads | Phantom Reads |
|---|---|---|---|
| READ_UNCOMMITTED | Yes | Yes | Yes |
| READ_COMMITTED | No | Yes | Yes |
| REPEATABLE_READ | No | No | Yes |
| SERIALIZABLE | No | No | No |
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 checkingSecond-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:
| Strategy | Use Case |
|---|---|
| READ_ONLY | Immutable data (countries, config) |
| READ_WRITE | Read-mostly, occasional updates |
| NONSTRICT_READ_WRITE | Eventual consistency OK |
| TRANSACTIONAL | Full 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 operationsConnection 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=1200000Common 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 sessionSolutions:
// 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
- Spring Boot Interview Guide - Spring Data JPA integration
- Java Core Interview Guide - Java fundamentals
- SQL Joins Interview Guide - Understanding the SQL JPA generates
- PostgreSQL & Node.js Interview Guide - Database concepts
