Spring Boot
OneToOne
@OneToOne maps a relationship where one entity instance is associated with exactly one instance of another entity. The most common examples are User → UserProfile, Order → ShippingAddress, and Employee → ParkingSpot. Spring Boot and Hibernate support both unidirectional and bidirectional one-to-one mappings, with the owning side holding the foreign key column.
Unidirectional @OneToOne
In a unidirectional one-to-one, only one side holds a reference to the other. The owning entity carries the foreign key column via @JoinColumn. The referenced entity has no knowledge of the relationship.
Java
// ── Owning side — holds the foreign key column: ──────────────────────
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String reference;
// Owning side: orders.shipping_address_id FK column
@OneToOne(
cascade = CascadeType.ALL, // persist/delete address with order
fetch = FetchType.LAZY, // ALWAYS use LAZY for @OneToOne
orphanRemoval = true // delete address when removed from order
)
@JoinColumn(
name = "shipping_address_id", // FK column in orders table
nullable = false,
unique = true // enforces one-to-one at DB level
)
private ShippingAddress shippingAddress;
protected Order() { }
public Order(String reference, ShippingAddress address) {
this.reference = reference;
this.shippingAddress = address;
}
public Long getId() { return id; }
public String getReference() { return reference; }
public ShippingAddress getShippingAddress() { return shippingAddress; }
public void setShippingAddress(ShippingAddress address) {
this.shippingAddress = address;
}
}
// ── Referenced side — no reference back to Order: ─────────────────────
@Entity
@Table(name = "shipping_addresses")
public class ShippingAddress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String street;
@Column(nullable = false)
private String city;
@Column(nullable = false, length = 2)
private String countryCode;
protected ShippingAddress() { }
public ShippingAddress(String street, String city, String countryCode) {
this.street = street;
this.city = city;
this.countryCode = countryCode;
}
public Long getId() { return id; }
public String getStreet() { return street; }
public String getCity() { return city; }
public String getCountryCode() { return countryCode; }
}Bidirectional @OneToOne
A bidirectional one-to-one lets both sides navigate to each other. One side owns the foreign key (mappedBy is absent); the other is the inverse side (mappedBy points to the owning field). Always synchronise both sides when setting the relationship.
Java
// ── Owning side — holds the FK column (no mappedBy): ─────────────────
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
// Owning side — users.profile_id FK column:
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY,
orphanRemoval = true)
@JoinColumn(name = "profile_id", unique = true)
private UserProfile profile;
// Convenience method — always set both sides together:
public void setProfile(UserProfile profile) {
this.profile = profile;
if (profile != null) {
profile.setUser(this);
}
}
protected User() { }
public User(String email) { this.email = email; }
public Long getId() { return id; }
public String getEmail() { return email; }
public UserProfile getProfile() { return profile; }
}
// ── Inverse side — mappedBy references the owning field: ─────────────
@Entity
@Table(name = "user_profiles")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
// Inverse side — no FK column here, no cascade (owner manages it):
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
protected UserProfile() { }
public UserProfile(String bio, String avatarUrl) {
this.bio = bio;
this.avatarUrl = avatarUrl;
}
// Package-private setter — only User.setProfile() should call this:
void setUser(User user) { this.user = user; }
public Long getId() { return id; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
public String getAvatarUrl() { return avatarUrl; }
public User getUser() { return user; }
}
// ── Service — always use the convenience method: ─────────────────────
@Transactional
public UserResponse createUserWithProfile(CreateUserRequest req) {
User user = new User(req.email());
UserProfile profile = new UserProfile(req.bio(), req.avatarUrl());
user.setProfile(profile); // sets both sides — do not set separately
return UserResponse.from(userRepository.save(user));
}Shared Primary Key @OneToOne
The shared primary key pattern makes the dependent entity use the same ID as the owning entity. The dependent entity's primary key column is also the foreign key. This is the most normalised one-to-one mapping and avoids a separate FK column.
Java
// ── Owner entity — standard ID generation: ────────────────────────────
@Entity
@Table(name = "employees")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@OneToOne(mappedBy = "employee", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private EmployeeDetail detail;
public void setDetail(EmployeeDetail detail) {
this.detail = detail;
if (detail != null) detail.setEmployee(this);
}
protected Employee() { }
public Employee(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
public EmployeeDetail getDetail() { return detail; }
}
// ── Dependent entity — @MapsId shares the primary key with the owner: ─
@Entity
@Table(name = "employee_details")
public class EmployeeDetail {
// Same PK as Employee — no separate @GeneratedValue:
@Id
private Long id;
private String department;
private String officeLocation;
private BigDecimal salary;
// @MapsId uses the Employee PK as this entity's PK:
@OneToOne(fetch = FetchType.LAZY)
@MapsId // employee_details.id = employees.id
@JoinColumn(name = "id")
private Employee employee;
protected EmployeeDetail() { }
public EmployeeDetail(String department, String location, BigDecimal salary) {
this.department = department;
this.officeLocation = location;
this.salary = salary;
}
void setEmployee(Employee employee) {
this.employee = employee;
this.id = employee.getId();
}
public Long getId() { return id; }
public String getDepartment() { return department; }
public Employee getEmployee() { return employee; }
}
// ── Schema produced: ──────────────────────────────────────────────────
// employees: id (PK), name
// employee_details: id (PK, FK → employees.id), department, office_location, salary
// No separate FK column — the PK IS the FK.Fetch Strategy and the N+1 Problem
Hibernate defaults @OneToOne to FetchType.EAGER, which causes a second SELECT for every parent row loaded — even when the association is not needed. Always declare FetchType.LAZY and use JOIN FETCH in queries when the association is required.
Java
// ── WRONG — default EAGER fetch: ─────────────────────────────────────
@OneToOne(cascade = CascadeType.ALL) // FetchType.EAGER is the default
@JoinColumn(name = "profile_id")
private UserProfile profile;
// Loading 100 users → 100 SELECTs for users + 100 SELECTs for profiles = 200 queries
// ── CORRECT — always declare LAZY: ───────────────────────────────────
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private UserProfile profile;
// Loading 100 users → 1 SELECT for users; profile loaded only when accessed
// ── IMPORTANT CAVEAT — Hibernate and lazy @OneToOne: ─────────────────
// Hibernate cannot make the NON-owning side of a bidirectional @OneToOne
// truly lazy without bytecode enhancement. On the inverse side (mappedBy),
// Hibernate must issue a SELECT to determine whether the association
// is null or not, even with FetchType.LAZY declared.
//
// The OWNING side (the one with @JoinColumn) IS truly lazy.
// Prefer placing the FK on the side you query most — that side is truly lazy.
// Or use @LazyToOne(LazyToOneOption.NO_PROXY) with bytecode enhancement.
// ── Repository — JOIN FETCH when association is needed: ───────────────
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Fetch user with profile in one query:
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile WHERE u.id = :id")
Optional<User> findByIdWithProfile(@Param("id") Long id);
// Fetch all users with profiles:
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile")
List<User> findAllWithProfile();
}Cascade and OrphanRemoval
CascadeType controls which lifecycle operations on the owning entity are propagated to the associated entity. orphanRemoval = true deletes the associated entity when it is removed from the relationship — distinct from CascadeType.REMOVE.
Java
// ── CascadeType options: ──────────────────────────────────────────────
@OneToOne(cascade = CascadeType.ALL) // all operations cascaded
@OneToOne(cascade = CascadeType.PERSIST) // only save cascaded
@OneToOne(cascade = CascadeType.MERGE) // only merge cascaded
@OneToOne(cascade = CascadeType.REMOVE) // only delete cascaded
@OneToOne(cascade = {CascadeType.PERSIST,
CascadeType.MERGE}) // save + merge cascaded
// ── CascadeType.ALL vs orphanRemoval: ─────────────────────────────────
// CascadeType.REMOVE — deletes the profile WHEN the user is deleted:
userRepository.delete(user);
// → DELETE FROM user_profiles WHERE id = ?
// → DELETE FROM users WHERE id = ?
// orphanRemoval = true — deletes the profile WHEN it is unlinked:
user.setProfile(null);
userRepository.save(user);
// → DELETE FROM user_profiles WHERE id = ? (profile is now orphaned)
// CascadeType.ALL without orphanRemoval:
user.setProfile(null);
userRepository.save(user);
// → UPDATE users SET profile_id = NULL (profile row remains — orphan)
// ── Recommended for dependent entities (profile owned by user): ───────
@OneToOne(
cascade = CascadeType.ALL,
fetch = FetchType.LAZY,
orphanRemoval = true // removing the association deletes the profile
)
@JoinColumn(name = "profile_id")
private UserProfile profile;
// ── Recommended for independent entities (address shared across orders): -
@OneToOne(
cascade = {CascadeType.PERSIST, CascadeType.MERGE}, // no REMOVE
fetch = FetchType.LAZY,
orphanRemoval = false // address may be referenced elsewhere
)
@JoinColumn(name = "address_id")
private Address billingAddress;