Skip to content
Chapter 28 · Spring Boot

JPA Part 6: One-to-One Mappings ​

Part 5 mapped single entities to tables.

Entities rarely stand alone. JPA has four association types: @OneToOne, @OneToMany, @ManyToOne and @ManyToMany, each unidirectional or bidirectional. This part covers one-to-one, plus things every association uses: cascading, fetch types and JSON serialization.

@OneToOne unidirectional ​

One entity (A) refers to exactly one instance of another (B), and the reference goes one way only: from the parent A to the child B.

java
@Table(name = "user_details")
@Entity
public class UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String phone;

    @OneToOne(cascade = CascadeType.ALL)
    private UserAddress userAddress;

    public UserDetails() {
    }
    // getters and setters
}
java
@Entity
@Table(name = "user_address")
public class UserAddress {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String street;
    private String city;
    private String state;
    private String country;
    private String pinCode;

    public UserAddress() {
    }
    // getters and setters
}
sql
create table user_details (id bigint generated by default as identity, user_address_id bigint unique,
    name varchar(255), phone varchar(255), primary key (id))

user_details gets a foreign key column pointing to user_address. By default Hibernate names it <field name>_id (so user_address_id) and points it at the other table's primary key. It is unique, since one address belongs to one user.

For more control, use @JoinColumn:

java
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "address_id", referencedColumnName = "id")
private UserAddress userAddress;

The foreign key is now address_id, referring to user_address.id.

Referencing a composite key ​

If the child has a composite key, Hibernate cannot guess the columns: map each one with @JoinColumns.

java
@OneToOne(cascade = CascadeType.ALL)
@JoinColumns({
    @JoinColumn(name = "address_street", referencedColumnName = "street"),
    @JoinColumn(name = "address_pin_code", referencedColumnName = "pinCode")
})
private UserAddress userAddress;
java
@Entity
@Table(name = "user_address")
public class UserAddress {

    @EmbeddedId
    private UserAddressCK id;

    private String city;
    private String state;
    private String country;
    // getters and setters
}

@Embeddable
public class UserAddressCK implements Serializable {
    private String street;
    private String pinCode;
    // no-arg constructor, equals(), hashCode(), getters and setters
}

Cascade types ​

Without a cascade type, an operation on the parent does not affect the child. You would have to save, update and delete each child yourself, which is error-prone. cascade passes operations from parent to child.

The examples use this controller and service:

java
@RestController
@RequestMapping("/api")
public class UserController {

    @Autowired
    private UserDetailsService userDetailsService;

    @PostMapping("/user")
    public UserDetails insertUser(@RequestBody UserDetails userDetails) {
        return userDetailsService.saveUser(userDetails);
    }

    @PutMapping("/user/{id}")
    public UserDetails updateUser(@PathVariable Long id, @RequestBody UserDetails userDetails) {
        return userDetailsService.updateUser(id, userDetails);
    }

    @DeleteMapping("/user/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userDetailsService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}
java
@Service
public class UserDetailsService {

    @Autowired
    UserDetailsRepository userDetailsRepository;

    public UserDetails saveUser(UserDetails user) {
        return userDetailsRepository.save(user);
    }

    public UserDetails updateUser(Long id, UserDetails user) {
        Optional<UserDetails> existingUser = userDetailsRepository.findById(id);
        if (existingUser.isPresent()) {
            // save() updates instead of inserting, because the body carries an id
            return userDetailsRepository.save(user);
        }
        return null;
    }

    public void deleteUser(Long userId) {
        userDetailsRepository.deleteById(userId);
    }
}

PERSIST: saving a new user also inserts its address.

json
POST /api/user
{"name": "A", "phone": "1", "userAddress": {"street": "S1", "city": "C1", "pinCode": "111"}}

Both rows are inserted.

MERGE: updating the user also updates its address. With only PERSIST, a PUT that changes both name and street updates only the user; the address change is silently ignored. For insert and update, combine them:

java
@OneToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE})

Now the PUT runs update user_address ... as well.

REMOVE: deleting the user also deletes its address. Without it, DELETE /api/user/1 removes only the user row and the address row stays.

java
@OneToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE})

Two less common ones:

  • REFRESH: entityManager.refresh(entity) re-reads an entity from the database, bypassing the first-level cache. With this cascade, refreshing the user refreshes its address too.
  • DETACH: entityManager.detach(entity) removes an entity from the persistence context, so JPA stops managing it. With this cascade, detaching the user detaches its address too.

ALL means all five: PERSIST, MERGE, REMOVE, REFRESH, DETACH.

Fetch types: eager and lazy ​

When you read a parent, is the child loaded too?

  • Eager: the child is loaded immediately with the parent (for one-to-one, in the same query with a left join). Default for @OneToOne and @ManyToOne: there is just one child, and it will probably be needed.
  • Lazy: the child is not loaded until you access it, e.g. user.getUserAddress().getStreet(), which then runs a separate query. Default for @OneToMany and @ManyToMany: there may be many children, and loading them all every time could hurt performance.

You can override the default:

java
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "address_id", referencedColumnName = "id")
private UserAddress userAddress;

The JSON problem with lazy loading ​

With LAZY, returning the entity from GET /api/user/1 fails with 500:

Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]

A lazy field does not hold a real UserAddress; it holds a Hibernate proxy, a generated subclass that loads the data on first access. When Jackson writes the response, it serializes this proxy and trips over Hibernate's internal fields, which it does not know how to write.

Why did the POST response work? There, userAddress is the real object you sent in the request, not a proxy.

Two fixes:

1. @JsonIgnore on the field. Jackson skips it entirely, lazy or eager. Be aware that it also ignores the field when reading JSON: a POST body's userAddress is dropped, so no address is saved.

java
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "address_id", referencedColumnName = "id")
@JsonIgnore
private UserAddress userAddress;

2. A DTO (data transfer object): the cleaner, recommended way. Instead of exposing the entity, copy what the client needs into a plain class. You decide exactly what goes out (and can hide internal column names):

java
public class UserDetailsDTO {

    private Long id;
    private String name;
    private String phone;
    private String address;

    public UserDetailsDTO(UserDetails userDetails) {
        this.id = userDetails.getId();
        this.name = userDetails.getName();
        this.phone = userDetails.getPhone();
        // this access triggers the lazy load
        this.address = userDetails.getUserAddress() != null ? userDetails.getUserAddress().getStreet() : null;
    }
    // getters and setters
}
java
// in UserDetails
public UserDetailsDTO toDTO() {
    return new UserDetailsDTO(this);
}

// in the controller
@GetMapping("/user/{id}")
public UserDetailsDTO fetchUser(@PathVariable Long id) {
    return userDetailsService.findByID(id).toDTO();
}

The lazy load needs an open EntityManager. In a web request, Open EntityManager in View (part 3) keeps one open; with spring.jpa.open-in-view=false, build the DTO inside a @Transactional service method, or you get a LazyInitializationException.

@OneToOne bidirectional ​

Both entities refer to each other: UserDetails has a UserAddress, and UserAddress has a UserDetails back. The back reference exists only in the objects, not in the tables.

  • Owning side: holds the foreign key in its table. Here, UserDetails as before.
  • Inverse side: no foreign key; just an object reference to the owner, marked with mappedBy = the name of the field on the owning side.
java
@Entity
@Table(name = "user_address")
public class UserAddress {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String street;
    private String city;
    private String state;
    private String country;
    private String pinCode;

    @OneToOne(mappedBy = "userAddress", fetch = FetchType.EAGER)
    private UserDetails userDetails;

    // getters and setters
}

The tables are exactly the same as in the unidirectional case, but you can now navigate from an address to its user, for example with an address API:

java
@GetMapping("/user-address/{id}")
public UserAddress fetchAddress(@PathVariable Long id) {
    return userAddressService.findByID(id);
}

Infinite recursion ​

Calling it breaks the JSON. Jackson serializes the UserAddress, finds userDetails and serializes it, finds its userAddress and serializes that, and so on:

{"id":1,"street":"S2",...,"userDetails":{"id":1,...,"userAddress":{"id":1,...,"userDetails":{...
Could not write JSON: Document nesting depth (1001) exceeds the maximum allowed (1000, ...)

Three ways out.

1. @JsonManagedReference and @JsonBackReference

  • @JsonManagedReference on the owning side: serialize the child normally.
  • @JsonBackReference on the inverse side: do not serialize the back reference.
java
// UserDetails
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "address_id", referencedColumnName = "id")
@JsonManagedReference
private UserAddress userAddress;

// UserAddress
@OneToOne(mappedBy = "userAddress", fetch = FetchType.EAGER)
@JsonBackReference
private UserDetails userDetails;
http
GET /api/user/1          {"id":1,"name":"A","phone":"1","userAddress":{"id":1,"street":"S1",...}}
GET /api/user-address/1  {"id":1,"street":"S1","city":"C1","pinCode":"111"}

The address response no longer contains the user at all.

2. @JsonIdentityInfo, to keep both directions but stop the loop. Jackson gives each object an identity (here its id). The first time it meets an object, it writes it in full; after that, it writes only the id.

java
@Table(name = "user_details")
@Entity
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class UserDetails { ... }

@Entity
@Table(name = "user_address")
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class UserAddress { ... }

property can be any unique field; usually the primary key.

http
GET /api/user/1          {"id":1,"name":"A","phone":"1","userAddress":{"id":1,"street":"S1",...,"userDetails":1}}
GET /api/user-address/1  {"id":1,"street":"S1",...,"userDetails":{"id":1,"name":"A","phone":"1","userAddress":1}}

3. A DTO, as above: you choose what to include, so there is no cycle.

Next: Part 7 covers the remaining associations: one-to-many, many-to-one and many-to-many.

Check yourself ​

  1. In a unidirectional one-to-one from UserDetails to UserAddress, which table holds the foreign key, and what is it called by default?
  2. How do you name the foreign key address_id?
  3. How do you reference a child with a composite key?
  4. With only CascadeType.PERSIST, a PUT changes the user's name and street. What is updated?
  5. Without CascadeType.REMOVE, what happens to the address when the user is deleted?
  6. What does CascadeType.ALL include?
  7. What are the default fetch types of the four association annotations?
  8. Returning an entity with a LAZY one-to-one fails with a ByteBuddyInterceptor error. Why?
  9. What side effect does @JsonIgnore have on a POST request body?
  10. What is the owning side and the inverse side in a bidirectional mapping? What does mappedBy point to?
  11. Why does a bidirectional mapping cause infinite recursion in JSON, and what are the ways to prevent it?
  12. With @JsonIdentityInfo, what does the address look like inside GET /api/user/1?
Answers
  1. user_details; user_address_id (field name + _id).
  2. @JoinColumn(name = "address_id", referencedColumnName = "id").
  3. With @JoinColumns, one @JoinColumn per key column.
  4. Only the user; the address change is ignored (it needs MERGE).
  5. It stays in its table.
  6. PERSIST, MERGE, REMOVE, REFRESH, DETACH.
  7. @OneToOne and @ManyToOne: eager. @OneToMany and @ManyToMany: lazy.
  8. The field holds a Hibernate proxy, and Jackson fails on the proxy's internal fields.
  9. The field is also ignored when reading, so the child in the body is not saved.
  10. Owning side: holds the foreign key (@JoinColumn). Inverse side: only an object reference, marked mappedBy, which names the owning side's field.
  11. Each side serializes the other, forever. @JsonManagedReference/@JsonBackReference, @JsonIdentityInfo, or a DTO.
  12. The address in full, with "userDetails":1 (the user's id) instead of the user object.