What is JPA?
JPA (Jakarta Persistence API), formerly Java Persistence API, is a specification that describes how to manage relational data in Java applications. It defines a standard way to map Java objects to database tables (ORM - Object-Relational Mapping) and provides APIs for CRUD operations, queries, and transactions.
JPA is a specification, not an implementation. You need a JPA provider (implementation) to use JPA. Hibernate is the most popular JPA provider, but others exist (EclipseLink, OpenJPA).
JPA vs Hibernate vs JDBC
// Architecture comparison:
┌─────────────────────────────────────────────────────────────┐
│ YOUR APPLICATION │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Pure JDBC │ │ JPA │ │ Hibernate │
│ (Manual SQL) │ │ (Specification) │ │ (Native API) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Hibernate │◄────────────┘
│ │ (JPA Provider) │
│ └─────────────────┘
│ │
└────────────────────┼────────────────────┘
▼
┌─────────────────┐
│ JDBC │
└─────────────────┘
│
▼
┌─────────────────┐
│ Database │
└─────────────────┘
| Aspect | JDBC | JPA/Hibernate |
|---|---|---|
| Abstraction Level | Low - write SQL | High - work with objects |
| Boilerplate Code | High | Low |
| Learning Curve | Lower | Higher |
| Performance Control | Full control | Less direct control |
| Caching | Manual | Built-in (L1, L2) |
| Database Portability | SQL dialect issues | Better portability |
Setting Up JPA with Hibernate
Maven Dependencies
<!-- JPA API -->
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version>
</dependency>
<!-- Hibernate (JPA Provider) -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.4.1.Final</version>
</dependency>
<!-- Database Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<!-- For Spring Boot (all-in-one) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Configuration (persistence.xml)
<!-- src/main/resources/META-INF/persistence.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
version="3.0">
<persistence-unit name="myPU" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
<!-- Entity classes -->
<class>com.example.entity.Employee</class>
<class>com.example.entity.Department</class>
<properties>
<!-- Database connection -->
<property name="jakarta.persistence.jdbc.driver"
value="com.mysql.cj.jdbc.Driver"/>
<property name="jakarta.persistence.jdbc.url"
value="jdbc:mysql://localhost:3306/mydb"/>
<property name="jakarta.persistence.jdbc.user"
value="root"/>
<property name="jakarta.persistence.jdbc.password"
value="password"/>
<!-- Hibernate settings -->
<property name="hibernate.dialect"
value="org.hibernate.dialect.MySQLDialect"/>
<property name="hibernate.hbm2ddl.auto"
value="update"/>
<property name="hibernate.show_sql"
value="true"/>
<property name="hibernate.format_sql"
value="true"/>
<!-- Connection pool (HikariCP) -->
<property name="hibernate.hikari.minimumIdle"
value="5"/>
<property name="hibernate.hikari.maximumPoolSize"
value="20"/>
</properties>
</persistence-unit>
</persistence>
Spring Boot Configuration (application.yml)
# application.yml - much simpler!
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update # create, create-drop, validate, update, none
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
Entity Mapping Basics
Simple Entity
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity // Marks class as JPA entity
@Table(name = "employees") // Maps to table name
public class Employee {
@Id // Primary key
@GeneratedValue(strategy = GenerationType.IDENTITY) // Auto-increment
private Long id;
@Column(name = "first_name", nullable = false, length = 50)
private String firstName;
@Column(name = "last_name", nullable = false, length = 50)
private String lastName;
@Column(unique = true, nullable = false)
private String email;
@Column(precision = 10, scale = 2)
private BigDecimal salary;
@Column(name = "hire_date")
private LocalDate hireDate;
@Column(nullable = false)
private Boolean active = true;
@Enumerated(EnumType.STRING) // Store enum as string, not ordinal
private EmployeeStatus status;
@Lob // Large object (TEXT/BLOB)
private String biography;
@Transient // Not persisted to database
private String fullName;
// Default constructor required by JPA
public Employee() {}
// Getters and setters...
// Calculated field
public String getFullName() {
return firstName + " " + lastName;
}
}
public enum EmployeeStatus {
ACTIVE, INACTIVE, ON_LEAVE, TERMINATED
}
ID Generation Strategies
// AUTO - Let JPA choose the strategy
@GeneratedValue(strategy = GenerationType.AUTO)
// IDENTITY - Database auto-increment (MySQL, PostgreSQL SERIAL)
@GeneratedValue(strategy = GenerationType.IDENTITY)
// SEQUENCE - Database sequence (Oracle, PostgreSQL)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "emp_seq")
@SequenceGenerator(name = "emp_seq", sequenceName = "employee_sequence", allocationSize = 50)
// TABLE - Simulated sequence using a table
@GeneratedValue(strategy = GenerationType.TABLE, generator = "emp_gen")
@TableGenerator(name = "emp_gen", table = "id_generator", pkColumnValue = "employee")
// UUID - For distributed systems
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
Embedded Objects
@Embeddable
public class Address {
private String street;
private String city;
private String state;
private String zipCode;
private String country;
// Constructors, getters, setters...
}
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Embedded
private Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "work_street")),
@AttributeOverride(name = "city", column = @Column(name = "work_city")),
@AttributeOverride(name = "state", column = @Column(name = "work_state")),
@AttributeOverride(name = "zipCode", column = @Column(name = "work_zip")),
@AttributeOverride(name = "country", column = @Column(name = "work_country"))
})
private Address workAddress;
}
Auditing Fields
@MappedSuperclass // Not an entity itself, inherited by entities
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "created_by", updatable = false)
private String createdBy;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@Entity
public class Employee extends BaseEntity {
private String name;
// id, createdAt, updatedAt inherited
}
EntityManager Operations
Basic CRUD Operations
import jakarta.persistence.*;
public class EmployeeService {
private EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPU");
// CREATE - persist()
public void save(Employee employee) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
em.persist(employee); // INSERT
tx.commit();
} catch (Exception e) {
if (tx.isActive()) tx.rollback();
throw e;
} finally {
em.close();
}
}
// READ - find()
public Employee findById(Long id) {
EntityManager em = emf.createEntityManager();
try {
return em.find(Employee.class, id); // SELECT by primary key
} finally {
em.close();
}
}
// READ - getReference() - lazy loading proxy
public Employee getReference(Long id) {
EntityManager em = emf.createEntityManager();
try {
return em.getReference(Employee.class, id); // Returns proxy, may throw EntityNotFoundException
} finally {
em.close();
}
}
// UPDATE - merge()
public Employee update(Employee employee) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
Employee merged = em.merge(employee); // UPDATE
tx.commit();
return merged;
} catch (Exception e) {
if (tx.isActive()) tx.rollback();
throw e;
} finally {
em.close();
}
}
// DELETE - remove()
public void delete(Long id) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
Employee employee = em.find(Employee.class, id);
if (employee != null) {
em.remove(employee); // DELETE
}
tx.commit();
} catch (Exception e) {
if (tx.isActive()) tx.rollback();
throw e;
} finally {
em.close();
}
}
}
Entity States and Lifecycle
// Entity States in JPA:
┌──────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ ┌──────────┐ │
│ │ NEW │──────persist()──────▶│ MANAGED │ │
│ │(Transient)│ │ │ │
│ └─────────┘ └────┬─────┘ │
│ ▲ │ │
│ │ find() │ merge() │
│ new Entity() ┌─────▼─────┐ │
│ │ │ Database │ │
│ │ └───────────┘ │
│ │ │ │
│ │ remove()│ │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │ REMOVED │ │
│ │ └────┬─────┘ │
│ │ │ │
│ │ close()│ clear() │
│ │ detach() │ │
│ │ ┌─────────────────────────┴──────────────┐ │
│ │ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ DETACHED │──────────────merge()──────────────┘ │
│ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
// NEW (Transient): Just created, not associated with EntityManager
Employee emp = new Employee();
emp.setName("John");
// MANAGED: Tracked by EntityManager, changes auto-synced
em.persist(emp); // Now managed
emp.setName("Jane"); // Change will be saved on commit!
// DETACHED: Was managed, but EntityManager closed
em.close(); // emp is now detached
emp.setName("Jack"); // Change NOT tracked!
// REMOVED: Marked for deletion
em.remove(emp); // Will be deleted on commit
JPQL and Criteria API
JPQL (Java Persistence Query Language)
public class EmployeeRepository {
private EntityManager em;
// Simple query
public List<Employee> findAll() {
return em.createQuery(
"SELECT e FROM Employee e",
Employee.class
).getResultList();
}
// Query with parameter
public List<Employee> findByDepartment(String deptName) {
return em.createQuery(
"SELECT e FROM Employee e WHERE e.department.name = :deptName",
Employee.class
)
.setParameter("deptName", deptName)
.getResultList();
}
// Query with multiple parameters
public List<Employee> findBySalaryRange(BigDecimal min, BigDecimal max) {
return em.createQuery(
"SELECT e FROM Employee e WHERE e.salary BETWEEN :min AND :max ORDER BY e.salary DESC",
Employee.class
)
.setParameter("min", min)
.setParameter("max", max)
.getResultList();
}
// Pagination
public List<Employee> findAllPaginated(int page, int size) {
return em.createQuery("SELECT e FROM Employee e ORDER BY e.id", Employee.class)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
}
// Single result
public Employee findByEmail(String email) {
try {
return em.createQuery(
"SELECT e FROM Employee e WHERE e.email = :email",
Employee.class
)
.setParameter("email", email)
.getSingleResult();
} catch (NoResultException e) {
return null;
}
}
// JOIN queries
public List<Employee> findWithDepartment() {
return em.createQuery(
"SELECT e FROM Employee e JOIN FETCH e.department",
Employee.class
).getResultList();
}
// Aggregate functions
public Double getAverageSalary() {
return em.createQuery(
"SELECT AVG(e.salary) FROM Employee e",
Double.class
).getSingleResult();
}
// Projection (DTO)
public List<EmployeeDTO> findAllAsDTO() {
return em.createQuery(
"SELECT NEW com.example.dto.EmployeeDTO(e.id, e.firstName, e.lastName, e.email) FROM Employee e",
EmployeeDTO.class
).getResultList();
}
// UPDATE query
public int giveRaise(Long deptId, BigDecimal percentage) {
return em.createQuery(
"UPDATE Employee e SET e.salary = e.salary * (1 + :pct) WHERE e.department.id = :deptId"
)
.setParameter("pct", percentage.divide(new BigDecimal("100")))
.setParameter("deptId", deptId)
.executeUpdate();
}
// DELETE query
public int deleteInactive() {
return em.createQuery(
"DELETE FROM Employee e WHERE e.active = false"
).executeUpdate();
}
}
Named Queries
@Entity
@NamedQueries({
@NamedQuery(
name = "Employee.findAll",
query = "SELECT e FROM Employee e ORDER BY e.lastName"
),
@NamedQuery(
name = "Employee.findByDepartment",
query = "SELECT e FROM Employee e WHERE e.department.id = :deptId"
),
@NamedQuery(
name = "Employee.findActive",
query = "SELECT e FROM Employee e WHERE e.active = true"
)
})
public class Employee {
// ...
}
// Usage
List<Employee> employees = em.createNamedQuery("Employee.findAll", Employee.class)
.getResultList();
List<Employee> deptEmployees = em.createNamedQuery("Employee.findByDepartment", Employee.class)
.setParameter("deptId", departmentId)
.getResultList();
Criteria API (Type-Safe Queries)
import jakarta.persistence.criteria.*;
public class EmployeeRepository {
private EntityManager em;
// Simple criteria query
public List<Employee> findAll() {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
Root<Employee> root = cq.from(Employee.class);
cq.select(root);
return em.createQuery(cq).getResultList();
}
// Criteria with WHERE clause
public List<Employee> findByDepartment(Long deptId) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
Root<Employee> root = cq.from(Employee.class);
cq.select(root)
.where(cb.equal(root.get("department").get("id"), deptId));
return em.createQuery(cq).getResultList();
}
// Dynamic criteria with multiple conditions
public List<Employee> search(String name, BigDecimal minSalary, Boolean active) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
Root<Employee> root = cq.from(Employee.class);
List<Predicate> predicates = new ArrayList<>();
if (name != null) {
predicates.add(cb.like(
cb.lower(root.get("lastName")),
"%" + name.toLowerCase() + "%"
));
}
if (minSalary != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("salary"), minSalary));
}
if (active != null) {
predicates.add(cb.equal(root.get("active"), active));
}
cq.where(predicates.toArray(new Predicate[0]));
cq.orderBy(cb.asc(root.get("lastName")));
return em.createQuery(cq).getResultList();
}
// Criteria with JOIN
public List<Employee> findByDepartmentName(String deptName) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
Root<Employee> root = cq.from(Employee.class);
Join<Employee, Department> deptJoin = root.join("department");
cq.select(root)
.where(cb.equal(deptJoin.get("name"), deptName));
return em.createQuery(cq).getResultList();
}
}
Fetching Strategies
Lazy vs Eager Loading
@Entity
public class Employee {
@ManyToOne(fetch = FetchType.LAZY) // Load department only when accessed
private Department department;
@OneToMany(fetch = FetchType.LAZY) // Default for collections
private List<Project> projects;
@ManyToOne(fetch = FetchType.EAGER) // Load immediately with employee
private Manager manager;
}
// Defaults:
// @OneToMany, @ManyToMany -> LAZY
// @OneToOne, @ManyToOne -> EAGER
// Best practice: Make everything LAZY, use JOIN FETCH when needed
N+1 Problem and Solutions
// THE N+1 PROBLEM:
List<Employee> employees = em.createQuery(
"SELECT e FROM Employee e", Employee.class
).getResultList(); // 1 query
for (Employee e : employees) {
System.out.println(e.getDepartment().getName()); // N queries!
}
// Total: 1 + N queries (very slow!)
// SOLUTION 1: JOIN FETCH
List<Employee> employees = em.createQuery(
"SELECT e FROM Employee e JOIN FETCH e.department",
Employee.class
).getResultList(); // 1 query with JOIN
// SOLUTION 2: Entity Graph
@Entity
@NamedEntityGraph(
name = "Employee.withDepartment",
attributeNodes = @NamedAttributeNode("department")
)
public class Employee { ... }
// Usage
EntityGraph<?> graph = em.getEntityGraph("Employee.withDepartment");
List<Employee> employees = em.createQuery("SELECT e FROM Employee e", Employee.class)
.setHint("jakarta.persistence.fetchgraph", graph)
.getResultList();
// SOLUTION 3: Batch fetching (Hibernate-specific)
@BatchSize(size = 25) // Fetch 25 departments at a time
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
Caching
First Level Cache (Session Cache)
// L1 Cache: Enabled by default, per EntityManager/Session
EntityManager em = emf.createEntityManager();
// First call - hits database
Employee emp1 = em.find(Employee.class, 1L); // SELECT from DB
// Second call - from L1 cache (no DB hit!)
Employee emp2 = em.find(Employee.class, 1L); // From cache
System.out.println(emp1 == emp2); // true - same instance!
// Clear L1 cache
em.clear(); // Detaches all entities
// Now hits DB again
Employee emp3 = em.find(Employee.class, 1L); // SELECT from DB
Second Level Cache (Shared Cache)
// L2 Cache: Shared across EntityManagers, must be configured
// persistence.xml configuration
<property name="hibernate.cache.use_second_level_cache" value="true"/>
<property name="hibernate.cache.region.factory_class"
value="org.hibernate.cache.jcache.JCacheRegionFactory"/>
<property name="hibernate.javax.cache.provider"
value="org.ehcache.jsr107.EhcacheCachingProvider"/>
// Mark entity as cacheable
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Employee {
// ...
}
// Cache strategies:
// READ_ONLY - Never changes, safest
// NONSTRICT_READ_WRITE - Rarely changes, eventual consistency OK
// READ_WRITE - Changes often, needs locking
// TRANSACTIONAL - Full transaction isolation
Query Cache
// Enable query cache
<property name="hibernate.cache.use_query_cache" value="true"/>
// Mark query as cacheable
List<Employee> employees = em.createQuery(
"SELECT e FROM Employee e WHERE e.active = true",
Employee.class
)
.setHint("org.hibernate.cacheable", true)
.getResultList();
// Named query with caching
@NamedQuery(
name = "Employee.findActive",
query = "SELECT e FROM Employee e WHERE e.active = true",
hints = @QueryHint(name = "org.hibernate.cacheable", value = "true")
)
Spring Data JPA
Spring Data JPA dramatically reduces boilerplate by providing repository interfaces that Spring implements for you.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
// Derived query methods - Spring generates SQL from method name!
List<Employee> findByLastName(String lastName);
List<Employee> findByFirstNameAndLastName(String firstName, String lastName);
List<Employee> findByDepartmentName(String deptName);
List<Employee> findBySalaryGreaterThan(BigDecimal salary);
List<Employee> findByActiveTrue();
List<Employee> findByEmailContainingIgnoreCase(String email);
List<Employee> findByHireDateBetween(LocalDate start, LocalDate end);
// Top/First queries
List<Employee> findTop5BySalaryOrderBySalaryDesc();
Employee findFirstByOrderByHireDateAsc();
// Count and exists
long countByDepartmentId(Long deptId);
boolean existsByEmail(String email);
// Custom JPQL query
@Query("SELECT e FROM Employee e WHERE e.salary > :salary AND e.department.id = :deptId")
List<Employee> findHighEarners(@Param("salary") BigDecimal salary,
@Param("deptId") Long departmentId);
// Native SQL query
@Query(value = "SELECT * FROM employees WHERE email LIKE %:domain", nativeQuery = true)
List<Employee> findByEmailDomain(@Param("domain") String domain);
// Modifying queries
@Modifying
@Query("UPDATE Employee e SET e.active = false WHERE e.id = :id")
int deactivate(@Param("id") Long id);
@Modifying
@Query("DELETE FROM Employee e WHERE e.active = false")
int deleteInactive();
}
// Usage in service
@Service
@Transactional
public class EmployeeService {
private final EmployeeRepository repository;
public EmployeeService(EmployeeRepository repository) {
this.repository = repository;
}
public Employee findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Employee not found"));
}
public Employee save(Employee employee) {
return repository.save(employee); // INSERT or UPDATE
}
public void delete(Long id) {
repository.deleteById(id);
}
public Page<Employee> findAll(Pageable pageable) {
return repository.findAll(pageable); // Pagination built-in!
}
}
Best Practices
Do's
- Use LAZY fetching as default, JOIN FETCH when needed
- Always use @Transactional for write operations
- Use DTOs for read-only queries (projections)
- Index foreign keys in the database
- Use batch operations for bulk inserts/updates
- Enable SQL logging in development to catch N+1
Don'ts
- Don't use EAGER fetching without good reason
- Don't ignore the generated SQL - review it
- Don't use bidirectional relationships unless necessary
- Don't modify detached entities without merge()
- Don't use flush() unnecessarily
Summary
- JPA: Specification for ORM in Java
- Hibernate: Most popular JPA implementation
- Entity: Java class mapped to database table
- EntityManager: API for CRUD and queries
- JPQL: Object-oriented query language
- Criteria API: Type-safe programmatic queries
- Lazy Loading: Load data on demand
- Caching: L1 (session), L2 (shared), Query cache
- Spring Data JPA: Repository abstraction, minimal boilerplate