Database ORM with JPA and Hibernate
Guidelines for effective Object-Relational Mapping using JPA and Hibernate in Spring Boot applications.
Overview
Object-Relational Mapping (ORM) bridges the impedance mismatch between object-oriented programming and relational databases. Instead of writing SQL queries manually, you work with Java objects (entities) and the ORM framework translates operations into SQL.
JPA (Jakarta Persistence API) is the specification defining how Java objects map to database tables. Hibernate is the most popular JPA implementation, providing the actual functionality. Spring Data JPA builds on top of JPA, adding repository abstractions and reducing boilerplate code.
This guide covers entity design, relationship mapping, repository patterns, and transaction management. For schema design principles, see Database Design. For schema evolution, see Database Migrations.
Core Principles
- Lazy Loading Default: Fetch related entities only when needed to avoid N+1 queries
- Constructor Injection: Use constructor-based entity creation for immutability where possible
- Explicit Transactions: Mark service methods with
@Transactionalboundaries - Read-Only Optimization: Use
@Transactional(readOnly = true)for queries - Avoid Bidirectional When Possible: Unidirectional relationships are simpler to maintain
- Entity ≠ DTO: Never expose entities directly in APIs; use DTOs for API contracts
- Version for Optimistic Locking: Prevent lost updates in concurrent scenarios
ORM Architecture
Key Components:
- Persistence Context: Hibernate's first-level cache (L1) that tracks all entities within a transaction. Repeated queries for the same entity return the cached instance.
- EntityManager: JPA interface for CRUD operations and queries. Spring Data JPA repositories delegate to the EntityManager.
- Query Translator: Converts JPQL/HQL queries into native SQL based on the database dialect.
- Connection Pool (HikariCP): Maintains a pool of reusable database connections for efficiency.
Connection Pooling
Database connections are expensive to create (authentication, network handshake, resource allocation). Connection pooling maintains a pool of reusable connections instead of creating new ones for each request.
HikariCP is the default and fastest connection pool for Spring Boot.
Configuration
spring:
datasource:
url: jdbc:postgresql://localhost:5432/appdb
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20 # Max total connections (default: 10)
minimum-idle: 5 # Min idle connections (default: same as max)
connection-timeout: 30000 # Max wait for connection in ms (default: 30s)
idle-timeout: 600000 # Max idle time before eviction (default: 10min)
max-lifetime: 1800000 # Max connection lifetime (default: 30min)
pool-name: HikariPool-App
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: validate # NEVER 'update' or 'create-drop' in production
show-sql: false # Use logging instead
properties:
hibernate:
format_sql: true
jdbc:
batch_size: 20 # Batch INSERT/UPDATE operations
order_inserts: true
order_updates: true
logging:
level:
org.hibernate.SQL: DEBUG # Log SQL statements
org.hibernate.type.descriptor.sql.BasicBinder: TRACE # Log parameters
Configuration Tuning:
-
maximum-pool-size: Set based on database capacity and application concurrency. Too low causes thread blocking; too high exhausts database resources. Start with
(core_count * 2) + effective_spindle_count(for HDDs) orcore_count * 4(for SSDs). -
minimum-idle: Keep connections warm to handle normal load. Setting equal to
maximum-pool-sizecreates a fixed-size pool (simpler, but less adaptive). -
connection-timeout: Maximum milliseconds to wait for a connection. If exceeded, throws
SQLTransientConnectionException. Balance user experience with not giving up too quickly. -
max-lifetime: Rotate connections to handle database-side timeouts and failovers. Should be shorter than database's
wait_timeout.
For production deployment considerations, see Spring Boot General.
Entity Design
Basic Entity
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, unique = true, length = 255)
private String email;
// Audit fields
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@Version
private Long version;
// No-args constructor required by JPA
protected Customer() {}
// All-args constructor for application use
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
// Getters and setters
}
Key Annotations:
@Entity: Marks this class as a JPA entity (mapped to database table)@Table(name = "..."): Specifies table name (optional if class name matches table)@Id: Marks the primary key field@GeneratedValue: Specifies how primary key values are generated@Column: Configures column mapping (nullability, length, uniqueness, etc.)@Version: Enables optimistic locking to prevent lost updates
Constructor Requirements: JPA requires a no-argument constructor (can be protected). Use an all-args constructor for application code to ensure entities are created in a valid state.
ID Generation Strategies
// UUID (distributed systems, no collisions)
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Auto-increment (simple, but collision risk in distributed scenarios)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Sequence (best for PostgreSQL, batching support)
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq")
@SequenceGenerator(
name = "customer_seq",
sequenceName = "customer_sequence",
allocationSize = 50 // Fetch 50 IDs at once for performance
)
private Long id;
Strategy Trade-offs:
- UUID: No collisions in distributed systems, but 16 bytes (vs 8 for Long), slightly slower joins and indexes
- IDENTITY: Database auto-increment, simple but prevents batch inserts (Hibernate must get ID immediately after insert)
- SEQUENCE: Best for PostgreSQL, supports batch inserts via
allocationSize(Hibernate pre-allocates IDs in memory)
Audit Fields with JPA Auditing
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@CreatedBy
@Column(nullable = false, updatable = false, length = 100)
private String createdBy;
@LastModifiedBy
@Column(nullable = false, length = 100)
private String lastModifiedBy;
@Version
private Long version;
}
Enable JPA Auditing in configuration:
@Configuration
@EnableJpaAuditing
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
// Get current user from security context
return () -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return Optional.of("SYSTEM");
}
return Optional.of(auth.getName());
};
}
}
This automatically populates createdBy and lastModifiedBy from the security context. For authentication patterns, see Security Authentication.
Enums
public enum OrderStatus {
PENDING,
PROCESSING,
COMPLETED,
CANCELLED
}
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// GOOD: Store as string (readable in database, safe when enum order changes)
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status;
// BAD: Store as ordinal (breaks when enum order changes)
// @Enumerated(EnumType.ORDINAL)
// private OrderStatus status;
}
Always use EnumType.STRING. EnumType.ORDINAL stores the enum's position (0, 1, 2...), which breaks if you reorder or insert enums in the middle. For database enum types, see Database Design.
Immutable Entities
@Entity
@Table(name = "audit_log")
@Immutable // Hibernate won't track changes or allow updates
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, updatable = false)
private String action;
@Column(nullable = false, updatable = false)
private LocalDateTime occurredAt;
// No setters - use constructor only
protected AuditLog() {}
public AuditLog(String action, LocalDateTime occurredAt) {
this.action = action;
this.occurredAt = occurredAt;
}
// Only getters
}
Use @Immutable for audit logs, events, or historical records that should never be modified after creation. Hibernate skips dirty checking for these entities, improving performance.
Relationship Mapping
One-to-Many / Many-to-One
// Parent entity (One side)
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Bidirectional relationship
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
// Helper methods to maintain bidirectional consistency
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this);
}
public void removeOrder(Order order) {
orders.remove(order);
order.setCustomer(null);
}
}
// Child entity (Many side - owns the relationship)
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Lazy loading by default for @ManyToOne
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
public void setCustomer(Customer customer) {
this.customer = customer;
}
}
Key Points:
mappedBy: Indicates the field in the child entity that owns the relationship. The child's foreign key column controls the relationship.cascade = CascadeType.ALL: Operations on parent (persist, merge, remove) cascade to childrenorphanRemoval = true: Delete child entities removed from the collection- Helper methods: Ensure both sides of the bidirectional relationship stay in sync
fetch = FetchType.LAZY: Load related entity only when accessed (default for@ManyToOne, but be explicit)
Fetch Strategies
// BAD: EAGER loading causes N+1 queries when loading multiple parents
@ManyToOne(fetch = FetchType.EAGER)
private Customer customer;
// GOOD: LAZY loading (default, but be explicit)
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
// Use JOIN FETCH in queries when you need related entities
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.id = :id")
Optional<Order> findByIdWithCustomer(@Param("id") UUID id);
Default Fetch Types:
@ManyToOne:LAZY(though some docs sayEAGER- always specify explicitly)@OneToOne:EAGER(often problematic, override toLAZY)@OneToMany:LAZY@ManyToMany:LAZY
Always use LAZY loading and fetch related entities explicitly when needed using JOIN FETCH or @EntityGraph.
Many-to-Many
// Owning side
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
public void enrollCourse(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void unenrollCourse(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
}
// Inverse side
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
// Getters and setters
}
Many-to-Many Best Practices:
- Use
Setinstead ofListto avoid duplicate join table entries - Don't use
CascadeType.REMOVE(would delete the other side when removing from collection) - Consider using an explicit join entity if you need attributes on the relationship:
// Explicit join entity with attributes
@Entity
@Table(name = "student_course")
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne
@JoinColumn(name = "course_id")
private Course course;
@Column(nullable = false)
private LocalDateTime enrolledAt;
private Integer grade;
}
This approach is more flexible and allows querying the relationship itself.
One-to-One
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// GOOD: Lazy loading, optional
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY, optional = true)
private UserProfile profile;
}
@Entity
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Owning side
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;
}
One-to-One Challenges: @OneToOne is tricky to lazy-load on the non-owning side (User.profile) because Hibernate must query to check if the related entity exists. Consider using @ManyToOne with unique constraint instead for better performance.
Repository Patterns
Spring Data JPA provides repository abstractions to reduce boilerplate:
@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
// Derived query methods (Spring generates implementation)
List<Order> findByCustomerId(UUID customerId);
Optional<Order> findByCustomerIdAndStatus(UUID customerId, OrderStatus status);
// Pagination and sorting
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
// Custom JPQL query
@Query("SELECT o FROM Order o WHERE o.customer.id = :customerId AND o.createdAt >= :since")
List<Order> findRecentOrders(
@Param("customerId") UUID customerId,
@Param("since") LocalDateTime since
);
// JOIN FETCH to avoid N+1 queries
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.id = :id")
Optional<Order> findByIdWithCustomer(@Param("id") UUID id);
// Native SQL for complex queries
@Query(value = """
SELECT DATE_TRUNC('day', created_at) as date, COUNT(*) as count
FROM orders
WHERE created_at >= :since
GROUP BY DATE_TRUNC('day', created_at)
ORDER BY date DESC
""", nativeQuery = true)
List<Object[]> getDailyOrderCounts(@Param("since") LocalDateTime since);
// Modifying query
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id = :id")
int updateStatus(@Param("id") UUID id, @Param("status") OrderStatus status);
}
Repository Methods:
- Derived Query Methods: Spring generates implementation from method name (e.g.,
findByCustomerIdAndStatus) @Querywith JPQL: Write queries in Java Persistence Query Language (object-oriented, database-agnostic)@QuerywithnativeQuery = true: Write native SQL (database-specific, but full SQL power)@Modifying: Required for UPDATE/DELETE queries (use with@Transactional)
Pagination Example:
@Service
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Page<Order> getOrders(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return orderRepository.findAll(pageable);
}
}
For testing repository methods, see Spring Boot Testing.
Transaction Management
Transactions ensure data consistency by grouping database operations into atomic units. Either all operations succeed, or all are rolled back.
Transaction Boundaries
@Service
@Transactional(readOnly = true) // Default for all methods
public class OrderService {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
public OrderService(OrderRepository orderRepository, CustomerRepository customerRepository) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
}
// GOOD: Write transaction explicitly marked
@Transactional
public Order createOrder(CreateOrderRequest request) {
Customer customer = customerRepository.findById(request.getCustomerId())
.orElseThrow(() -> new CustomerNotFoundException(request.getCustomerId()));
Order order = new Order(customer, request.getAmount());
order.setStatus(OrderStatus.PENDING);
return orderRepository.save(order);
}
// Read-only transaction (inherits from class level)
public Order getOrder(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// GOOD: Explicit read-only for clarity
@Transactional(readOnly = true)
public List<Order> getCustomerOrders(UUID customerId) {
return orderRepository.findByCustomerId(customerId);
}
}
Transaction Best Practices:
- Service Layer: Place
@Transactionalon service methods, not controllers or repositories - Read-Only Default: Set
@Transactional(readOnly = true)at class level, override with@Transactionalfor writes - Keep Transactions Short: Long transactions hold database locks and connections
- Don't Call External APIs: Inside transactions (network calls can fail, timeout, or delay commits)
Isolation Levels
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processRefund(UUID orderId, BigDecimal amount) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.getStatus() != OrderStatus.COMPLETED) {
throw new IllegalStateException("Cannot refund non-completed order");
}
order.setStatus(OrderStatus.REFUNDED);
orderRepository.save(order);
}
Isolation Levels (from weakest to strongest):
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ_UNCOMMITTED | Yes | Yes | Yes |
| READ_COMMITTED (default) | No | Yes | Yes |
| REPEATABLE_READ | No | No | Yes |
| SERIALIZABLE | No | No | No |
- Dirty Read: Reading uncommitted changes from another transaction
- Non-Repeatable Read: Same query returns different results within transaction (another transaction modified data)
- Phantom Read: Same query returns different number of rows (another transaction inserted/deleted data)
PostgreSQL default is READ_COMMITTED. Use higher isolation levels (REPEATABLE_READ, SERIALIZABLE) for critical operations where consistency matters more than performance.
Optimistic Locking
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Version
private Long version; // Hibernate manages this automatically
// Other fields
}
When saving:
@Transactional
public void updateOrderStatus(UUID orderId, OrderStatus newStatus) {
try {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(newStatus);
orderRepository.save(order); // Throws OptimisticLockException if version changed
} catch (OptimisticLockException e) {
throw new ConcurrentModificationException("Order was modified by another user. Please refresh and try again.");
}
}
Hibernate generates SQL like:
UPDATE orders SET status = ?, version = version + 1
WHERE id = ? AND version = ?
If the version doesn't match (another transaction updated the row), the update affects 0 rows and Hibernate throws OptimisticLockException.
Rollback Rules
// Default: Rollback on RuntimeException, not checked exceptions
@Transactional
public void processOrder(UUID orderId) throws OrderProcessingException {
// Rolls back on RuntimeException
// Does NOT roll back on checked OrderProcessingException
}
// Rollback on specific checked exceptions
@Transactional(rollbackFor = OrderProcessingException.class)
public void processOrder(UUID orderId) throws OrderProcessingException {
// Rolls back on OrderProcessingException too
}
// Don't rollback on specific exceptions
@Transactional(noRollbackFor = NotFoundException.class)
public void processOrder(UUID orderId) {
// Won't rollback if NotFoundException is thrown
}
Avoiding N+1 Queries
The N+1 query problem occurs when you load a collection of entities (1 query) and then access related entities, triggering additional queries for each parent entity (N queries).
// BAD: N+1 queries
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
String customerName = order.getCustomer().getName(); // N queries (one per order)!
}
Solutions
1. JOIN FETCH:
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// Or for single result
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.id = :id")
Optional<Order> findByIdWithCustomer(@Param("id") UUID id);
2. @EntityGraph:
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
@EntityGraph(attributePaths = {"customer", "items"})
Optional<Order> findById(UUID id);
3. Batch Fetching (fetch multiple in one query):
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 10) // Fetch 10 customers at once
private Customer customer;
}
4. DTO Projection (best for read-only queries):
public interface OrderSummary {
UUID getId();
BigDecimal getAmount();
String getCustomerName();
}
@Query("SELECT o.id as id, o.amount as amount, c.name as customerName " +
"FROM Order o JOIN o.customer c")
List<OrderSummary> findAllSummaries();
For performance testing and profiling, see Spring Boot Testing and Performance Testing.
Query Optimization
Pagination
// Always paginate large result sets
Pageable pageable = PageRequest.of(
page,
size,
Sort.by("createdAt").descending()
);
Page<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING, pageable);
// Return API response
return new PageResponse<>(
orders.getContent(),
orders.getTotalElements(),
orders.getTotalPages(),
orders.getNumber()
);
Pagination prevents loading unbounded data into memory, which causes OutOfMemoryError and slow response times.
Batch Operations
@Transactional
public void createOrders(List<Order> orders) {
int batchSize = 50;
for (int i = 0; i < orders.size(); i++) {
orderRepository.save(orders.get(i));
// Flush and clear every batch
if (i % batchSize == 0 && i > 0) {
entityManager.flush();
entityManager.clear();
}
}
}
Batching reduces database round-trips by grouping multiple INSERT/UPDATE statements. Configure hibernate.jdbc.batch_size in properties.
Warning: @Modifying bulk updates bypass entity lifecycle:
// BAD: Bulk update doesn't trigger @PreUpdate, doesn't update version
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int updateStatus(@Param("ids") List<UUID> ids, @Param("status") OrderStatus status);
Read-Only Queries
@QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
List<Order> findAllReadOnly();
Read-only queries skip dirty checking (performance optimization) since you don't intend to modify entities.
Summary
Key Takeaways:
- ORM bridges objects and relational databases using JPA specifications and Hibernate implementation
- Connection pooling (HikariCP) reuses connections for efficiency
- Lazy loading is default for most relationships; use
JOIN FETCHor@EntityGraphto avoid N+1 queries - Transaction boundaries should be at the service layer, not controllers or repositories
- Optimistic locking (
@Version) prevents lost updates in concurrent scenarios - Always use
EnumType.STRINGfor enum mapping to avoid ordinal issues - Pagination is mandatory for large result sets to prevent memory issues
- Batch operations reduce database round-trips for bulk inserts/updates
@Transactional(readOnly = true)optimizes read-only queries by skipping dirty checking- DTO projections are best for read-only API responses; never expose entities directly
Related Guides:
- Database Design - Schema design, normalization, indexing, constraints
- Database Migrations - Flyway versioning and zero-downtime migrations
- Spring Boot Data Access - Spring-specific integration patterns
- Spring Boot Testing - Testing with TestContainers
- Security Authentication - Security context for audit fields