Spring Boot One-to-One Mapping with JPA

Learning Objectives

  • Understand how to implement one-to-one relationships between entities using JPA annotations
  • Configure bidirectional one-to-one mappings with proper foreign key management
  • Use Spring Data JPA repositories to persist and query related entities
  • Set up an H2 in-memory database with seed data for testing one-to-one relationships

Introduction

Database relationships are the backbone of any real application. You've got users, profiles, orders, payments—all these entities need to connect in meaningful ways. The one-to-one relationship is the simplest of the bunch, but getting it right in Spring Boot requires understanding a few critical decisions about ownership, cascading, and fetch strategies.

Think about a user account system. Every user has exactly one profile with their bio, preferences, and settings. That profile belongs to one user, nobody else. This is a textbook one-to-one relationship. You could jam all that data into a single user table, but that gets messy fast. Separating concerns keeps your domain model clean and your database normalized.

We're going to build exactly that: a User entity and a Profile entity linked through JPA. We'll use H2 for quick in-memory testing, seed some data, and see how Spring Boot handles the persistence layer. By the end, you'll know how to set up these relationships and avoid the common traps that catch people off guard.

Setting Up the Spring Boot Project

Start with Spring Initializr and grab the dependencies you need: Spring Web, Spring Data JPA, and H2 Database. That's it. H2 gives us a lightweight in-memory database that's perfect for development and testing without the overhead of running PostgreSQL or MySQL locally.

Your application.properties needs a few configurations to enable H2's console and show SQL statements:

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.h2.console.enabled=true

The ddl-auto=create-drop setting tells Hibernate to create your schema when the application starts and drop it when it shuts down. Perfect for development, terrible for production. The show-sql flag logs every SQL statement Hibernate generates, which is incredibly useful when you're learning how JPA translates your annotations into actual database operations.

Creating the User Entity

The User entity is straightforward. It represents a user account with basic information. Here's where we define one side of the one-to-one relationship:

package javapro.academy.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String username;
    private String email;
    
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private Profile profile;
    
    public User() {}
    
    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }
    
    // Getters and setters
    public Long getId() {
        return id;
    }
    
    public void setId(Long id) {
        this.id = id;
    }
    
    public String getUsername() {
        return username;
    }
    
    public void setUsername(String username) {
        this.username = username;
    }
    
    public String getEmail() {
        return email;
    }
    
    public void setEmail(String email) {
        this.email = email;
    }
    
    public Profile getProfile() {
        return profile;
    }
    
    public void setProfile(Profile profile) {
        this.profile = profile;
        if (profile != null) {
            profile.setUser(this);
        }
    }
}

Notice the @OneToOne annotation with mappedBy = "user". This tells JPA that User is the inverse side of the relationship. The Profile entity owns the foreign key, not User. That's a crucial distinction. The mappedBy attribute points to the field name in the Profile class that defines the owning side.

Cascade settings matter here. CascadeType.ALL means any operation on User (persist, merge, remove) cascades to Profile. Create a user, the profile gets created. Delete a user, the profile goes too. The orphanRemoval = true setting handles the case where you null out the profile reference—JPA will delete that orphaned profile from the database automatically.

The setter method for profile does something important: it maintains bidirectional consistency. When you set a profile on a user, it also sets the user reference on that profile. This prevents the nightmare scenario where your object graph is inconsistent in memory even though the database state would eventually be correct.

Creating the Profile Entity

Profile is the owning side of the relationship. It holds the foreign key that references the User:

package javapro.academy.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "profiles")
public class Profile {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String bio;
    private String avatarUrl;
    private String location;
    
    @OneToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
    
    public Profile() {}
    
    public Profile(String bio, String avatarUrl, String location) {
        this.bio = bio;
        this.avatarUrl = avatarUrl;
        this.location = location;
    }
    
    // Getters and setters
    public Long getId() {
        return id;
    }
    
    public void setId(Long id) {
        this.id = id;
    }
    
    public String getBio() {
        return bio;
    }
    
    public void setBio(String bio) {
        this.bio = bio;
    }
    
    public String getAvatarUrl() {
        return avatarUrl;
    }
    
    public void setAvatarUrl(String avatarUrl) {
        this.avatarUrl = avatarUrl;
    }
    
    public String getLocation() {
        return location;
    }
    
    public void setLocation(String location) {
        this.location = location;
    }
    
    public User getUser() {
        return user;
    }
    
    public void setUser(User user) {
        this.user = user;
    }
}

The @JoinColumn annotation defines the foreign key column in the profiles table. The column named user_id will store the reference to the users table's primary key. Setting nullable = false enforces that every profile must belong to a user—no orphaned profiles allowed at the database level.

Why make Profile the owning side instead of User? Performance and control. When you load a User, you might not always need the Profile data. Making Profile own the relationship gives you more flexibility with fetch strategies. Plus, conceptually, a profile exists because of a user, not the other way around.

Creating the Repositories

Spring Data JPA repositories eliminate the need for boilerplate DAO code. Define interfaces that extend JpaRepository and you get CRUD operations for free:

package javapro.academy.repository;

import javapro.academy.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    Optional<User> findByEmail(String email);
}
package javapro.academy.repository;

import javapro.academy.entity.Profile;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ProfileRepository extends JpaRepository<Profile, Long> {
}

The JpaRepository interface provides methods like save(), findById(), findAll(), and delete(). Spring Data JPA also parses method names to generate queries automatically. findByUsername becomes a query that filters users by the username field. It's convention over configuration at its finest.

Returning Optional instead of null is a modern Java practice that forces callers to handle the absence of a value explicitly. No more NullPointerExceptions from forgetting to check if a user exists.

Seeding Data with CommandLineRunner

H2 starts empty. For testing and development, you need sample data. Spring Boot's CommandLineRunner interface lets you execute code after the application context loads. Perfect for seeding the database:

package javapro.academy.config;

import javapro.academy.entity.Profile;
import javapro.academy.entity.User;
import javapro.academy.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataSeeder {
    
    @Bean
    CommandLineRunner initDatabase(UserRepository userRepository) {
        return args -> {
            User user1 = new User("john_doe", "john@example.com");
            Profile profile1 = new Profile(
                "Software developer with a passion for Java and Spring Boot",
                "https://example.com/avatars/john.jpg",
                "San Francisco, CA"
            );
            user1.setProfile(profile1);
            
            User user2 = new User("jane_smith", "jane@example.com");
            Profile profile2 = new Profile(
                "Full-stack engineer specializing in microservices architecture",
                "https://example.com/avatars/jane.jpg",
                "Austin, TX"
            );
            user2.setProfile(profile2);
            
            User user3 = new User("bob_wilson", "bob@example.com");
            Profile profile3 = new Profile(
                "DevOps enthusiast and cloud infrastructure expert",
                "https://example.com/avatars/bob.jpg",
                "Seattle, WA"
            );
            user3.setProfile(profile3);
            
            userRepository.save(user1);
            userRepository.save(user2);
            userRepository.save(user3);
            
            System.out.println("Database seeded with " + userRepository.count() + " users");
        };
    }
}

This configuration class defines a bean that returns a CommandLineRunner. The lambda receives the UserRepository through dependency injection. We create three users, each with a profile, and save them using the repository.

Because we set CascadeType.ALL on the User entity, saving the user automatically persists the associated profile. You don't need to call profileRepository.save() separately. Hibernate handles the cascade operation, inserts the user first to get the generated ID, then inserts the profile with the foreign key reference.

The console output confirms the data loaded. When you run the application, check the logs—you'll see the SQL INSERT statements Hibernate generates for both users and profiles.

Creating a Simple REST Controller

Let's expose this data through a REST API so we can actually see it working:

package javapro.academy.controller;

import javapro.academy.entity.User;
import javapro.academy.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @Autowired
    private UserRepository userRepository;
    
    @GetMapping
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        return userRepository.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
    
    @GetMapping("/username/{username}")
    public ResponseEntity<User> getUserByUsername(@PathVariable String username) {
        return userRepository.findByUsername(username)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

This controller gives you three endpoints: get all users, get a user by ID, and get a user by username. When you fetch a user, JPA loads the associated profile because of the one-to-one relationship. The JSON response will include the profile data nested within the user object.

Be careful with bidirectional relationships and JSON serialization. By default, Jackson (Spring's JSON library) can get into infinite loops trying to serialize User → Profile → User → Profile endlessly. You'll need to add @JsonManagedReference on User's profile field and @JsonBackReference on Profile's user field to break the cycle, or use DTOs to control exactly what data gets serialized.

Testing the Application

Run the application and hit the H2 console at http://localhost:8080/h2-console. Use the JDBC URL from your properties file (jdbc:h2:mem:testdb) and connect. You'll see the users and profiles tables with your seed data.

Query the data directly in H2:

SELECT * FROM users;
SELECT * FROM profiles;
SELECT u.username, p.bio, p.location 
FROM users u 
JOIN profiles p ON u.id = p.user_id;

The join query shows how the foreign key links the tables. Each profile has a user_id that matches exactly one user's id. That's the one-to-one constraint in action.

Now test the REST endpoints. Hit http://localhost:8080/api/users and you'll get all three users with their profiles. Try http://localhost:8080/api/users/1 to get a specific user. The profile data comes along automatically because of the relationship mapping.

Watch the console logs to see the SQL Hibernate generates. You'll notice it might issue separate SELECT statements for users and profiles depending on fetch strategy. By default, @OneToOne uses eager fetching, meaning it loads the profile immediately when you load the user. You can change that to fetch = FetchType.LAZY if you want to defer loading the profile until you actually access it, but lazy loading has its own complications with session management and proxy objects.

Common Pitfalls and How to Avoid Them

The mappedBy attribute trips people up constantly. If you put it on both sides or on the wrong side, Hibernate creates extra join tables or throws exceptions. Remember: mappedBy goes on the inverse side (User), and @JoinColumn goes on the owning side (Profile).

Forgetting to maintain bidirectional consistency in your setters causes subtle bugs. Your in-memory object graph might have a user pointing to a profile, but the profile's user reference is null. Then you try to access profile.getUser().getUsername() and boom, NullPointerException. Always set both sides of the relationship in your convenience methods.

Cascade operations can delete data you didn't mean to delete. If you remove a user, the profile gets deleted too because of CascadeType.ALL. That's usually what you want, but make sure you understand the implications. Accidentally orphaning records or cascading deletes to unintended entities has caused more than one production incident.

Lazy loading seems great until you get LazyInitializationException. The Hibernate session closes after the repository method returns, and if you try to access a lazy-loaded collection or relationship outside that session, Hibernate can't fetch the data. The exception tells you "no Session" or "could not initialize proxy." Solutions include using @Transactional to keep the session open longer, explicitly fetching what you need with JOIN FETCH queries, or switching to eager fetching (which has performance trade-offs).

Summary

One-to-one relationships in Spring Boot with JPA require understanding ownership and cascade behavior. The owning side holds the foreign key and uses @JoinColumn. The inverse side uses mappedBy to reference the owning side's field name. This distinction affects how Hibernate generates the schema and manages persistence operations.

Bidirectional relationships give you navigation in both directions—from user to profile and profile to user. Maintaining consistency on both sides of the relationship prevents bugs and makes your domain model more robust. Helper methods in your setters enforce this consistency automatically.

Spring Data JPA repositories abstract away the persistence layer complexity. You get CRUD operations and query derivation without writing SQL or JDBC code. Combined with CommandLineRunner for seeding data, you can build and test your domain model rapidly during development.

H2's in-memory database is a developer's best friend for prototyping and testing. It starts clean, runs fast, and doesn't require external database servers. The H2 console gives you direct SQL access to verify your JPA mappings produce the correct schema and data. Once you've proven your design works with H2, swapping in PostgreSQL or MySQL for production is just a configuration change.

The real skill is knowing when to use one-to-one versus denormalizing into a single table, when to cascade operations, and how to handle fetch strategies. Every relationship in your domain model affects query performance, database design, and code maintainability. Getting these decisions right early saves you from painful refactoring later when your application is in production and your schema is locked in by existing data.


Did you find this helpful? You'll love our Building Production-Ready REST APIs with Spring Boot course. We take these JPA fundamentals and show you how to build complete, scalable systems—with validation, error handling, business logic, and everything else tutorials skip but employers demand. Learn to create APIs that work in the real world: https://www.javapro.academy/bootcamp/building-production-ready-rest-apis-with-spring-boot/

Complete Core Java Programming Course

Master Java from scratch to advanced! This comprehensive bootcamp covers everything from your first line of code to building complex applications, with expert guidance on collections, multithreading, exception handling, and more.

Get the Java Weekly Digest

Stay sharp with curated Java insights delivered straight to your inbox. Join 5,000+ developers who read our digest to level up their skills.

No spam. Unsubscribe anytime.

Name