Every Java application that works with a database solves one problem: how to move data from the rows and columns of tables into Java objects — and back again. Let's look at why ORM was invented for this, who JPA and Hibernate are, and when this tool is worth using.
One call, down the stack and back. You declared a repository interface — Spring Data wrote the implementation. It calls EntityManager, which is only the standard, a set of interfaces; all the work is done by Hibernate: it builds the SELECT from the Order class mapping, hands it to JDBC, and spreads the returned row over the object's fields. A ResultSet never appears in your code.
What hurts without ORM: life with plain JDBC
Imagine a simple task: load an order from the database together with its lines.
String sql = "SELECT o.id, o.status, ol.product_id, ol.quantity " +
"FROM orders o JOIN order_lines ol ON ol.order_id = o.id " +
"WHERE o.id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, orderId);
ResultSet rs = ps.executeQuery();
Order order = null;
List<OrderLine> lines = new ArrayList<>();
while (rs.next()) {
if (order == null) {
order = new Order(rs.getLong("id"), rs.getString("status"));
}
lines.add(new OrderLine(rs.getLong("product_id"), rs.getInt("quantity")));
}
if (order != null) order.setLines(lines);
}
The code works, but there is almost nothing about business logic in it — only about shuffling data. Add null handling, several tables, updates — and the volume grows several times over. This is boilerplate: repetitive, mechanical code that is prone to errors when the schema changes.
Object-relational mapping (ORM) is an approach where the framework takes the data shuffling upon itself. You describe how a Java class corresponds to a table, and the framework generates SQL, runs queries, and assembles objects from the results.
Who is who: JPA, Hibernate, and Spring Data JPA
Three names that are often confused — let's untangle each one.
JPA (Jakarta Persistence API) is a standard, a specification. A set of interfaces and annotations (@Entity, @Id, @OneToMany, EntityManager) that describe how ORM should work in Java. JPA on its own does no actual work — it is only a contract.
The package in Jakarta EE / Spring Boot 3: jakarta.persistence (before Spring Boot 3 it was javax.persistence).
Hibernate is an implementation of JPA. It contains all the real code: the annotation parser, the SQL generator, connection management, caches. When a Spring Boot application runs a query through JPA, physically it is Hibernate doing the work. Hibernate also offers its own extensions on top of the standard (Session, HQL, specific annotations).
Spring Data JPA is a repository layer on top of JPA/Hibernate. It removes even the minimal code that would remain if you used EntityManager directly: you declare an interface — Spring generates the implementation.
| Layer, top to bottom | What it does |
|---|---|
| your code | calls a repository method and gets an object back |
| Spring Data JPA | repositories, findBy* methods |
JPA / EntityManager | the standard API: interfaces and annotations |
| Hibernate | the implementation: SQL generation, caches, session |
| JDBC | driver and connection |
| database | tables and rows |
What an Entity is and how it looks
An Entity is a Java class that Hibernate maps to a database table. A minimal example:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "status")
private String status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderLine> lines = new ArrayList<>();
// constructors, getters, setters
}
Hibernate reads the annotations and knows: Order → table orders, field id → column id with auto-increment, collection lines → related table via @OneToMany.
Now loading the order:
Order order = entityManager.find(Order.class, orderId);
Hibernate generates the SQL itself, runs the query, and assembles the object. The ResultSet mapping has disappeared from your code.
When ORM helps
ORM works well where the application logic revolves around domain objects: creating, modifying, relationships between entities, validations. Typical cases:
- CRUD operations on individual entities: create an order, change an address, add a line.
- Navigation through the object graph:
order.getCustomer().getAddress()— Hibernate loads the data by itself. - Transaction management and dirty checking: Hibernate tracks what changed in an entity and writes the
UPDATEat the end of the transaction on its own. You don't need to callsave()manually. - Caching: the first-level cache (within a single session) is built in by default; the second level is configurable.
When ORM gets in the way
ORM is not a universal tool. There are situations where it adds complexity rather than removing it.
Bulk operations. Updating the status of a million rows through entityManager.find() in a loop is a performance disaster. Hibernate would load each object into memory, track changes, and generate a separate UPDATE. The right path is a JPQL bulk query or native SQL:
// JPQL bulk update — a single SQL query
entityManager.createQuery(
"UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus"
).setParameter("newStatus", "ARCHIVED")
.setParameter("oldStatus", "DONE")
.executeUpdate();
Such a query has a flip side: it bypasses the persistence context. Objects already loaded into memory know nothing about it and keep the old status.
Complex analytical queries and reports. When a query joins 10 tables, uses window functions, GROUPING SETS, complex aggregation — ORM becomes a hindrance. Here native SQL or a specialized tool (jOOQ, for example) is better.
Fine-grained control over SQL. You don't always know what query Hibernate will generate — and where every query is tuned by hand, that gets in the way.
Persistence Context: Hibernate's central concept
To avoid being surprised by Hibernate's behavior, you need to understand the persistence context. It is the session's working area: a map of all the objects that Hibernate has loaded or created within the current transaction.
On entityManager.find(Order.class, 1L) Hibernate first looks into that map: if an object with this id is already there, it returns it from memory with no query; if not, it runs a SELECT and puts the result into the same map.
At the end of the transaction, Hibernate walks through all objects in the persistence context, compares them with their original state, and generates an UPDATE wherever something has changed. This is called dirty checking.
@Transactional
public void updateStatus(Long orderId, String newStatus) {
Order order = entityManager.find(Order.class, orderId);
order.setStatus(newStatus); // just change the field
// Hibernate generates the UPDATE at commit — an explicit save() is not needed
}
What ORM does for you: flat rows into an object graph
The database answers with a rectangle. For an order with two items the JOIN result is two records, and the order header repeats in both. Folding that into one object with a collection is the same manual work the article started with — here it is in full, without a database:
live example
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class RowsToGraphDemo {
public static void main(String[] args) {
List<Object[]> rows = List.of(
new Object[]{1L, "PAID", 10L, 2},
new Object[]{1L, "PAID", 11L, 1},
new Object[]{2L, "NEW", 10L, 5});
Map<Long, String> orders = new LinkedHashMap<>();
Map<Long, List<String>> lines = new LinkedHashMap<>();
for (Object[] row : rows) {
Long orderId = (Long) row[0];
orders.putIfAbsent(orderId, (String) row[1]);
lines.computeIfAbsent(orderId, id -> new ArrayList<>())
.add("item " + row[2] + " x" + row[3]);
}
orders.forEach((id, status) ->
System.out.println("Order(" + id + ", " + status + ") " + lines.get(id)));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Three flat rows became two objects with collections. This is exactly what Hibernate takes over: you declared @OneToMany, and it assembles the graph itself — the more tables in the query, the more this pays off.
In short
- JDBC requires manual
ResultSet→ object mapping — lots of boilerplate. - ORM takes this mapping upon itself: you describe the class/table correspondence, it generates SQL.
- JPA is the standard (interfaces and annotations). Hibernate is its implementation. Spring Data JPA is repositories on top.
- JPA annotations in Spring Boot 3 come from the
jakarta.persistencepackage. - ORM is a good fit for CRUD and object-oriented operations; for bulk updates and complex analytics, native SQL is better — and such a query bypasses the persistence context.
- The key concept is the persistence context: a map of loaded objects plus their snapshot, which dirty checking uses to decide who needs an
UPDATEat commit.
What to read next
- Entity Mapping — the
@Columnand@Embeddedannotations, type converters, inheritance. - Persistence Context — entity states, dirty checking, flush, and detach.
- Common Hibernate Pitfalls — LazyInitializationException, N+1, and others.
- Spring Data JPA — repositories, derived queries,
@Queryon top of Hibernate.