Entity Relationships & Associations

@OneToOne

18 min Lesson 2 of 13

@OneToOne

A one-to-one association is the simplest relationship in a relational model: one row in table A corresponds to exactly one row in table B. In JPA / Hibernate the @OneToOne annotation maps this relationship to Java objects. Knowing where the foreign key lives, and which side owns it, determines how the DDL is generated, how inserts work, and how the association is loaded — so let us walk through every meaningful variant.

The Domain: User and UserProfile

A classic example is a User with a corresponding UserProfile that holds extended bio data. Each user has at most one profile, and each profile belongs to exactly one user.

Variant 1 — Foreign Key on the Owning Side

The most common layout stores the foreign key in the child table (user_profiles). The entity that physically holds the foreign-key column is called the owning side. The other entity is the inverse side (covered in detail in Lesson 5).

// --- Entity: User (inverse side — no FK column here) --- import jakarta.persistence.*; @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; @OneToOne(mappedBy = "user", // field name in UserProfile cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) private UserProfile profile; // getters / setters omitted for brevity }
// --- Entity: UserProfile (owning side — holds the FK column) --- 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; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", // FK column in user_profiles nullable = false, unique = true) // enforces one-to-one at DB level private User user; // getters / setters omitted for brevity }

Hibernate generates a user_id column with a UNIQUE constraint in user_profiles. The unique = true attribute on @JoinColumn is what tells the database to enforce the "at most one" cardinality — without it you would have a many-to-one at the DB level.

mappedBy signals the inverse side. On User, mappedBy = "user" tells Hibernate: "the real foreign-key column lives over there in UserProfile.user". Only one side can own the association; forgetting this causes Hibernate to create two foreign-key columns — one per entity — which is almost always a bug.

Variant 2 — Shared Primary Key

An alternative schema uses the same primary key value for both tables. The child row reuses the parent's PK as its own PK. This is efficient (no extra FK index) and semantically clean when the child cannot exist independently. Use @MapsId to wire this up:

@Entity @Table(name = "user_profiles") public class UserProfile { @Id // same PK as User private Long id; private String bio; @OneToOne(fetch = FetchType.LAZY) @MapsId // copies User's PK into UserProfile.id @JoinColumn(name = "id") private User user; }
Prefer @MapsId when the child has no independent life-cycle. Shared-PK avoids a surrogate FK column, keeps the join on the indexed PK, and makes it impossible to accidentally detach the child from its parent with a stale foreign key.

Persisting a OneToOne Association

Because User owns the cascade policy (CascadeType.ALL), persisting the parent automatically persists the child:

@Service @Transactional public class UserService { private final UserRepository userRepo; public UserService(UserRepository userRepo) { this.userRepo = userRepo; } public User createUser(String email, String bio) { User user = new User(); user.setEmail(email); UserProfile profile = new UserProfile(); profile.setBio(bio); profile.setUser(user); // set the owning side — REQUIRED user.setProfile(profile); // set inverse for in-memory graph consistency return userRepo.save(user);// cascades to profile automatically } }
Always set both sides of the relationship in memory. Hibernate uses the owning side to generate the SQL, so forgetting profile.setUser(user) means the FK column stays NULL. Setting only the owning side is correct for persistence, but setting both keeps your first-level cache consistent and prevents confusing NullPointerExceptions when you later traverse user.getProfile() within the same transaction.

Fetch Type Choices

JPA's default for @OneToOne is EAGER, which means every time you load a User, Hibernate immediately joins and loads the UserProfile. In most applications that is wasteful when the profile is not needed.

  • EAGER (default) — joined in the same SELECT. Simple, but loads data you may not need.
  • LAZY — the profile is loaded only when you call user.getProfile(). Almost always the right choice; requires the session to still be open at access time.

To enable LAZY on the inverse side Hibernate needs to generate a proxy sub-class. This works reliably with byte-code enhancement (enabled by default in Spring Boot 3 via the Hibernate Enhancer plugin) or with the @LazyToOne(LazyToOneOption.NO_PROXY) hint on older setups.

// On the owning side — LAZY is reliable, Hibernate wraps the join entity @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", unique = true) private User user; // On the inverse side — LAZY requires byte-code enhancement or NO_PROXY hint @OneToOne(mappedBy = "user", fetch = FetchType.LAZY) private UserProfile profile;

Reading with Spring Data JPA

The repository looks the same as any other:

public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByEmail(String email); }

Because the profile is LAZY, accessing it inside a @Transactional service method works transparently. Accessing it outside the transaction (in a controller, after the session is closed) throws a LazyInitializationException — the standard solution is to load what you need inside the service or use a DTO projection.

Summary

A @OneToOne association maps two entities that share a one-to-one cardinality. The owning side holds the @JoinColumn (and the real FK column); the inverse side uses mappedBy. For a shared-PK layout use @MapsId. Always declare fetch = LAZY to avoid loading related data you do not need, set both ends of the relationship in memory when persisting, and add unique = true to @JoinColumn so the database enforces the cardinality constraint. The next lesson extends this to the far more common one-to-many and many-to-one relationships.