Table of Contents
- What is JPA?
- What is Hibernate?
- JPA vs Hibernate — Key Differences
- Relationship Mapping in JPA — Quick Reference
- What is a One-to-One Relationship?
- Bidirectional One-to-One — Standard Mapping
- The Lazy Loading Problem on the Inverse Side
- Solutions to the Inverse-Side Probe Query
- Deep Dive — @MapsId
- Comparison — @JoinColumn vs @MapsId
- Quick Reference Table
- Summary
1. What is JPA?
The Java Persistence API (JPA) is a Java specification that defines a standard way to manage relational data in Java applications using Object Relational Mapping (ORM).
It provides a set of interfaces and annotations that allow developers to map Java objects to database tables, perform CRUD operations, and manage persistence without writing large amounts of SQL.
Key points:
- JPA is a specification, not an implementation
- It standardizes how Java applications interact with relational databases
- It uses annotations and configuration to map objects to tables
- It simplifies database operations through entity management and persistence context
2. What is Hibernate?
Hibernate is a popular open-source ORM framework that provides a concrete implementation of the JPA specification.
It allows developers to interact with databases using Java objects instead of writing complex SQL queries, making application code loosely coupled with the underlying database.
Key points:
- Hibernate is a JPA implementation
- Provides powerful ORM capabilities beyond the JPA spec
- Handles CRUD operations automatically
- Supports caching, lazy loading, and transaction management
- Reduces boilerplate JDBC code
3. JPA vs Hibernate — Key Differences
| JPA | Hibernate | |
|---|---|---|
| Type | Specification (standard API) | Implementation of JPA |
| Who defines it | Jakarta EE / Oracle | Red Hat / JBoss |
| Can run standalone? | No — needs an implementation | Yes |
| Extra features | Standard only | Caching, @BatchSize, @NaturalId, etc. |
| Code coupling | Low — portable across providers | Higher — Hibernate-specific annotations |
In practice:
- Developers write code using JPA annotations and interfaces
- Hibernate executes the actual database operations behind the scenes
4. Relationship Mapping in JPA — Quick Reference
| Annotation | Relationship | FK Location |
|---|---|---|
@OneToOne |
One entity ↔ one entity | Child / owning side |
@OneToMany |
One entity → many entities | Child table |
@ManyToOne |
Many entities → one entity | Owning entity (FK here) |
@ManyToMany |
Many entities ↔ many entities | Join / junction table |
Key best practices across all relationship types:
-
Always use
FetchType.LAZYon relationships — avoids N+1 query problems -
mappedBygoes on the non-owning side (the side without the FK column) - Cascade carefully — only cascade from parent to child, never upward
-
@JoinColumnexplicitly names your FK column for clarity - Use
Setinstead ofListfor@ManyToManyto avoid duplicate join queries
5. What is a One-to-One Relationship?
A One-to-One relationship occurs when one entity is associated with exactly one instance of another entity.
Real-world examples:
-
Person→Passport -
User→UserProfile -
Order→Invoice
Unidirectional vs Bidirectional
Unidirectional — Only one entity has a reference to the other. Navigation works in one direction only.
User → UserProfile (User knows about UserProfile; UserProfile does NOT know about User)
Bidirectional — Both entities have a reference to each other. Navigation works in both directions.
User ⇆ UserProfile (both sides can navigate to the other)
How to Identify Owner, FK Side, and Child
Ask yourself these three questions:
| Question | Answer |
|---|---|
| Who cannot exist without the other? | That's the child → holds the FK |
| Who exists independently? | That's the parent → has mappedBy
|
| Who makes sense to delete first? | That's the child → cascade from parent |
Rule of thumb: Child = cannot exist without parent = has
@JoinColumn(FK).
Parent = exists independently = hasmappedBy(no FK).
JPA Annotation Summary
| Annotation | Side | FK |
|---|---|---|
@JoinColumn |
Owning / Child | FK lives here |
mappedBy |
Inverse / Parent | No FK |
@JoinTable |
@ManyToMany owner |
Junction table |
cascade |
Parent → Child | Delete parent = delete child |
6. Bidirectional One-to-One — Standard Mapping
In a User ↔ UserProfile relationship, UserProfile is the child because the foreign key (user_id) lives in the user_profile table. User is the parent (inverse side).
users user_profile
───────────── ──────────────────────
id (PK) ◀── id (PK)
username phone
password address
enabled user_id (FK) ← FK lives here
The Child Entity (Owning Side)
@JoinColumn is used on the owning side — the child entity contains the foreign key pointing to the parent's primary key.
@Entity
@Table(name = "user_profile")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String phone;
@Column(nullable = false)
private String address;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", unique = true) // FK column lives here
private User user;
}
The Parent Entity (Inverse Side)
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled = true;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private UserProfile profile;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"})
)
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private Set<Role> roles = new HashSet<>();
// Bidirectional sync helper — keeps both sides consistent
public void setProfile(UserProfile profile) {
this.profile = profile;
if (profile != null) {
profile.setUser(this);
}
}
// Role helper methods
public void addRole(Role role) {
if (role != null) this.roles.add(role);
}
public void removeRole(Role role) {
this.roles.remove(role);
}
public boolean hasRole(Role role) {
return this.roles.contains(role);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User user)) return false;
return username != null && username.equals(user.getUsername());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
Note on
@ElementCollection:@ElementCollectionstores simple values (like enumRole) in a separate table — it is not a full entity relationship, so there is no "other side" to sync.
KeepingFetchType.EAGERhere is intentional when using Spring Security —UserDetailsneeds roles immediately on authentication.
7. The Lazy Loading Problem on the Inverse Side
Declaring fetch = FetchType.LAZY on the inverse side (mappedBy) of a @OneToOne does not actually make it lazy. Hibernate silently fires an extra query anyway.
Why the Extra Query Fires
On the owning side, the FK column is in the same row — Hibernate immediately knows if the association is null or not, and can safely create a proxy.
On the inverse side, there is no FK column. Hibernate must probe the database just to determine whether a UserProfile exists for a given User.
Query 1: SELECT * FROM users ← your actual request
Query 2: SELECT * FROM user_profile ← YOU DIDN'T ASK FOR THIS (Hibernate probe)
Query 3: SELECT * FROM user_roles ← expected (EAGER @ElementCollection)
Query 2 is Hibernate asking: "does a profile exist for this user?" — because the inverse side has no FK column to check locally.
8. Solutions to the Inverse-Side Probe Query
Option 1 — @LazyToOne Bytecode Instrumentation
Forces truly lazy loading via Hibernate bytecode enhancement — Hibernate injects an interceptor into the bytecode so it never needs to probe the DB.
// User.java
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
@LazyToOne(LazyToOneOption.NO_PROXY) // forces true lazy via bytecode
private UserProfile profile;
Requires adding the Hibernate bytecode enhancer plugin to your build tool:
<!-- Maven -->
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<configuration>
<enableLazyInitialization>true</enableLazyInitialization>
</configuration>
</plugin>
| Without Enhancement | With Enhancement |
|---|---|
| Hibernate probes DB to check if profile is null | Bytecode interceptor handles it — no probe needed |
| Extra query always fires | Truly lazy — query only fires on access |
Option 2 — JOIN FETCH Query
Load both entities together in a single SQL query — profile is already in memory, no probe needed.
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile")
List<User> findAllWithProfile();
Hibernate generates:
SELECT
u.id, u.enabled, u.password, u.username,
p.id, p.address, p.phone
FROM users u
LEFT JOIN user_profile p ON u.id = p.user_id
| Without JOIN FETCH | With JOIN FETCH |
|---|---|
SELECT FROM users + SELECT FROM user_profile (2 queries) |
SELECT FROM users LEFT JOIN user_profile (1 query) |
Option 3 — @NamedEntityGraph
A @NamedEntityGraph defines a pre-declared fetch plan that can be reused across multiple repository methods, avoiding the need to write JOIN FETCH everywhere.
@NamedEntityGraph(
name = "User.withDetails",
attributeNodes = {
@NamedAttributeNode("roles"),
@NamedAttributeNode("profile")
}
)
@Entity
@Table(name = "users")
public class User { ... }
Use it in the repository:
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile")
List<User> findAllWithProfile();
@EntityGraph("User.withDetails") // applies the named graph
List<User> findAll();
}
Combining with @NamedSubgraph for nested associations:
When you need to fetch deeply nested associations (e.g., User → UserProfile → Address), use @NamedSubgraph:
@Entity
@NamedEntityGraph(
name = "User.profile",
attributeNodes = {
@NamedAttributeNode(value = "profile", subgraph = "profile-subgraph")
},
subgraphs = {
@NamedSubgraph(
name = "profile-subgraph",
attributeNodes = {
@NamedAttributeNode("address")
}
)
}
)
public class User { ... }
@Entity
public class UserProfile {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Address address;
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(value = "User.profile", type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findById(Long id);
}
With the entity graph, Hibernate generates a single query:
SELECT u.*, p.*, a.*
FROM users u
LEFT JOIN user_profile p ON p.user_id = u.id
LEFT JOIN address a ON p.address_id = a.id
WHERE u.id = ?
When to use @EntityGraph:
- You want dynamic fetch strategies per query
- You want associations to be LAZY by default
- You want to avoid scattering
JOIN FETCHacross all queries - Different repository methods need different fetch plans
Option 4 — @MapsId Shared Primary Key (Best & Simplest)
Since both entities share the same PK, Hibernate already knows the profile ID without probing. This is the cleanest solution for a strict @OneToOne relationship.
See Section 9 — Deep Dive: @MapsId for full details.
9. Deep Dive — @MapsId
The best way to map a @OneToOne relationship in Hibernate is using @MapsId, which allows the child entity to share the same primary key as the parent entity.
In this approach:
- The child table uses the parent's PK as its own PK
- This avoids creating an additional FK column
- Hibernate already knows the child's ID — no probe query needed
users user_profile
───────────── ──────────────────────
id (PK) ═══ id (PK = FK) ← shared PK, no separate user_id column
username phone
password address
@Entity
@Table(name = "user_profile")
public class UserProfile {
@Id // No @GeneratedValue — value is copied from User
private Long id;
@Column(nullable = false)
private String phone;
@Column(nullable = false)
private String address;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // this entity's PK = users.id
@JoinColumn(name = "id")
private User user;
}
The id column in user_profile serves as both PK and FK — its value is copied directly from User.id.
Key Differences Per Entity
User |
UserProfile |
|
|---|---|---|
@GeneratedValue |
✅ Yes — generates own PK | ❌ No — copies from User
|
| Owns the relationship | No — no profile field needed |
Yes — has @MapsId
|
| Probe query on fetch | Gone — no inverse field | — |
| Save strategy | userRepository.save(user) |
userProfileRepository.save(profile) |
Advantages of @MapsId
- No extra FK column — cleaner schema, better normalization
- No probe query — Hibernate already knows the child's ID
- Efficient joins — PK = FK means the join is on the same column
-
No need for bidirectional association —
Usercan be unidirectional -
UserProfilecan always be fetched directly using theUserID
Downsides of @MapsId
-
Tight coupling —
UserProfilecannot exist withoutUser(shares PK) -
Insert order dependency —
Usermust be persisted beforeUserProfile - Less flexibility — migrating from one-to-one → one-to-many requires schema redesign
-
Harder independent lifecycle management — deleting
UserinvalidatesUserProfile -
Not suitable for optional relationships — if a
Usercan exist without aUserProfile, this gets awkward -
Harder for beginners — shared PK,
@MapsId, and entity lifecycle can be confusing - Schema migration complexity — adding a separate PK to the child table later requires a full refactor
When @MapsId Is Ideal
Use @MapsId when:
- The relationship is strictly one-to-one
- The child cannot exist without the parent
- The child is more like an extension of the parent (e.g.,
UserProfileis just extra columns forUser) - You want zero extra queries and a cleaner schema
10. Comparison — @JoinColumn vs @MapsId
@JoinColumn on child |
@MapsId |
|
|---|---|---|
| Profile FK column | user_profile.user_id |
user_profile.id = users.id
|
| Hibernate knows child ID? | Must probe DB | Already has it |
Extra query on getUser()? |
Always fires (inverse side) | Only when accessed |
| DB schema | Extra user_id column |
Shared PK — cleaner |
| Optional relationship? | ✅ Yes | ❌ Not ideal |
| Child exists independently? | ✅ Can | ❌ Cannot |
| Schema simplicity | Slightly more columns | Minimal columns |
11. Quick Reference Table
| Topic | Recommendation | Notes |
|---|---|---|
| Owning side | Entity holding the FK (@JoinColumn) |
Always the child |
| Inverse side | Entity with mappedBy
|
No FK — navigation only |
@OneToOne fetch (owning) |
FetchType.LAZY |
FK in same row — proxy safe |
@OneToOne fetch (inverse) |
FetchType.LAZY declared, but won't be |
Hibernate probes regardless |
| Probe query fix |
@MapsId or JOIN FETCH
|
@MapsId is cleanest for strict 1:1 |
| N+1 prevention |
JOIN FETCH or @EntityGraph
|
@NamedSubgraph for nested graphs |
| Cascade |
CascadeType.ALL on parent only |
Never cascade from child to parent |
orphanRemoval |
true for owned children |
Handles disassociation too |
equals/hashCode
|
Based on business key | Never based on database ID |
| Collection for roles | Set<Role> |
Safer than List with multiple joins |
| Best overall mapping |
@MapsId for strict 1:1 |
@JoinColumn for optional or flexible |
12. Summary
- In a
@OneToOnerelationship, the child holds the FK (@JoinColumn); the parent usesmappedBy. - Declaring
FetchType.LAZYon the inverse side does not make it truly lazy — Hibernate fires a probe query anyway because there is no FK column to inspect locally. -
Four ways to fix the probe query: bytecode instrumentation (
@LazyToOne),JOIN FETCH,@NamedEntityGraph, or@MapsId. -
@MapsIdis the cleanest solution for strict one-to-one relationships — the child shares the parent's PK, eliminating the extra FK column and the probe query entirely. -
Bidirectional = convenience (
user.getProfile()) but costs a probe query. Unidirectional = no probe but you lose navigation from theUserside. - For most applications — skip bytecode plugins, go unidirectional with
@MapsId, and useJOIN FETCHwhen you need both entities at once. - Always use
CascadeType.ALLon the parent side only — never cascade upward from child to parent. - Base
equals()/hashCode()on a stable business key (e.g.,username), never on the auto-generated database ID.
Top comments (0)