ORM basics
Object-Relational Mapping (ORM) lets object models map to relational tables. This approach reduces boilerplate CRUD code and centralizes mapping rules in annotations or XML.
- What it is: A bridge between Java objects (classes, fields) and database structures (tables, columns).
- Why it helps: Saves writing repetitive SQL, keeps domain logic in Java, and makes refactoring safer because mappings are declared once.
- How it works: You annotate classes (e.g.,
@Entity
) and fields (e.g., @Id
). Hibernate translates object operations (persist, find) into SQL under the hood.
- Compared to plain JDBC: JDBC needs you to write SQL and map ResultSet rows to objects yourself; ORM automates that mapping.
@Entity
@Table(name = "customers")
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// getters/setters
}
Configuration
Configuration supplies the datasource, dialect, and mapping metadata. In plain Hibernate, this lives in properties or XML; with Spring Boot, application properties typically drive setup.
- Datasource: Where the database lives (URL, username, password).
- Dialect: Tells Hibernate which SQL flavor to generate (e.g., PostgreSQL vs MySQL).
- DDL strategy:
hibernate.hbm2ddl.auto
controls schema creation/validation. Beginners often use update
for local development and validate
for production.
- Logging:
show_sql
and format_sql
help you learn what SQL is being executed.
// hibernate.properties (example)
hibernate.connection.url=jdbc:postgresql://localhost:5432/app
hibernate.connection.username=app
hibernate.connection.password=secret
hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
hibernate.hbm2ddl.auto=validate
hibernate.show_sql=false
hibernate.format_sql=true
Session and SessionFactory
The SessionFactory is a heavyweight, thread-safe factory created once per database. A Session is a lightweight, single-threaded unit of work tied to a conversation with the database.
- What to remember: One SessionFactory per DB; open a Session per request/use-case and close it when done.
- Why separate: SessionFactory builds metadata and caches; Session tracks entities you load/change.
- Beginner tip: Don’t share a Session across threads; it’s not thread-safe.
try (SessionFactory sf = new Configuration().configure().buildSessionFactory()) {
try (Session session = sf.openSession()) {
// use session
}
}
Transactions
Transactions ensure atomicity and consistency. A pattern seen in plain Hibernate wraps work in a transaction boundary and rolls back on errors.
- What: A transaction groups changes so they all succeed or all fail (no half-saved state).
- Why: Protects data integrity when multiple operations must go together.
- How: Begin, do your work, commit. On exceptions, roll back.
- Beginner tip: Avoid relying on auto-commit; always use explicit transactions for writes.
Transaction tx = null;
try (Session session = sf.openSession()) {
tx = session.beginTransaction();
session.persist(new Customer());
tx.commit();
} catch (RuntimeException e) {
if (tx != null && tx.getStatus().canRollback()) tx.rollback();
throw e;
}
Persistent object states
Entities flow through states: transient (not associated), persistent (managed in a Session), detached (no longer managed), and removed. Understanding these states clarifies when SQL executes and how identity is tracked.
- Transient → Persistent: Call
persist
or load via find/get
; Hibernate starts tracking changes.
- Persistent → Detached: Close/clear the Session or end the transaction; the object still exists but is not tracked.
- Persistent → Removed: Call
remove
; deletion happens at flush/commit.
- Beginner tip: Accessing LAZY fields after the Session is closed causes LazyInitializationException. Fetch what you need before closing.
Mappings
Mappings define how classes relate to tables and how associations are represented. JPA annotations are commonly used with Hibernate as provider.
- Primary keys:
@GeneratedValue
strategies include IDENTITY, SEQUENCE, and AUTO. Choose one supported by your DB.
- Relationships:
@ManyToOne
, @OneToMany
, @OneToOne
, @ManyToMany
. The owning side owns the foreign key.
- Cascading:
cascade = CascadeType.ALL
(or subsets) makes operations propagate to children (e.g., save order and items together).
- Orphan removal:
orphanRemoval = true
deletes child rows removed from a collection.
- Beginner tip: Avoid unbounded
@OneToMany
fetching on large tables; prefer paging or separate queries.
@Entity
class Order {
@Id @GeneratedValue Long id;
@ManyToOne(fetch = FetchType.LAZY) Customer customer; // many orders belong to one customer
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
List<OrderItem> items = new ArrayList<>();
}
@Entity
class OrderItem {
@Id @GeneratedValue Long id;
@ManyToOne(fetch = FetchType.LAZY) Order order; // owning side is OrderItem (FK lives here)
}
Fetching strategies
Fetching can be eager or lazy. Lazy loading defers association queries until needed, which reduces upfront cost but may cause N+1 issues if not handled with fetch joins or batch fetching.
- LAZY (recommended): Loads associations on access, which avoids loading everything up front.
- EAGER (use carefully): Loads associations immediately; can explode into lots of joins and data.
- N+1 problem: One query for parents, then N separate queries for children. Fix with fetch joins or batch size.
- Tools: HQL/JPQL
join fetch
, JPA Entity Graphs, @BatchSize
to reduce round-trips.
// HQL with fetch join to avoid N+1
select o from Order o join fetch o.items where o.id = :id
Querying (HQL, Criteria, Native SQL)
Queries can use HQL/JPQL for entity-centric querying, the Criteria API for type safety, or native SQL for advanced database features.
- HQL/JPQL: Write queries over entities/fields, not tables/columns. Bind parameters to avoid SQL injection.
- Criteria API: Builder-style, type-safe queries, helpful for dynamic filters.
- Native SQL: Useful for DB-specific features or complex reports; map results manually or to DTOs.
- Beginner tip: Always use named/positional parameters (
:name
) instead of string concatenation.
// HQL/JPQL
List<Order> recent = session.createQuery(
"select o from Order o where o.createdAt > :cutoff order by o.createdAt desc",
Order.class).setParameter("cutoff", cutoff).getResultList();
// Criteria API
var cb = session.getCriteriaBuilder();
var cq = cb.createQuery(Customer.class);
var root = cq.from(Customer.class);
cq.select(root).where(cb.like(root.get("name"), "%Acme%"));
List<Customer> customers = session.createQuery(cq).getResultList();
// Native SQL
List<Object[]> rows = session.createNativeQuery("select id, name from customers where active = true")
.getResultList();
Caching (L1, L2, query cache)
The first-level cache lives in the Session and is always on. The second-level cache is optional and shared across sessions, reducing database round-trips for frequently read entities. A query cache can store result IDs for repeated queries.
- L1 cache (always on): Within one Session, the same entity is returned from memory without hitting the DB again.
- L2 cache (optional): Shared across Sessions, helpful for reference data (e.g., countries). Needs a cache provider (Ehcache, Caffeine).
- Query cache: Stores IDs of results for a specific query; still needs L2 cache to quickly resolve those IDs.
- Beginner tip: Start without L2/query cache. Add them only when you know read patterns and consistency needs.
// Example properties for L2 cache (Ehcache, Caffeine, etc.)
hibernate.cache.use_second_level_cache=true
hibernate.cache.use_query_cache=true
hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
Dirty checking and flushing
While in the persistent state, field changes are detected automatically and synchronized to the database during flush (on commit by default). Flush modes influence when synchronization occurs.
- Dirty checking: Hibernate compares current field values with original ones; changed entities are scheduled for update.
- Flush: Sends pending SQL to the DB. Default is before commit; can be earlier if needed by a query.
- Flush modes:
COMMIT
(safest for beginners), AUTO
(default), others for advanced cases.
- Beginner tip: Many small flushes inside loops hurt performance; batch and flush/clear periodically.
session.setHibernateFlushMode(FlushMode.COMMIT); // defer until commit
Customer c = session.get(Customer.class, 1L);
c.setEmail("new@example.com"); // no explicit update call needed
session.getTransaction().commit(); // triggers flush
Batch processing and performance
Bulk inserts/updates benefit from JDBC batching and careful session management. Tuning fetch sizes and minimizing N+1 queries typically yields large gains.
- JDBC batching: Group multiple INSERT/UPDATE statements to reduce round-trips.
- Session management: For large loops, flush and clear every N items to keep memory usage low.
- Reading performance: Use projections (DTO queries) when you don’t need full entities; set fetch size for streaming large results.
- Beginner tip: Start by fixing N+1 queries and adding indexes where filters occur often.
// Properties
hibernate.jdbc.batch_size=50
hibernate.order_inserts=true
hibernate.order_updates=true
// Example loop
try (Session session = sf.openSession()) {
Transaction tx = session.beginTransaction();
for (int i = 0; i < 1_000; i++) {
session.persist(new Customer());
if (i % 50 == 0) { session.flush(); session.clear(); }
}
tx.commit();
}
Spring + Hibernate integration
Spring Boot commonly layers Hibernate behind JPA, exposing an EntityManager (and a Hibernate Session if needed). Transactions are typically declarative with @Transactional
.
- JPA vs Hibernate: JPA is the standard API; Hibernate is a popular implementation. You code to JPA but can access Hibernate features when needed.
- EntityManager and Session:
EntityManager
is the JPA interface; you can unwrap a Hibernate Session
from it for provider-specific features.
- @Transactional: Opens a transaction and ties a Session to the thread for you, so LAZY fields can be accessed within the method safely.
- Beginner tip: Keep transactions short and service-layer scoped. Avoid doing network calls or long work while a transaction is open.
// build.gradle
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
runtimeOnly "org.postgresql:postgresql"
// application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/app
spring.datasource.username=app
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
// Service example
@Service
class CustomerService {
private final EntityManager em;
CustomerService(EntityManager em) { this.em = em; }
@Transactional
public Long create(String name, String email) {
var c = new Customer();
c.setName(name);
c.setEmail(email);
em.persist(c);
return c.getId();
}
}