Appearance
Spring Boot JPA (Part 6) | OneToOne Unidirectional and Bidirectional Mapping
The Citizen and Passport Analogy
Imagine a government agency managing citizen identities. Every legal citizen is issued exactly one passport document, and every passport document belongs to exactly one citizen. This is a classic One to One relationship.
Now consider how records are physically stored. The government agency does not create a redundant database with passports and then duplicate all citizen names inside it. Instead, they choose an owner: in the passport registry, each passport row contains a field: citizen_id = 89012. That foreign key column establishes the relationship on the database side.
In JPA, object relationships have two dimensions:
- Directionality: Can you navigate the relationship from Citizen to Passport only (Unidirectional), or can you also navigate from Passport back to Citizen (Bidirectional)?
- Ownership: Which side of the relationship owns the foreign key column in the relational database table?
This lecture covers OneToOne unidirectional mapping, bidirectional mapping with mappedBy, cascade types (CascadeType.ALL), orphan removal, and the lazy loading trap with OneToOne associations.
The Concept of Relationship Ownership
In a relational database, a one to one relationship between two tables (users and user_profiles) has exactly one foreign key column:
Table: users Table: user_profiles
+----+----------+ +----+----------------+---------+
| id | username | | id | bio | user_id | <-- Foreign Key
+----+----------+ +----+----------------+---------+
| 1 | "alice" | | 10 | "Software Eng" | 1 |
+----+----------+ +----+----------------+---------+Notice that the foreign key user_id lives strictly in the user_profiles table. The users table has no foreign key column.
In JPA:
- The entity that maps to the table containing the foreign key is the Owning Side (declared using
@JoinColumn). - The entity that navigates the relationship without owning the foreign key is the Inverse / Non Owning Side (declared using
mappedBy).
1. Unidirectional OneToOne Mapping
In a unidirectional relationship, only one entity knows about the other.
Here, User knows about UserProfile, but UserProfile has zero knowledge of User:
java
package com.example.orderservice.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
// Unidirectional OneToOne: User owns the foreign key
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
private UserProfile profile;
// Getters and setters
}java
package com.example.orderservice.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "user_profiles")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
// Zero reference to User!
// Getters and setters
}Generated Schema:
sql
CREATE TABLE user_profiles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
bio VARCHAR(255),
avatar_url VARCHAR(255)
);
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255),
profile_id BIGINT,
CONSTRAINT fk_user_profile FOREIGN KEY (profile_id) REFERENCES user_profiles(id)
);2. Bidirectional OneToOne Mapping with mappedBy
In a bidirectional relationship, you can navigate in both directions: user.getProfile() and profile.getUser().
To prevent JPA from creating two foreign key columns in both tables, you must declare the non owning side using mappedBy:
The Owning Side (User):
java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
// Owning side: contains @JoinColumn
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
private UserProfile profile;
// Helper method to keep bidirectional references synchronized
public void setProfile(UserProfile profile) {
this.profile = profile;
if (profile != null) {
profile.setUser(this);
}
}
// Getters and setters
}The Non Owning Side (UserProfile):
java
@Entity
@Table(name = "user_profiles")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
// Non-owning side: mappedBy points to the field name in User class
@OneToOne(mappedBy = "profile", fetch = FetchType.LAZY)
private User user;
// Getters and setters
}Notice:
mappedBy = "profile"tells Hibernate: "Do not create a foreign key column inuser_profiles. The entity fieldprofilein theUserclass already manages this relationship."- Only one foreign key column (
profile_idinusers) is generated in the database.
Cascade Types Explained (CascadeType)
CascadeType controls whether operations performed on the parent entity propagate automatically to the associated child entity:
| Cascade Option | Effect |
|---|---|
CascadeType.PERSIST | Calling entityManager.persist(user) automatically persists the associated profile |
CascadeType.MERGE | Calling entityManager.merge(user) automatically merges updates to profile |
CascadeType.REMOVE | Calling entityManager.remove(user) automatically deletes the associated profile |
CascadeType.REFRESH | Reloading user from database refreshes profile |
CascadeType.ALL | Applies all cascading operations (PERSIST, MERGE, REMOVE, REFRESH, DETACH) |
Example: Saving Parent and Child Together with CascadeType.ALL
java
User user = new User();
user.setUsername("alice");
UserProfile profile = new UserProfile();
profile.setBio("Cloud Architect");
// Helper method links both sides
user.setProfile(profile);
// Because of CascadeType.ALL, saving user automatically inserts BOTH user and profile rows!
userRepository.save(user);Without CascadeType.PERSIST or CascadeType.ALL, calling userRepository.save(user) throws an IllegalStateException: object references an unsaved transient instance.
orphanRemoval = true vs CascadeType.REMOVE
A frequent interview question tests the difference between orphanRemoval = true and CascadeType.REMOVE:
java
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "profile_id")
private UserProfile profile;The Difference:
CascadeType.REMOVE: If you delete theUser(userRepository.delete(user)), the correspondingUserProfilerow is deleted from the database. But if you sever the relationship by settinguser.setProfile(null)and saving the user, the profile row remains in the database as an orphaned row.orphanRemoval = true: In addition to deleting the child when the parent is deleted, if you sever the association in Java (user.setProfile(null)), Hibernate detects that the child is now an orphan and automatically issues aDELETE FROM user_profiles WHERE id = ...SQL statement.
The Lazy Loading Trap with @OneToOne
By default, @OneToOne associations use FetchType.EAGER. Eager fetching is dangerous: querying twenty users executes twenty extra SQL joins or queries to fetch twenty profiles, even if your code only needed usernames.
You should always configure fetch = FetchType.LAZY:
java
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private UserProfile profile;The Critical Exception: Non Owning Side
On the non owning side (mappedBy = "profile"):
- In standard Hibernate,
FetchType.LAZYon the non owning side of a@OneToOnerelationship is silently ignored and falls back to EAGER! - Why? Because in the database, the foreign key is in the other table. To know whether
user.getProfile()should returnnullor a dynamic CGLIB proxy, Hibernate must check whether a profile row actually exists. It cannot determine this without querying the database immediately. - To achieve true lazy loading on the non owning side, you must enable bytecode enhancement or use an
@Embeddablemapping.
Interview Questions & Pitfalls
Q1: What does the mappedBy attribute signify in a bidirectional @OneToOne mapping?
mappedBy is placed on the non owning (inverse) side of the relationship. It informs Hibernate that this side does not own the foreign key column in the database, and points to the name of the corresponding association field in the owning entity.
Q2: What is the difference between CascadeType.REMOVE and orphanRemoval = true?
CascadeType.REMOVE deletes the child entity only when the parent entity is explicitly deleted. orphanRemoval = true goes a step further: if you sever the association by setting the child reference to null (user.setProfile(null)) and save the parent, Hibernate automatically deletes the orphaned child row from the database.
Q3: Why is bidirectional relationship synchronization code (helper methods) necessary?
In memory Java objects do not automatically synchronize when you set one side of a bidirectional relationship. If you write user.setProfile(profile), profile.getUser() will remain null in memory until you explicitly set both sides or reload from the database. Helper methods (e.g. setProfile assigning this to the child) guarantee consistency across both objects in memory.
Q4: Why does FetchType.LAZY often fail to work on the non owning side of a @OneToOne relationship?
The non owning entity table does not have a foreign key column. To determine whether the field should be set to null or populated with a proxy object, Hibernate is forced to query the database immediately to check for the existence of the child record, causing lazy loading to fall back to eager loading.
Q5: What happens if both entities in a bidirectional @OneToOne mapping declare @JoinColumn without mappedBy?
Hibernate interprets this as two separate, independent unidirectional relationships. It will generate two distinct foreign key columns (one in each table), creating redundant columns and data synchronization bugs. One side must use @JoinColumn and the other must use mappedBy.