Spring Boot
Hibernate Basics
Hibernate is the most widely used Java ORM (Object-Relational Mapping) framework and the default JPA provider in Spring Boot. It maps Java classes to database tables, translates JPQL and Criteria queries to SQL, manages the persistence context, and handles transactions. Understanding Hibernate's core concepts — entities, the session, the persistence context, and the SQL it generates — is essential for building correct and performant data access layers.
What Hibernate Does
Hibernate solves the object-relational impedance mismatch — the structural difference between Java's object graph model and a relational database's table model. Without an ORM you write JDBC boilerplate: open a connection, prepare a statement, map ResultSet rows to objects, handle transactions, close resources. Hibernate handles all of this.
The four things Hibernate provides: schema generation or validation (DDL), object-to-row mapping (DML — INSERT, UPDATE, DELETE), query translation (JPQL and Criteria API → SQL), and the persistence context (identity map, dirty checking, lazy loading).
Hibernate implements the JPA (Jakarta Persistence API) specification. JPA defines the standard annotations (@Entity, @Id, @OneToMany) and interfaces (EntityManager, EntityManagerFactory). Hibernate is the engine behind these interfaces in Spring Boot. You interact with JPA annotations and Spring Data repositories; Hibernate does the work.
Auto-Configuration in Spring Boot
Adding spring-boot-starter-data-jpa pulls in Hibernate, auto-configures an EntityManagerFactory, a JpaTransactionManager, and Spring Data JPA repositories. The only required configuration is the datasource and the DDL strategy.
XML
<!-- pom.xml: -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Add your database driver — MySQL example: -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
# ── application.yml — minimum required configuration: ────────────────
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate # validate schema against entities at startup
show-sql: false # log generated SQL (use true in dev)
open-in-view: false # disable OSIV — important for REST APIs
properties:
hibernate:
format_sql: true # pretty-print SQL when show-sql is true
dialect: org.hibernate.dialect.MySQL8Dialect
# ── ddl-auto values: ──────────────────────────────────────────────────
# none — do nothing (use for externally managed schemas)
# validate — verify entities match the schema; fail if not (recommended for prod)
# update — alter the schema to match entities (never use in prod)
# create — drop and recreate schema on startup (dev/test only)
# create-drop — create on startup, drop on shutdown (test only)Entities and the @Entity Annotation
A Hibernate entity is a Java class mapped to a database table. The class must be annotated with @Entity, have a no-argument constructor (public or protected), and declare a field annotated with @Id. Every other field is mapped to a column with the same name by default.
Java
@Entity
@Table(name = "users",
uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class User {
// ── Primary key ───────────────────────────────────────────────────
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT
private Long id;
// ── Basic columns ─────────────────────────────────────────────────
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Enumerated(EnumType.STRING) // store enum name, not ordinal
@Column(nullable = false, length = 20)
private Role role = Role.USER;
@Column(columnDefinition = "TEXT")
private String bio;
// ── Audit timestamps ──────────────────────────────────────────────
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
// ── Lifecycle callbacks: ──────────────────────────────────────────
@PrePersist
void onCreate() { createdAt = updatedAt = LocalDateTime.now(); }
@PreUpdate
void onUpdate() { updatedAt = LocalDateTime.now(); }
// ── Required: no-arg constructor (may be protected): ─────────────
protected User() { }
public User(String name, String email) {
this.name = name;
this.email = email;
}
public enum Role { USER, ADMIN, MODERATOR }
// Getters (and setters for mutable fields):
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}Primary Key Generation Strategies
@GeneratedValue controls how Hibernate generates primary key values. The strategy choice affects database portability, INSERT performance, and whether IDs are assigned before or after the INSERT.
Java
// ── IDENTITY — database auto-increment (most common for MySQL, PostgreSQL):
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Hibernate must execute the INSERT to get the ID.
// Disables JDBC batch inserts (each INSERT is separate).
// Best for: most applications — simple, widely supported.
// ── SEQUENCE — database sequence (recommended for PostgreSQL, Oracle):
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "user_seq")
@SequenceGenerator(name = "user_seq",
sequenceName = "users_id_seq",
allocationSize = 50) // fetch 50 IDs at once — batching friendly
private Long id;
// IDs are fetched from the sequence before INSERT — enables batch inserts.
// allocationSize > 1 reduces round trips but gaps appear in IDs.
// Best for: high-insert-volume applications, PostgreSQL, Oracle.
// ── AUTO — Hibernate picks IDENTITY or SEQUENCE based on the dialect:
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
// Portable but less predictable — Hibernate may choose a table-based
// sequence (hibernate_sequence) which requires DDL privileges.
// Avoid in production — be explicit with IDENTITY or SEQUENCE.
// ── UUID — application-assigned UUID (no DB dependency):
@Id
@GeneratedValue(strategy = GenerationType.UUID) // Hibernate 6+
private UUID id;
// ID is assigned before INSERT — fully portable, no DB sequence needed.
// Larger index size; random UUIDs cause index fragmentation on insert.
// Best for: distributed systems, APIs that expose IDs publicly.
// ── Manually assigned — application sets the ID:
@Id
private String code; // no @GeneratedValue — application sets code before saveThe Persistence Context
The persistence context is Hibernate's first-level cache and change tracker. It is the core concept that distinguishes Hibernate from plain JDBC. Every entity loaded within a persistence context is tracked — Hibernate compares its current state to its snapshot at load time and generates UPDATE statements for changed fields automatically at flush time.
Java
// ── Persistence context lifecycle: ────────────────────────────────────
// In Spring: one persistence context per @Transactional method by default.
// The EntityManager is the Java API for interacting with the context.
@Service
@Transactional
public class UserService {
@PersistenceContext
private EntityManager em; // each transaction gets its own context
public void demonstratePersistenceContext() {
// ── MANAGED state: entity is in the persistence context ───────
User user = em.find(User.class, 1L);
// user is now MANAGED — Hibernate tracks every field
user.setName("New Name");
// No save() call needed — Hibernate detects the change at flush time
// and generates: UPDATE users SET name = 'New Name' WHERE id = 1
// This is called "dirty checking"
// ── TRANSIENT state: new entity, not yet persisted ────────────
User newUser = new User("Alice", "alice@example.com");
// newUser is TRANSIENT — not in the persistence context
em.persist(newUser);
// newUser is now MANAGED — Hibernate will INSERT it at flush time
// ── DETACHED state: was managed, context closed or explicitly detached
em.detach(user);
user.setName("Another Name");
// Change is NOT tracked — no UPDATE will be generated
// To re-attach and track: em.merge(user)
// ── REMOVED state: ────────────────────────────────────────────
User toDelete = em.find(User.class, 2L);
em.remove(toDelete);
// DELETE generated at flush time
}
}
// ── Identity map — same ID returns the same instance within a context: ─
User a = em.find(User.class, 1L);
User b = em.find(User.class, 1L);
assert a == b; // true — Hibernate returns the same object from the cache
// only one SELECT is executed, not twoHibernate Configuration Properties
Hibernate's behaviour is controlled through spring.jpa.properties.hibernate.* in application.yml. These map directly to Hibernate's own configuration keys. The most important ones for production control SQL logging, batching, statistics, and schema validation.
yaml
spring:
jpa:
show-sql: false # log generated SQL to stdout (dev only)
open-in-view: false # CRITICAL — disable for REST APIs (see below)
hibernate:
ddl-auto: validate # prod: validate | dev: create-drop
properties:
hibernate:
# ── Dialect — tells Hibernate which SQL dialect to generate: ───
dialect: org.hibernate.dialect.MySQL8Dialect
# PostgreSQL: org.hibernate.dialect.PostgreSQLDialect
# H2: org.hibernate.dialect.H2Dialect
# Hibernate 6 auto-detects dialect — explicit setting is optional
# ── SQL formatting: ───────────────────────────────────────────
format_sql: true # pretty-print SQL (only relevant if show-sql)
use_sql_comments: true # include JPQL as SQL comment
# ── Batching — improves INSERT/UPDATE throughput: ─────────────
jdbc:
batch_size: 25 # batch up to 25 statements
batch_versioned_data: true # batch UPDATE with @Version
# ── Second-level cache: ───────────────────────────────────────
cache:
use_second_level_cache: false # enable with a cache provider
use_query_cache: false
# ── Statistics — log query counts (useful for N+1 detection): ─
generate_statistics: false # set true to expose via Actuator/logs
# ── Fetch size — rows fetched per round trip: ─────────────────
jdbc:
fetch_size: 50
# ── Default batch fetch size — for lazy collections: ──────────
default_batch_fetch_size: 16
# ── open-in-view: false — why this matters for REST APIs: ─────────────
# open-in-view: true (Spring Boot default) holds the persistence context
# open for the entire HTTP request — including view rendering.
# This enables lazy loading outside @Transactional but causes:
# - Connections held longer than necessary
# - Hidden N+1 queries in templates or Jackson serialisation
# - Unpredictable transaction boundaries
# For REST APIs: always set open-in-view: false.
# Spring Boot logs a warning when it is true.SQL Logging and Debugging
Hibernate's generated SQL is rarely obvious from the Java code. Logging the SQL is essential during development to catch N+1 queries, missing indexes, and unexpected cartesian products.
yaml
# ── application.yml — enable SQL logging in dev: ─────────────────────
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
# show-sql logs to stdout (not the logging framework).
# For proper log-level control, use the logging.level approach:
logging:
level:
org.hibernate.SQL: DEBUG # log all SQL statements
org.hibernate.orm.jdbc.bind: TRACE # log bind parameter values (Hibernate 6)
org.hibernate.type.descriptor.sql: TRACE # Hibernate 5 bind parameters
# ── Example output with format_sql and use_sql_comments: ─────────────
# /* select u from User u where u.email = :email */
# select
# u1_0.id,
# u1_0.email,
# u1_0.name,
# u1_0.role,
# u1_0.created_at,
# u1_0.updated_at
# from
# users u1_0
# where
# u1_0.email=?
# binding parameter [1] as [VARCHAR] - [alice@example.com]
# ── Statistics — log query counts per request: ────────────────────────
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: DEBUG
# Logs at end of session:
# Session Metrics {
# 4359 nanoseconds spent acquiring 1 JDBC connections
# 1 flushes as part of EntityManager lifecycle
# 3 queries executed to database ← watch for unexpected counts
# }
# ── p6spy — logs actual SQL with real parameter values (not ?): ───────
# Add p6spy dependency and configure datasource URL:
# spring.datasource.url: jdbc:p6spy:mysql://localhost:3306/mydb
# spring.datasource.driver-class-name: com.p6spy.engine.spy.P6SpyDriverEntity Lifecycle Callbacks
JPA lifecycle callbacks are methods on the entity class that Hibernate invokes at defined points in the entity's life — before and after persist, update, remove, and load. They are the correct place for audit timestamps, default values, and invariant enforcement.
Java
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private BigDecimal total;
@Enumerated(EnumType.STRING)
private Status status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime deletedAt;
// ── @PrePersist — called before INSERT: ──────────────────────────
@PrePersist
void onPrePersist() {
createdAt = updatedAt = LocalDateTime.now();
if (status == null) status = Status.PENDING;
}
// ── @PreUpdate — called before UPDATE: ───────────────────────────
@PreUpdate
void onPreUpdate() {
updatedAt = LocalDateTime.now();
}
// ── @PostLoad — called after SELECT (entity loaded into context): ─
@PostLoad
void onPostLoad() {
// Recalculate transient fields after loading:
// this.displayName = name.toUpperCase();
}
// ── @PreRemove — called before DELETE: ───────────────────────────
@PreRemove
void onPreRemove() {
// Soft delete — mark deleted instead of removing:
this.deletedAt = LocalDateTime.now();
this.status = Status.CANCELLED;
}
// ── @PostPersist / @PostUpdate / @PostRemove — after the DML: ────
@PostPersist
void onPostPersist() {
// Fire domain event, update search index, etc.
// Note: transaction may not be committed yet at this point.
}
public enum Status { PENDING, PAID, SHIPPED, DELIVERED, CANCELLED }
}
// ── EntityListeners — extract callbacks to a separate class: ──────────
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class) // Spring Data auditing
public abstract class AuditableEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
// Enable Spring Data auditing:
@SpringBootApplication
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class MyApplication { }
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext()
.getAuthentication()).map(Authentication::getName);
}Common Hibernate Pitfalls
Hibernate's automatic behaviour — dirty checking, lazy loading, the persistence context — is powerful but produces subtle bugs when misunderstood. These are the most common issues and how to avoid them.
Java
// ── PITFALL 1: N+1 select problem ────────────────────────────────────
// Loading a list of orders and accessing each order's user triggers
// one SELECT for the orders + one SELECT per order for the user.
// WRONG — N+1 queries:
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> System.out.println(o.getUser().getName()));
// SELECT * FROM orders → 1 query
// SELECT * FROM users WHERE id=1 → 1 query per order
// CORRECT — JOIN FETCH in JPQL:
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
// SELECT o.*, u.* FROM orders o JOIN users u ON u.id = o.user_id → 1 query
// ── PITFALL 2: LazyInitializationException ────────────────────────────
// Accessing a lazy association outside a transaction throws:
// "could not initialize proxy - no Session"
// WRONG — lazy load outside @Transactional:
@GetMapping("/{id}")
public OrderResponse findById(@PathVariable Long id) {
Order order = orderService.findById(id); // transaction ends here
order.getUser().getName(); // LazyInitializationException!
}
// CORRECT — load everything you need inside the transaction:
@Transactional(readOnly = true)
public OrderResponse findById(Long id) {
Order order = orderRepository.findByIdWithUser(id) // JOIN FETCH
.orElseThrow(() -> new OrderNotFoundException(id));
return OrderResponse.from(order); // map to DTO while session is open
}
// ── PITFALL 3: Accidental updates from dirty checking ────────────────
// Mutating an entity inside a @Transactional method without intending
// to update the database still produces an UPDATE.
@Transactional
public UserResponse findById(Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setName(user.getName().toUpperCase()); // mutates a managed entity!
return UserResponse.from(user);
// Hibernate generates: UPDATE users SET name = 'ALICE' WHERE id = 1
// Even though no save() was called.
}
// CORRECT — use readOnly = true for read operations:
@Transactional(readOnly = true) // disables dirty checking
public UserResponse findById(Long id) {
User user = userRepository.findById(id).orElseThrow();
return UserResponse.from(user); // map to DTO — do not mutate the entity
}
// ── PITFALL 4: open-in-view anti-pattern ──────────────────────────────
// open-in-view: true keeps the session open during view rendering.
// Jackson serialises lazy collections → hidden SELECT queries.
// Always set: spring.jpa.open-in-view: false
// Map to DTOs in the service layer — never serialise entities directly.