JPA & ORM¶
ORM (Object-Relational Mapping) maps objects ↔ relational tables. JPA is the Java spec; Hibernate is the most common implementation. Reduces boilerplate, manages transactions and entity lifecycle.
Hibernate: Object-Relational Mapping¶
Entity Definition¶
@Entity // Marks class as JPA entity (maps to DB table)
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(columnNames = "email"),
@UniqueConstraint(columnNames = {"first_name", "last_name"})
})
public class User {
// Primary key with auto-generation
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB auto-increment
private Long id;
// Column mapping
@Column(name = "first_name", nullable = false, length = 100)
private String firstName;
@Column(unique = true)
private String email;
@Column(columnDefinition = "INT DEFAULT 0") // Raw SQL definition
private Integer loginCount;
@Transient // Not persisted
private String tempCalculatedField;
// Timestamp
@Column(updatable = false) // Set only on insert
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp // Auto-updated on every change
private LocalDateTime updatedAt;
}
ID Generation Strategies¶
public class IdGenerationExamples {
// IDENTITY: DB auto-increment (MySQL, SQL Server)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// SEQUENCE: Oracle, PostgreSQL native sequence
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "seq_users", allocationSize = 1)
private Long id;
// TABLE: Simulate sequence using a table (database-agnostic)
@Id
@GeneratedValue(strategy = GenerationType.TABLE, generator = "user_table_gen")
@TableGenerator(name = "user_table_gen", table = "id_generators",
pkColumnName = "entity", pkColumnValue = "user", valueColumnName = "id_value")
private Long id;
// AUTO: Let Hibernate choose based on dialect
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
// UUID: Generate UUID on application side
@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")
private String id;
// Manual: Provide ID yourself
@Id
private String customId;
}
Relationships¶
// One-to-Many: User has multiple Orders
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// One user has many orders
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@LazyCollection(LazyCollectionOption.EXTRA) // Load only when needed
private Set<Order> orders = new HashSet<>();
}
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // Default for @ManyToOne is EAGER; use LAZY
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
// Many-to-Many: Users and Groups
@Entity
public class User {
@ManyToMany(cascade = CascadeType.PERSIST)
@JoinTable(
name = "user_groups",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "group_id")
)
private Set<Group> groups = new HashSet<>();
}
@Entity
public class Group {
@ManyToMany(mappedBy = "groups")
private Set<User> users = new HashSet<>();
}
// One-to-One
@Entity
public class User {
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id")
private UserProfile profile;
}
@Entity
public class UserProfile {
@OneToOne(mappedBy = "profile")
private User user;
}
Cascade Types¶
| Type | Behavior |
|---|---|
PERSIST |
Save parent → save children |
MERGE |
Update parent → update children |
REMOVE |
Delete parent → delete children |
REFRESH |
Reload parent → reload children |
DETACH |
Detach parent → detach children |
ALL |
All of above |
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items; // Delete items when removed from list
Persistence Context & Entity Lifecycle¶
Entity states:
1. Transient — Not known to Hibernate; created with new
2. Persistent (Managed) — Associated with EntityManager; changes tracked
3. Detached — Was managed, now disconnected (after session closes)
4. Removed — Marked for deletion
@Service
public class UserService {
private EntityManager em;
public void demonstrateLifecycle() {
// 1. TRANSIENT
User user = new User(); // Not in DB, not managed
// 2. PERSISTENT
em.persist(user); // Now managed; Hibernate tracks changes
user.setName("John"); // Dirty checked at commit
// 3. DETACHED
em.detach(user); // Remove from context
user.setName("Jane"); // Changes NOT tracked
// 4. MERGE back to PERSISTENT
User merged = em.merge(user); // Re-attach; changes sync'd
// 5. REMOVED
em.remove(merged); // Mark for deletion
}
}
Session Lifecycle: persist() vs save() vs merge() vs update()¶
Entity State Transitions:
| Method | Input State | Output State | DB Effect | Use Case |
|---|---|---|---|---|
| persist() | Transient | Persistent | INSERT scheduled (flush) | New entity, guaranteed unique ID handling |
| save() | Transient or Detached | Persistent | INSERT or UPDATE (auto-detect) | General save (JPA/Spring Data shorthand) |
| merge() | Detached | Persistent (new instance) | UPDATE (or INSERT if new) | Re-attach modified detached entity |
| update() | Detached | Unchanged | UPDATE only | Explicit update (rare; use merge instead) |
Detailed Comparison:
@Transactional
@Service
public class UserService {
private EntityManager em;
private UserRepository userRepo; // Spring Data
// ✅ persist() — new entity, guaranteed INSERT
public void persistNewUser() {
User user = new User("John", "john@example.com"); // Transient
em.persist(user); // Now Persistent; INSERT queued
// At transaction commit:
// 1. Hibernate generates ID
// 2. INSERT executes
// 3. ID is set on user object
System.out.println(user.getId()); // ✅ ID available after flush/commit
}
// ✅ save() — auto-detect persist vs update
public void saveUser() {
User user = new User("Jane", "jane@example.com"); // Transient
User saved = userRepo.save(user); // Returns managed instance
// Spring Data's save():
// - If ID is null → persist() → INSERT
// - If ID exists → merge() → UPDATE
System.out.println(saved.getId()); // ✅ ID available immediately
}
// ⚠️ merge() — re-attach detached entity (creates new managed copy)
public void mergeDetachedUser(Long userId) {
// Get and detach
User user = em.find(User.class, userId); // Persistent
em.detach(user); // Now Detached
user.setName("Updated Name"); // ❌ Changes NOT tracked!
// Re-attach
User managed = em.merge(user); // Returns NEW managed instance
managed.setEmail("new@example.com"); // ✅ Changes tracked
// user != managed (different objects)
// UPDATE executes at commit with managed's state
}
// ❌ update() — raw update, requires existing entity
public void updateExistingUser(User detachedUser) {
// Rarely used; merge() is preferred
em.getTransaction().begin();
em.update(detachedUser); // UPDATE only (assumes entity exists)
em.getTransaction().commit();
}
}
Key Insight: Dirty Checking vs Explicit Operations
@Transactional // Critical: provides persistence context
public void demonstrateDirtychecking() {
User user = userRepo.findById(1L).get(); // Persistent
user.setName("New Name");
// ❌ Don't need to call save()!
// ✅ Dirty checking happens automatically:
// 1. At transaction commit, Hibernate snapshots entity state
// 2. Detects differences from original
// 3. Generates UPDATE SQL
// 4. Executes and commits
// userRepo.save(user); // ← Not needed for managed entities
}
// Without @Transactional: Changes are NOT tracked!
public void noTransactionNoUpdate() {
User user = userRepo.findById(1L).get();
user.setName("This won't persist");
// No @Transactional = no transaction = no dirty check
// Changes silently lost!
}
Best Practices:
// ✅ DO: Use Spring Data save() for simplicity
User saved = userRepository.save(newUser);
// ✅ DO: Use @Transactional for automatic updates
@Transactional
public void updateUser(Long id, String newName) {
User user = userRepository.findById(id).orElseThrow();
user.setName(newName); // Dirty check handles update
}
// ✅ DO: Always use merge() to re-attach detached entities
User managed = entityManager.merge(detachedUser);
managed.setEmail("new@example.com");
// ❌ DON'T: Use em.update() directly
// ❌ DON'T: Forget @Transactional (changes won't persist)
// ❌ DON'T: Assume detached entities are still managed
Lazy Loading & N+1 Problem¶
// ❌ N+1 problem
@Repository
public class UserRepository extends JpaRepository<User, Long> {
public List<User> findAllWithOrders() {
List<User> users = findAll(); // 1 query
for (User user : users) {
System.out.println(user.getOrders()); // N queries (one per user)
}
return users;
}
}
// ✅ Fix 1: JOIN FETCH
@Repository
public class UserRepository extends JpaRepository<User, Long> {
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders")
public List<User> findAllWithOrdersJoinFetch();
}
// ✅ Fix 2: Entity Graph
@Repository
public class UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "orders")
@Query("SELECT u FROM User u")
public List<User> findAllWithOrdersGraph();
}
// ✅ Fix 3: Batch loading
@Entity
public class User {
@OneToMany(mappedBy = "user")
@BatchSize(size = 10) // Load 10 users' orders in single query
private Set<Order> orders;
}
Dirty Checking & Flush¶
@Transactional
public void demonstrateDirtyChecking() {
User user = userRepo.findById(1L).orElse(null);
user.setName("New Name");
// No explicit save() call!
// At @Transactional commit:
// 1. Dirty checking: Hibernate compares object state to snapshot
// 2. Flush: Auto-generates UPDATE SQL
// 3. Commit: DB commits
}
// Manual flush
@Transactional
public void manualFlush() {
User user = userRepo.findById(1L).orElse(null);
user.setName("John");
em.flush(); // Write to DB immediately (still in transaction)
// Later...
user.setEmail("john@example.com");
// flush() again on commit
}
HQL (Hibernate Query Language)¶
@Repository
public class UserRepository extends JpaRepository<User, Long> {
// Simple query
@Query("FROM User u WHERE u.email = ?1")
User findByEmail(String email);
// With named parameter
@Query("FROM User u WHERE u.email = :email AND u.age > :minAge")
List<User> findAdultsByEmail(@Param("email") String email, @Param("minAge") int age);
// Join
@Query("SELECT u FROM User u JOIN u.orders o WHERE o.amount > :amount")
List<User> findUsersWithOrdersOver(@Param("amount") BigDecimal amount);
// Aggregation
@Query("SELECT COUNT(u) FROM User u WHERE u.age > :age")
long countAdults(@Param("age") int age);
// Update (dangerous!)
@Modifying
@Transactional
@Query("UPDATE User u SET u.loginCount = u.loginCount + 1 WHERE u.id = :id")
void incrementLoginCount(@Param("id") Long id);
}
MyBatis: SQL Mapper¶
MyBatis — Write SQL manually; full control over queries. Good for complex/legacy schemas.
Setup¶
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.2</version>
</dependency>
Mapper Interface & XML¶
@Mapper
public interface UserMapper {
User selectById(Long id);
List<User> selectAll();
int insert(User user);
int update(User user);
int deleteById(Long id);
}
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<resultMap id="userResultMap" type="User">
<id column="id" property="id"/>
<result column="first_name" property="firstName"/>
<result column="email" property="email"/>
<result column="created_at" property="createdAt"/>
</resultMap>
<!-- SELECT -->
<select id="selectById" parameterType="long" resultMap="userResultMap">
SELECT id, first_name, email, created_at
FROM users
WHERE id = #{id}
</select>
<!-- SELECT multiple with dynamic where -->
<select id="selectAll" resultMap="userResultMap">
SELECT id, first_name, email, created_at
FROM users
<where>
<if test="firstName != null">
AND first_name LIKE CONCAT('%', #{firstName}, '%')
</if>
<if test="email != null">
AND email = #{email}
</if>
</where>
ORDER BY id
</select>
<!-- INSERT -->
<insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (first_name, email, created_at)
VALUES (#{firstName}, #{email}, NOW())
</insert>
<!-- UPSERT (MySQL) -->
<insert id="upsert" parameterType="User">
INSERT INTO users (id, first_name, email)
VALUES (#{id}, #{firstName}, #{email})
ON DUPLICATE KEY UPDATE
first_name = #{firstName},
email = #{email}
</insert>
<!-- UPDATE -->
<update id="update" parameterType="User">
UPDATE users
<set>
<if test="firstName != null">first_name = #{firstName},</if>
<if test="email != null">email = #{email},</if>
updated_at = NOW()
</set>
WHERE id = #{id}
</update>
<!-- DELETE -->
<delete id="deleteById" parameterType="long">
DELETE FROM users WHERE id = #{id}
</delete>
</mapper>
Annotation-Based Mapper¶
@Mapper
public interface UserMapper {
@Select("SELECT * FROM users WHERE id = #{id}")
@Results({
@Result(property = "id", column = "id", id = true),
@Result(property = "firstName", column = "first_name"),
@Result(property = "createdAt", column = "created_at")
})
User selectById(Long id);
@Select("SELECT * FROM users WHERE email = #{email}")
User selectByEmail(String email);
@Insert("INSERT INTO users (first_name, email) VALUES (#{firstName}, #{email})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
@Update("UPDATE users SET first_name = #{firstName} WHERE id = #{id}")
int update(User user);
@Delete("DELETE FROM users WHERE id = #{id}")
int deleteById(Long id);
}
Complex Queries with Joins¶
<mapper namespace="com.example.mapper.UserMapper">
<resultMap id="userWithOrdersMap" type="User">
<id column="user_id" property="id"/>
<result column="first_name" property="firstName"/>
<collection property="orders" ofType="Order">
<id column="order_id" property="id"/>
<result column="amount" property="amount"/>
<result column="order_date" property="createdAt"/>
</collection>
</resultMap>
<select id="selectUserWithOrders" parameterType="long" resultMap="userWithOrdersMap">
SELECT
u.id AS user_id,
u.first_name,
o.id AS order_id,
o.amount,
o.created_at AS order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id = #{id}
</select>
</mapper>
Dynamic SQL¶
<select id="searchUsers" parameterType="map" resultType="User">
SELECT * FROM users
<where>
<if test="firstName != null">
AND first_name LIKE CONCAT('%', #{firstName}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
<choose>
<when test="sortBy == 'name'">
ORDER BY first_name
</when>
<when test="sortBy == 'created'">
ORDER BY created_at DESC
</when>
<otherwise>
ORDER BY id
</otherwise>
</choose>
LIMIT #{limit} OFFSET #{offset}
</select>
<!-- Batch insert -->
<insert id="batchInsert" parameterType="list">
INSERT INTO users (first_name, email) VALUES
<foreach collection="list" item="user" separator=",">
(#{user.firstName}, #{user.email})
</foreach>
</insert>
JPA vs MyBatis Comparison¶
| Aspect | JPA/Hibernate | MyBatis |
|---|---|---|
| Style | ORM (object-centric) | SQL mapper (SQL-centric) |
| Boilerplate | Less; auto CRUD | More; write SQL for each query |
| Control | Less control over SQL | Full SQL control |
| Relationships | Built-in (@OneToMany, etc.) | Manual joins in XML/SQL |
| Performance | Good; caching, lazy loading | Very fast; pure SQL, no overhead |
| Best for | Standard CRUD, clean domains | Complex queries, legacy schemas |
| Learning Curve | Moderate (concepts: lazy, cascade, context) | Low (just SQL + mappings) |
When to Use Each¶
Use JPA/Hibernate for: - Domain-model-driven design - Standard CRUD operations - OOP-heavy codebase - Need for automatic relationships
Use MyBatis for: - Complex/tuned queries - Legacy databases with odd schemas - Bulk operations - Team comfortable with SQL
Spring Data JPA¶
Convenience layer over Hibernate with derived queries and custom methods.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Derived query (method name → SQL)
User findByEmail(String email);
List<User> findByFirstNameContainsIgnCase(String firstName);
List<User> findByAgeGreaterThan(int age);
// Custom JPQL
@Query("SELECT u FROM User u WHERE u.email = ?1")
User findByEmailCustom(String email);
// Native SQL
@Query(value = "SELECT * FROM users WHERE age > ?1", nativeQuery = true)
List<User> findAdultNative(int age);
// Pageable
Page<User> findByAgeGreaterThan(int age, Pageable pageable);
// Sorting
List<User> findAll(Sort.by("firstName"));
}
Maintaining Entity IDs¶
Best Practices¶
@Entity
public class User {
// ✅ DO: Use Long/UUID for primary keys
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ❌ DON'T: Use String PKs without UUID
// ❌ DON'T: Use business logic as PK (email, SSN, etc.)
// Consider: Business key as unique index
@Column(unique = true, nullable = false)
private String email; // Natural key, indexed
}
// Composite key (when necessary)
@Embeddable
public class OrderItemKey implements Serializable {
@Column(name = "order_id")
private Long orderId;
@Column(name = "product_id")
private Long productId;
}
@Entity
public class OrderItem {
@EmbeddedId
private OrderItemKey id;
@ManyToOne
@MapsId("orderId")
private Order order;
}
ID Assignment Patterns¶
// Strategy 1: Database-generated (IDENTITY) — recommended for most cases
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Strategy 2: UUID — good for distributed systems, no central DB sequence
@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")
private String id;
// Strategy 3: Custom ID generation
@Id
@GeneratedValue(strategy = GenerationType.AUTO, generator = "custom_id")
private String id;
// Strategy 4: Manual assignment (for imports/migrations)
@Id
private Long id;
// Set manually before persist
user.setId(1L);
em.persist(user);