Skip to main content

JPA Best Practices

Overview

This comprehensive guide covers JPA and Hibernate best practices for the Ink platform, including entity design patterns, repository optimization, query performance, N+1 problem solutions, and transaction management.

Target Audience: Backend developers
Prerequisites: JPA/Hibernate basics, SQL knowledge
Estimated Time: 50-60 minutes

Prerequisites

JPA Architecture

Installation Steps

1. JPA Dependencies

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-60</artifactId>
<version>2.21.1</version>
</dependency>
</dependencies>
<!-- ...existing code... -->

2. JPA Configuration

# filepath: /Users/jetstart/dev/jetrev/ink/src/main/resources/application.yml
# ...existing code...
spring:
jpa:
hibernate:
ddl-auto: validate
naming:
physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
use_sql_comments: true
jdbc:
batch_size: 20
fetch_size: 50
order_inserts: true
order_updates: true
generate_statistics: false
query:
plan_cache_max_size: 2048
show-sql: false
open-in-view: false
# ...existing code...

Configuration

Entity Design Best Practices

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/entity/User.java
@Entity
@Table(
name = "users",
indexes = {
@Index(name = "idx_users_username", columnList = "username"),
@Index(name = "idx_users_email", columnList = "email")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_users_username", columnNames = "username"),
@UniqueConstraint(name = "uk_users_email", columnNames = "email")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(of = "id")
@ToString(exclude = {"password", "licenses", "subscriptions"})
public class User implements Serializable {

@Serial
private static final long serialVersionUID = 1L;

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

@Column(name = "username", nullable = false, unique = true, length = 50)
private String username;

@Column(name = "email", nullable = false, unique = true, length = 255)
private String email;

@Column(name = "password", nullable = false, length = 255)
private String password;

@Column(name = "first_name", length = 100)
private String firstName;

@Column(name = "last_name", length = 100)
private String lastName;

@Column(name = "active")
private boolean active = true;

// Use @ElementCollection for simple collections
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id")
)
@Column(name = "role", length = 50)
@BatchSize(size = 25)
private Set<String> roles = new HashSet<>();

// One-to-Many: LAZY by default (good)
@OneToMany(
mappedBy = "user",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@BatchSize(size = 25)
private List<License> licenses = new ArrayList<>();

@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

@Column(name = "updated_at", nullable = false)
private Instant updatedAt;

@Column(name = "deleted_at")
private Instant deletedAt;

// Optimistic locking
@Version
@Column(name = "version")
private Integer version;

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

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

// Helper methods for bidirectional relationships
public void addLicense(License license) {
licenses.add(license);
license.setUser(this);
}

public void removeLicense(License license) {
licenses.remove(license);
license.setUser(null);
}
}

Repository Interface Patterns

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/repository/UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {

// Query methods - use method name queries for simple cases
Optional<User> findByUsername(String username);

Optional<User> findByEmail(String email);

boolean existsByUsername(String username);

boolean existsByEmail(String email);

// Use @Query for complex queries
@Query("SELECT u FROM User u JOIN FETCH u.roles WHERE u.username = :username")
Optional<User> findByUsernameWithRoles(@Param("username") String username);

// Projection queries for better performance
@Query("SELECT u.id as id, u.username as username, u.email as email " +
"FROM User u WHERE u.active = true")
List<UserProjection> findAllActiveUsersProjection();

// Native queries when needed (use sparingly)
@Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)
List<User> findUsersCreatedAfter(@Param("date") Instant date);

// Pagination with custom query
@Query("SELECT u FROM User u WHERE u.active = :active")
Page<User> findByActive(@Param("active") boolean active, Pageable pageable);

// Modifying queries
@Modifying
@Query("UPDATE User u SET u.deletedAt = :deletedAt WHERE u.id = :id")
void softDelete(@Param("id") Long id, @Param("deletedAt") Instant deletedAt);

// Batch delete
@Modifying
@Query("DELETE FROM User u WHERE u.deletedAt IS NOT NULL AND u.deletedAt < :cutoffDate")
int permanentlyDeleteOldRecords(@Param("cutoffDate") Instant cutoffDate);
}

// Projection interface
interface UserProjection {
Long getId();
String getUsername();
String getEmail();
}

Solving the N+1 Problem

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/repository/LicenseRepository.java
@Repository
public interface LicenseRepository extends JpaRepository<License, Long> {

// ❌ BAD: Causes N+1 queries
List<License> findByUserId(Long userId);
// This will load licenses, then execute N queries to fetch each user

// ✅ GOOD: Use JOIN FETCH to load associations in one query
@Query("SELECT l FROM License l JOIN FETCH l.user WHERE l.user.id = :userId")
List<License> findByUserIdWithUser(@Param("userId") Long userId);

// ✅ GOOD: Fetch multiple associations
@Query("SELECT l FROM License l " +
"JOIN FETCH l.user " +
"JOIN FETCH l.product " +
"WHERE l.status = :status")
List<License> findByStatusWithUserAndProduct(@Param("status") String status);

// ✅ GOOD: Use @EntityGraph for flexible loading
@EntityGraph(attributePaths = {"user", "product", "activations"})
List<License> findByStatus(String status);

// ✅ GOOD: Define named entity graph
@EntityGraph(value = "License.withUserAndProduct")
Optional<License> findById(Long id);
}

// Entity with named graph
@Entity
@Table(name = "licenses")
@NamedEntityGraph(
name = "License.withUserAndProduct",
attributeNodes = {
@NamedAttributeNode("user"),
@NamedAttributeNode("product")
}
)
public class License {
// ...existing code...
}

Custom Repository Implementation

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/repository/CustomUserRepository.java
public interface CustomUserRepository {
List<User> findUsersWithCriteria(UserSearchCriteria criteria);
void batchUpdateStatus(List<Long> userIds, boolean active);
}

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/repository/CustomUserRepositoryImpl.java
@Repository
@RequiredArgsConstructor
public class CustomUserRepositoryImpl implements CustomUserRepository {

private final EntityManager entityManager;

@Override
public List<User> findUsersWithCriteria(UserSearchCriteria criteria) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);

List<Predicate> predicates = new ArrayList<>();

if (criteria.getUsername() != null) {
predicates.add(cb.like(
cb.lower(root.get("username")),
"%" + criteria.getUsername().toLowerCase() + "%"
));
}

if (criteria.getEmail() != null) {
predicates.add(cb.equal(root.get("email"), criteria.getEmail()));
}

if (criteria.getActive() != null) {
predicates.add(cb.equal(root.get("active"), criteria.getActive()));
}

if (criteria.getCreatedAfter() != null) {
predicates.add(cb.greaterThan(
root.get("createdAt"),
criteria.getCreatedAfter()
));
}

query.where(predicates.toArray(new Predicate[0]));
query.orderBy(cb.desc(root.get("createdAt")));

return entityManager.createQuery(query)
.setMaxResults(criteria.getMaxResults())
.getResultList();
}

@Override
@Transactional
public void batchUpdateStatus(List<Long> userIds, boolean active) {
// Batch update for better performance
entityManager.createQuery(
"UPDATE User u SET u.active = :active WHERE u.id IN :ids"
)
.setParameter("active", active)
.setParameter("ids", userIds)
.executeUpdate();
}
}

Usage Examples

Lazy vs Eager Loading

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/entity/Order.java
@Entity
@Table(name = "orders")
public class Order {

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

// ❌ AVOID: EAGER loading on collections
@OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
private List<OrderItem> items; // This loads ALL items every time

// ✅ GOOD: LAZY loading (default for @OneToMany)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 25) // Optimize batch loading
private List<OrderItem> items;

// ✅ GOOD: Use @ManyToOne EAGER only when needed and data is small
@ManyToOne(fetch = FetchType.LAZY) // LAZY is better default
@JoinColumn(name = "user_id")
private User user;

// Load associations explicitly when needed
public void loadItems() {
items.size(); // Force initialization
}
}

// Service layer example
@Service
@Transactional(readOnly = true)
public class OrderService {

@Autowired
private OrderRepository orderRepository;

public OrderDto getOrderWithItems(Long orderId) {
// Use JOIN FETCH or @EntityGraph
Order order = orderRepository.findByIdWithItems(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));

return orderMapper.toDto(order);
}
}

Batch Operations

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/UserBatchService.java
@Service
@RequiredArgsConstructor
public class UserBatchService {

private final UserRepository userRepository;
private final EntityManager entityManager;

@Transactional
public void batchInsertUsers(List<CreateUserRequest> requests) {
int batchSize = 20;

for (int i = 0; i < requests.size(); i++) {
User user = createUserFromRequest(requests.get(i));
entityManager.persist(user);

if (i > 0 && i % batchSize == 0) {
// Flush and clear to avoid memory issues
entityManager.flush();
entityManager.clear();
}
}
}

@Transactional
public void batchUpdateActiveStatus(List<Long> userIds, boolean active) {
// Use batch update query instead of loading entities
int updated = entityManager.createQuery(
"UPDATE User u SET u.active = :active, u.updatedAt = :now " +
"WHERE u.id IN :ids"
)
.setParameter("active", active)
.setParameter("now", Instant.now())
.setParameter("ids", userIds)
.executeUpdate();

log.info("Updated {} users", updated);
}

private User createUserFromRequest(CreateUserRequest request) {
// ...existing code...
return null;
}
}

Using Specifications for Dynamic Queries

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/specification/UserSpecification.java
public class UserSpecification {

public static Specification<User> hasUsername(String username) {
return (root, query, cb) -> username == null ?
cb.conjunction() :
cb.like(cb.lower(root.get("username")), "%" + username.toLowerCase() + "%");
}

public static Specification<User> hasEmail(String email) {
return (root, query, cb) -> email == null ?
cb.conjunction() :
cb.equal(root.get("email"), email);
}

public static Specification<User> isActive(Boolean active) {
return (root, query, cb) -> active == null ?
cb.conjunction() :
cb.equal(root.get("active"), active);
}

public static Specification<User> hasRole(String role) {
return (root, query, cb) -> {
if (role == null) return cb.conjunction();

Join<User, String> rolesJoin = root.join("roles");
return cb.equal(rolesJoin, role);
};
}

public static Specification<User> createdAfter(Instant date) {
return (root, query, cb) -> date == null ?
cb.conjunction() :
cb.greaterThan(root.get("createdAt"), date);
}

public static Specification<User> byCriteria(UserSearchCriteria criteria) {
return Specification
.where(hasUsername(criteria.getUsername()))
.and(hasEmail(criteria.getEmail()))
.and(isActive(criteria.getActive()))
.and(hasRole(criteria.getRole()))
.and(createdAfter(criteria.getCreatedAfter()));
}
}

// Usage in service
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;

public Page<UserDto> searchUsers(UserSearchCriteria criteria, Pageable pageable) {
Specification<User> spec = UserSpecification.byCriteria(criteria);
return userRepository.findAll(spec, pageable)
.map(userMapper::toDto);
}
}

Verification

Query Performance Testing

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/performance/QueryPerformanceTest.java
@SpringBootTest
@ActiveProfiles("test")
class QueryPerformanceTest {

@Autowired
private UserRepository userRepository;

@Autowired
private EntityManager entityManager;

@Test
@DisplayName("Should not have N+1 query problem")
void shouldNotHaveNPlusOneProblem() {
// Create test data
createTestUsers(10);

entityManager.clear();

// Enable query logging
Statistics stats = entityManager.getEntityManagerFactory()
.unwrap(SessionFactory.class)
.getStatistics();
stats.clear();
stats.setStatisticsEnabled(true);

// Execute query
List<User> users = userRepository.findAllWithRoles();
users.forEach(user -> user.getRoles().size()); // Access roles

// Verify number of queries
long queryCount = stats.getPrepareStatementCount();
assertThat(queryCount).isLessThanOrEqualTo(2); // 1 for users, 1 for roles batch

stats.setStatisticsEnabled(false);
}

private void createTestUsers(int count) {
for (int i = 0; i < count; i++) {
User user = User.builder()
.username("user" + i)
.email("user" + i + "@example.com")
.password("password")
.roles(Set.of("USER"))
.build();
entityManager.persist(user);
}
entityManager.flush();
}
}

Troubleshooting

Common JPA Issues

Issue: LazyInitializationException

// ❌ Problem: Accessing lazy collection outside transaction
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
// Transaction ends here
user.getRoles().size(); // LazyInitializationException!
return userMapper.toDto(user);
}

// ✅ Solution 1: Fetch eagerly when needed
@GetMapping("/{id}")
@Transactional(readOnly = true)
public UserDto getUser(@PathVariable Long id) {
User user = userRepository.findByIdWithRoles(id).orElseThrow();
return userMapper.toDto(user);
}

// ✅ Solution 2: Use DTO projection
@Query("SELECT new com.jetrev.ink.dto.UserDto(u.id, u.username, u.email) " +
"FROM User u WHERE u.id = :id")
Optional<UserDto> findDtoById(@Param("id") Long id);

Issue: Slow Queries

-- Enable query logging to identify slow queries
-- application.yml
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.stat: DEBUG

Issue: Optimistic Locking Failures

@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;

@Transactional
@Retryable(
value = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
public UserDto updateUser(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id).orElseThrow();

// Update fields
user.setEmail(request.getEmail());
user.setFirstName(request.getFirstName());

User updated = userRepository.save(user);
return userMapper.toDto(updated);
}
}

Code Examples

DTO Projection with Constructor

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/dto/UserSummaryDto.java
@Data
@AllArgsConstructor
public class UserSummaryDto {
private Long id;
private String username;
private String email;
private boolean active;
}

// Repository with constructor projection
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

@Query("SELECT new com.jetrev.ink.dto.UserSummaryDto(" +
"u.id, u.username, u.email, u.active) " +
"FROM User u WHERE u.active = true")
List<UserSummaryDto> findAllActiveSummaries();
}

Second Level Cache Configuration

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/CacheConfig.java
@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("users", "products", "licenses");
}
}

// Entity with caching
@Entity
@Table(name = "products")
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
// ...existing code...
}

Best Practices

  1. Always Use LAZY Loading: Default to LAZY, use JOIN FETCH when needed
  2. Avoid N+1 Queries: Use JOIN FETCH or @EntityGraph
  3. Use Batch Operations: Enable batch inserts/updates for bulk operations
  4. Optimize Queries: Use projections for read-only operations
  5. Enable Statistics: Monitor query performance in development
  6. Use Read-Only Transactions: Mark read operations as readOnly = true
  7. Avoid open-in-view: Disable Spring's open-in-view pattern
  8. Use Specifications: For complex dynamic queries
  9. Proper Indexing: Ensure database indexes match query patterns
  10. Version Control: Use @Version for optimistic locking

Performance Optimization

Query Hints

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

@QueryHints({
@QueryHint(name = "org.hibernate.cacheable", value = "true"),
@QueryHint(name = "org.hibernate.cacheMode", value = "NORMAL")
})
List<User> findByActive(boolean active);

@QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
List<User> findAllForReport();
}

Pagination Best Practices

@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;

@Transactional(readOnly = true)
public Page<UserDto> getUsers(Pageable pageable) {
// Use count query optimization
return userRepository.findAll(pageable)
.map(userMapper::toDto);
}

@Transactional(readOnly = true)
public Slice<UserDto> getUsersSlice(Pageable pageable) {
// Use Slice when you don't need total count
return userRepository.findAllBy(pageable)
.map(userMapper::toDto);
}
}

Additional Resources


Next Steps: Continue with Angular Development Guide for frontend development patterns.