Blog

One To One Example | Spring Data JPA

Key Takeaways

  • A Spring Data JPA one-to-one relationship can use a shared primary key, a unique foreign key, or a join table.
  • The owning side writes the association; the inverse side names that Java field with mappedBy. Keep both references synchronized.
  • @MapsId ties the dependent entity’s identity to its parent. It can eliminate a separate identifier column, but it isn’t universally the best mapping.
  • Enforce cardinality with database constraints, and verify fetching with SQL rather than assuming EAGER means one query.

To map a Spring Data JPA one-to-one relationship, annotate the association with @OneToOne, choose where the relationship lives in the schema, and enforce uniqueness in the database. The three options below use a shared primary key, a direct foreign key, and a join table.

We’ll keep Author and Book as a deliberately simplified model: an author can be linked to at most one book, and a book to at most one author. If your authors write multiple books, use a one-to-many relationship instead.

Setup and Mapping Choices

The example baseline is Java 21, Spring Boot 4.0, Spring Data JPA 4.0, and Jakarta Persistence 3.2. Keep dependency versions managed by Spring Boot; its dependency coordinates identify the compatible releases.

Create a Maven application using Spring Initializr, selecting Spring Data JPA and H2 Database. Keep the generated application class, put the following Java files in its package, and use these dependencies in the generated POM. The test dependency below is the Spring Boot 4 JPA test starter.

<!-- Fragment: dependencies inside the generated Spring Boot POM. -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa-test</artifactId>
    <scope>test</scope>
</dependency>

Use jakarta.persistence imports for this stack. Older Spring Boot 2 examples use javax.persistence; the Spring Boot 3 migration guide explains the namespace transition. If repositories are new to you, start with the Spring Data JPA CRUD example.

MappingRelationship storageOwner hereChoose it when
Shared primary keybook.author_id is both PK and FKBook.authorThe book’s identity depends on its author
Unique foreign keyauthor.book_id references book.idAuthor.bookEntities need independent identities
Join tableauthor_book contains two unique FKsAuthor.bookAn existing schema or separate association table calls for it

Owning Side Versus Inverse Side

The owning side determines relationship updates in the database. With a direct FK, it’s the entity whose table contains that FK; with a join table, it’s the association declaring @JoinTable. The inverse side uses mappedBy, which names a Java field or property—not a database column. Updating only the inverse reference can leave the database association unchanged.

Ownership doesn’t decide which entities get inserted. Explicit persistence operations and cascade configuration do that. Use a unidirectional mapping if you don’t need navigation in both directions; the second Java reference isn’t required to represent the relationship.

1) @OneToOne Example Using a Shared Primary Key

Here, Book depends on Author. Its primary key comes from the author through @MapsId, and the same column is also a foreign key. No separate ID generator belongs on Book.

author                         book
id [PK] <--------------------- author_id [PK, FK]

Owner: Book.author
Inverse: Author.book -- mappedBy="author" --> Book.author
Lifecycle: persist Author --> derive Book.id --> persist Book

Author.java — complete entity; replace the package name with your generated application’s package.

package com.example.onetoone;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.util.Objects;

@Entity
@Table(name = "author")
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(mappedBy = "author", fetch = FetchType.LAZY)
    private Book book;

    protected Author() {}

    public Long getId() { return id; }
    public Book getBook() { return book; }

    public void attachBook(Book book) {
        Objects.requireNonNull(book, "book");
        if (this.book != null || book.getAuthor() != null) {
            throw new IllegalStateException("Author or book already linked");
        }
        this.book = book;
        book.attachTo(this);
    }
}

Book.java

package com.example.onetoone;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;

@Entity
@Table(name = "book")
public class Book {
    @Id
    private Long id;

    @MapsId
    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;

    protected Book() {}

    public Long getId() { return id; }
    public Author getAuthor() { return author; }

    void attachTo(Author author) {
        this.author = author;
    }
}

The helper updates both Java references and intentionally supports initial attachment only. Don’t reassign an existing shared-PK book to another author: that would change its persistent identity. @PrimaryKeyJoinColumn isn’t needed on the inverse field; @MapsId expresses the derived identity here.

Persist the Parent Before the Dependent

Create these repositories as separate files. The explicit fetch query is explained in the fetching section below; Spring Data supports @Query with named @Param parameters.

// AuthorRepository.java
package com.example.onetoone;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface AuthorRepository extends JpaRepository<Author, Long> {
    @Query("select a from Author a left join fetch a.book where a.id = :id")
    Optional<Author> findWithBookById(@Param("id") Long id);
}
// BookRepository.java
package com.example.onetoone;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {}

The service saves both entities explicitly, without cascades. Its transaction covers both repository calls.

package com.example.onetoone;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {
    private final AuthorRepository authorRepo;
    private final BookRepository bookRepo;

    public CatalogService(AuthorRepository authorRepo, BookRepository bookRepo) {
        this.authorRepo = authorRepo;
        this.bookRepo = bookRepo;
    }

    @Transactional
    public Long createPair() {
        Author author = authorRepo.save(new Author());
        Book book = new Book();
        author.attachBook(book);
        bookRepo.save(book);
        return author.getId();
    }
}

Illustrative rows after commit—not promised generated ID values:

author                 book
id                     author_id
24                     24

Why Shared Identity Isn’t Always Best

This schema avoids a separate book.id column alongside an author_id FK. The trade-off is identity coupling: a book cannot exist without its author, while an author can exist without a book. A smaller schema alone doesn’t prove faster queries or lower operating costs; choose this design for the lifecycle, then measure your workload.

2) @OneToOne Example Using a Unique Foreign Key

For independent identities, keep a generated ID on each entity and put book_id on author. Starting from the previous entities, replace the fields shown below; keep the constructors, getters, and attachment helper.

// Fragments: replacement fields inside the existing entities.

// Author.java: add import jakarta.persistence.JoinColumn;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", unique = true, nullable = true)
private Book book;

// Book.java: add imports for GeneratedValue and GenerationType.
// Remove @MapsId and the old owning association mapping.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@OneToOne(mappedBy = "book", fetch = FetchType.LAZY)
private Author author;
author                               book
id [PK]                              id [PK]
book_id [FK, UNIQUE, nullable] ------> id

Owner: Author.book
Inverse: Book.author -- mappedBy="book" --> Author.book

A foreign key enforces a valid reference; uniqueness prevents multiple authors from referencing the same book. JPA’s default one-to-one FK mapping includes a unique constraint, but an annotation doesn’t retrofit an independently managed database. Explicit unique = true makes the intent visible; verify the actual migration and constraints.

Run the Foreign-Key Example

Replace the body of CatalogService.createPair() with this fragment. Persist the book before the author that references it; the helper still updates both sides.

// Fragment: replacement createPair() body, inside its existing transaction.
Book book = bookRepo.save(new Book());
Author author = new Author();
author.attachBook(book);
return authorRepo.save(author).getId();
book                   author
id                     id | book_id
30                     45 | 30

The extra FK column stores the association; it isn’t caused by bidirectional navigation. Unlike the shared-PK version, the book has its own identity, and the nullable FK allows an author without a book.

3) @OneToOne Example Using a Join Table

Keep the independently generated IDs and Book.author from option 2. Replace only Author.book with this mapping, adding the jakarta.persistence.JoinTable import.

// Fragment: replacement Author.book field.
@OneToOne(fetch = FetchType.LAZY)
@JoinTable(
    name = "author_book",
    joinColumns = @JoinColumn(
        name = "author_id", referencedColumnName = "id",
        nullable = false, unique = true
    ),
    inverseJoinColumns = @JoinColumn(
        name = "book_id", referencedColumnName = "id",
        nullable = false, unique = true
    )
)
private Book book;
author                  author_book                 book
id [PK] <-------------- author_id [FK, UNIQUE, NN]
                        book_id   [FK, UNIQUE, NN] --> id [PK]

NN = NOT NULL
Owner: Author.book
Inverse: Book.author -- mappedBy="book" --> Author.book

joinColumns references the owning entity; inverseJoinColumns references the associated entity. Each FK is individually unique. A unique constraint on only the pair would still allow the same author or book in multiple associations.

Run the Join-Table Example

Use the same transactional createPair() body as option 2: save the book, attach both references, and save the author. Illustrative rows are:

author       book         author_book
id           id           author_id | book_id
14           10           14        | 10

Why Use a Join Table?

A join table is useful when you must match an existing schema or keep association storage outside either entity table. An absent relationship can be represented by no join row instead of a nullable entity-table FK. The cost is another table, another association write, and a longer join path when fetching the pair.

Optionality, Constraints, and Lifecycle

For a mandatory direct-FK association, replace the option 2 field with:

// Fragment: mandatory Author.book field.
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "book_id", unique = true, nullable = false)
private Book book;
  • optional = false declares that the entity association must be non-null.
  • nullable = false declares that the mapped FK column is non-nullable.
  • Actual NOT NULL, UNIQUE, and FK constraints enforce those rules for database writes, including concurrent writers and clients outside your application.

Keep the mapping and database aligned. Generated DDL varies by provider and database; don’t rely on a particular exception type or on the error appearing at the save() call rather than flush or commit.

Cascade and Orphan Removal Are Different Decisions

No cascades are enabled in these examples. CascadeType.PERSIST propagates persistence, while CascadeType.ALL also includes MERGE, REMOVE, REFRESH, and DETACH. Adding ALL can therefore make deleting an author delete its book through that association. See the cascade operation definitions.

orphanRemoval = true schedules removal of a managed dependent when it’s disconnected from the association. Use it for privately owned dependents, not as a convenient default. Cascade direction matters: a cascade on Author.book isn’t traversed merely because you save Book.

Fetching: EAGER Doesn’t Mean One Query

@OneToOne defaults to FetchType.EAGER; LAZY is a provider hint. Eager loading requires the association to be fetched, but Hibernate can satisfy that through joins or additional selects. Loading a list can therefore produce N+1 queries: one list query followed by extra association queries.

The examples request LAZY, but inverse one-to-one loading needs special attention. Hibernate may need to query the dependent table to determine whether a row exists; reliable lazy loading in some bidirectional scenarios requires bytecode enhancement. Check the Hibernate association-fetching documentation for your provider release.

The repository’s left join fetch a.book explicitly fetches the book while retaining authors without one. Spring Data’s @EntityGraph(attributePaths = "book") is another fetch-plan option, not a portable guarantee of one SQL statement.

Repository findById() returns an Optional, not an entity. Handle the missing row explicitly and access lazy state within the intended transaction:

// Fragment: inside a transactional reader with authorRepo and id supplied.
Author author = authorRepo.findById(id)
    .orElseThrow(() -> new IllegalArgumentException("Unknown author: " + id));
Book book = author.getBook(); // May be null; may require another SQL query.

How @OneToOne Works Behind the Scenes

Jakarta Persistence, still commonly called JPA, defines mapping annotations and persistence semantics. Hibernate is an object-relational mapping provider that implements them; Spring Data JPA adds repository abstractions on top. These are different layers, as explained in Spring Data JPA versus Hibernate.

Hibernate builds mapping metadata, tracks managed entity changes, and translates persistence operations and queries into SQL. Schema creation is a separate configuration choice—not an automatic consequence of adding an annotation.

Besides eager fetching, the annotation defaults are optional = true, no cascades, orphanRemoval = false, and an empty mappedBy. Set the options that express your domain rather than copying every option into every relationship.

Verify the Mapping Before Shipping

For option 1, put this test under src/test/java in the same package. It flushes the inserts, clears the persistence context, and reloads the pair rather than merely checking the objects already in memory. Spring Boot’s @DataJpaTest logs SQL by default and rolls back test transactions.

package com.example.onetoone;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;

@DataJpaTest
@Import(CatalogService.class)
class SharedPrimaryKeyTest {
    @Autowired CatalogService catalog;
    @Autowired AuthorRepository authorRepo;
    @PersistenceContext EntityManager entityManager;

    @Test
    void persistsAndReloadsSharedIdentity() {
        Long id = catalog.createPair();
        entityManager.flush();
        entityManager.clear();

        Author author = authorRepo.findWithBookById(id).orElseThrow();
        assertEquals(id, author.getBook().getId());
        assertSame(author, author.getBook().getAuthor());
    }
}
  • Inspect the generated schema for the PK, FK, uniqueness, and nullability shown in each diagram.
  • For option 2, test duplicate non-null author.book_id values. For option 3, test duplicate values in each join column separately. The database should reject them.
  • Test allowed missing associations, disallowed missing associations, and your chosen deletion behavior.
  • Count SQL statements when fetching both one pair and a list; don’t infer query count from annotations.
  • Run constraint tests against your production database engine, not only the demonstration H2 database.

FAQ

Which side owns a JPA @OneToOne relationship?

For a direct foreign-key mapping, the entity whose table contains the FK owns the relationship. For a join-table mapping, the association declaring @JoinTable owns it. The inverse side uses mappedBy to name the owning Java field or property.

Does @OneToOne automatically create a unique foreign key?

The default JPA one-to-one foreign-key mapping includes a unique constraint when the provider generates the schema. It doesn’t add constraints to an independently managed schema by itself; declare the intended uniqueness explicitly and verify your database migration.

How do I map a shared-primary-key one-to-one relationship with @MapsId?

Give the dependent entity an @Id and annotate its parent association with @MapsId and @JoinColumn. Don’t generate a separate dependent ID. Persist the parent first, or configure an appropriate persistence cascade, and assign the parent reference before persisting the dependent.

Does FetchType.EAGER guarantee one SQL query?

No. EAGER requires the association to be fetched, but it doesn’t prescribe the SQL strategy or number of round trips. Use an explicit fetch plan and verify the generated SQL for your query and provider.

What is the difference between optional=false and nullable=false?

optional=false declares a mandatory entity association; nullable=false declares a non-nullable mapped join column. Keep both aligned with actual database constraints. Neither a non-null join-table column nor an inverse-side annotation alone guarantees that every parent has an association row.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me