Master Object-Relational Mapping
Java Persistence API (JPA) is a specification for Object-Relational Mapping (ORM)
Several implementations exist:
| Implementation | Description |
|---|---|
| Hibernate | Most popular, feature-rich |
| EclipseLink | Reference implementation |
| OpenJPA | Apache project |
We'll use Hibernate - the most widely adopted ORM framework
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.4.1.Final</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- src/main/resources/META-INF/persistence.xml -->
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.0">
<persistence-unit name="my-unit">
<properties>
<property name="jakarta.persistence.jdbc.url"
value="jdbc:postgresql://localhost:5432/mydb"/>
<property name="jakarta.persistence.jdbc.user" value="user"/>
<property name="jakarta.persistence.jdbc.password" value="password"/>
<property name="hibernate.dialect"
value="org.hibernate.dialect.PostgreSQLDialect"/>
<property name="hibernate.hbm2ddl.auto" value="update"/>
<property name="hibernate.show_sql" value="true"/>
</properties>
</persistence-unit>
</persistence>
Spring Boot hides this. In pure JPA you create it yourself:
import jakarta.persistence.*;
// Create ONCE per application - expensive, thread-safe
EntityManagerFactory emf =
Persistence.createEntityManagerFactory("my-unit");
// Create per unit of work - cheap, NOT thread-safe
EntityManager em = emf.createEntityManager();
try {
EntityTransaction tx = em.getTransaction();
tx.begin();
em.persist(new User("alice@example.com"));
tx.commit();
} finally {
em.close();
}
emf.close(); // on application shutdown
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "full_name", length = 100)
private String fullName;
@Column(name = "created_at")
private LocalDateTime createdAt;
// Constructors, getters, setters
}
@Entity - Marks class as JPA entity@Table - Specifies table name (optional if class name matches)@Id - Marks primary key field@GeneratedValue - Auto-generate primary key@Column - Maps field to column (optional with customization)AUTO - Let JPA chooseIDENTITY - Database auto-incrementSEQUENCE - Database sequenceTABLE - Separate table for ID generation@Entity
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
private List<Book> books = new ArrayList<>();
}
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
}
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
}
Control how operations propagate to related entities:
| Cascade Type | Description |
|---|---|
| PERSIST | Propagate persist operation |
| MERGE | Propagate merge operation |
| REMOVE | Propagate remove operation |
| REFRESH | Propagate refresh operation |
| DETACH | Propagate detach operation |
| ALL | All of the above |
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books;
Data loaded only when accessed
@ManyToOne(fetch = FetchType.EAGER)
private Author author;
Data loaded immediately with parent entity
Best Practice: Use LAZY by default, load eagerly only when needed
Without Spring Data, you write a thin data access object:
public class UserDao {
private final EntityManager em;
public UserDao(EntityManager em) {
this.em = em;
}
public void save(User user) {
em.persist(user);
}
public User findById(Long id) {
return em.find(User.class, id);
}
public User findByEmail(String email) {
return em.createQuery(
"SELECT u FROM User u WHERE u.email = :email", User.class)
.setParameter("email", email)
.getSingleResult();
}
public List<User> findAll() {
return em.createQuery("SELECT u FROM User u", User.class)
.getResultList();
}
}
The EntityManager is the central JPA interface:
| Method | Description |
|---|---|
persist(entity) |
Insert a new entity (transient to managed) |
find(Class, id) |
Look up by primary key |
merge(entity) |
Re-attach and update a detached entity |
remove(entity) |
Delete a managed entity |
refresh(entity) |
Reload state from the database |
flush() |
Synchronize the context to the database |
createQuery(jpql) |
Build a JPQL query |
JPQL operates on entities and fields, not tables and columns:
| Pattern | JPQL |
|---|---|
| Equality | SELECT u FROM User u WHERE u.email = :email |
| AND / OR | ... WHERE u.name = :name AND u.email = :email |
| BETWEEN | ... WHERE u.createdAt BETWEEN :start AND :end |
| Comparison | ... WHERE u.age > :age |
| LIKE | ... WHERE u.name LIKE :pattern |
| ORDER BY | ... ORDER BY u.name ASC |
List<User> users = em.createQuery(
"SELECT u FROM User u WHERE u.age > :age", User.class)
.setParameter("age", 18)
.getResultList();
// JPQL
List<User> users = em.createQuery(
"SELECT u FROM User u WHERE u.email LIKE :domain", User.class)
.setParameter("domain", "%@example.com")
.getResultList();
// Native SQL
List<User> recent = em.createNativeQuery(
"SELECT * FROM users WHERE created_at > ?1", User.class)
.setParameter(1, date)
.getResultList();
// Modifying query - inside an active transaction
int updated = em.createQuery(
"UPDATE User u SET u.active = false WHERE u.lastLogin < :date")
.setParameter("date", date)
.executeUpdate();
// DTO projection with a constructor expression
List<UserDTO> dtos = em.createQuery(
"SELECT new com.example.dto.UserDTO(u.id, u.email) FROM User u",
UserDTO.class)
.getResultList();
Declare reusable queries with @NamedQuery on the entity, then call em.createNamedQuery(...).
JPA paginates with setFirstResult and setMaxResults:
public class UserService {
private final EntityManager em;
public UserService(EntityManager em) {
this.em = em;
}
public List<User> getUsers(int page, int size) {
return em.createQuery(
"SELECT u FROM User u ORDER BY u.fullName ASC", User.class)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
}
public long countUsers() {
return em.createQuery(
"SELECT COUNT(u) FROM User u", Long.class)
.getSingleResult();
}
}
In Java SE you drive the EntityTransaction manually:
public void createUser(User user) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
em.persist(user);
tx.commit(); // flush and commit
} catch (RuntimeException e) {
if (tx.isActive()) {
tx.rollback(); // any exception rolls back
}
throw e;
} finally {
em.close();
}
}
public User findUser(Long id) {
EntityManager em = emf.createEntityManager();
try {
User user = em.find(User.class, id); // read-only, no tx needed
if (user == null) throw new UserNotFoundException(id);
return user;
} finally {
em.close();
}
}
In a Jakarta EE container, JTA can manage transactions for you instead.
// 1 query to get all authors
List<Author> authors = em.createQuery(
"SELECT a FROM Author a", Author.class).getResultList();
// N queries - one for each author's books!
for (Author author : authors) {
System.out.println(author.getBooks().size());
}
// Use JOIN FETCH to load everything in one query
List<Author> authors = em.createQuery(
"SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books",
Author.class).getResultList();
// Or use an EntityGraph as a fetch hint
EntityGraph<Author> graph = em.createEntityGraph(Author.class);
graph.addAttributeNodes("books");
List<Author> result = em.createQuery(
"SELECT a FROM Author a", Author.class)
.setHint("jakarta.persistence.fetchgraph", graph)
.getResultList();
// Set hibernate.jdbc.batch_size, then flush and clear periodically
for (int i = 0; i < users.size(); i++) {
em.persist(users.get(i));
if (i % 50 == 0) { // release managed entities to free memory
em.flush();
em.clear();
}
}
// Load only needed fields with a constructor expression
List<UserDTO> dtos = em.createQuery(
"SELECT new com.example.UserDTO(u.id, u.email) FROM User u",
UserDTO.class).getResultList();
Add the Hibernate JCache integration with an Ehcache provider:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
<!-- persistence.xml -->
<property name="hibernate.cache.use_second_level_cache" value="true"/>
<property name="hibernate.cache.region.factory_class" value="jcache"/>
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class User { }
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Place migration files under src/main/resources/db/migration/ and run them standalone:
Flyway flyway = Flyway.configure()
.dataSource("jdbc:postgresql://localhost:5432/mydb", "user", "password")
.load();
flyway.migrate();
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Set hibernate.hbm2ddl.auto to validate so Flyway owns the schema.
Pure JPA tracks timestamps with @PrePersist and @PreUpdate:
@MappedSuperclass
public abstract class Auditable {
@Column(updatable = false)
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
@PrePersist
public void onCreate() {
createdDate = LocalDateTime.now();
lastModifiedDate = createdDate;
}
@PreUpdate
public void onUpdate() {
lastModifiedDate = LocalDateTime.now();
}
}
@Entity
public class User extends Auditable {
// User fields
}
Hibernate also offers @CreationTimestamp and @UpdateTimestamp as shortcuts.
Task: Design a blog application data model
Task: Implement complex queries
Task: Identify and fix N+1 queries
Accessing a lazy association after the EntityManager is closed. Fetch it within the persistence context, use JOIN FETCH, or map to a DTO.
Always close() the EntityManager in a finally block to release the connection.
persist, merge and remove require an active EntityTransaction.
Monitor SQL with hibernate.show_sql=true and watch for N+1.
In this module, you learned:
Next Module: RESTful Web Services & APIs