Appearance
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@OneToOneand@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@OneToManyand@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,
UserDetailsas 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
@JsonManagedReferenceon the owning side: serialize the child normally.@JsonBackReferenceon 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
- In a unidirectional one-to-one from
UserDetailstoUserAddress, which table holds the foreign key, and what is it called by default? - How do you name the foreign key
address_id? - How do you reference a child with a composite key?
- With only
CascadeType.PERSIST, aPUTchanges the user's name and street. What is updated? - Without
CascadeType.REMOVE, what happens to the address when the user is deleted? - What does
CascadeType.ALLinclude? - What are the default fetch types of the four association annotations?
- Returning an entity with a
LAZYone-to-one fails with aByteBuddyInterceptorerror. Why? - What side effect does
@JsonIgnorehave on aPOSTrequest body? - What is the owning side and the inverse side in a bidirectional mapping? What does
mappedBypoint to? - Why does a bidirectional mapping cause infinite recursion in JSON, and what are the ways to prevent it?
- With
@JsonIdentityInfo, what does the address look like insideGET /api/user/1?
Answers
user_details;user_address_id(field name +_id).@JoinColumn(name = "address_id", referencedColumnName = "id").- With
@JoinColumns, one@JoinColumnper key column. - Only the user; the address change is ignored (it needs
MERGE). - It stays in its table.
PERSIST,MERGE,REMOVE,REFRESH,DETACH.@OneToOneand@ManyToOne: eager.@OneToManyand@ManyToMany: lazy.- The field holds a Hibernate proxy, and Jackson fails on the proxy's internal fields.
- The field is also ignored when reading, so the child in the body is not saved.
- Owning side: holds the foreign key (
@JoinColumn). Inverse side: only an object reference, markedmappedBy, which names the owning side's field. - Each side serializes the other, forever.
@JsonManagedReference/@JsonBackReference,@JsonIdentityInfo, or a DTO. - The address in full, with
"userDetails":1(the user's id) instead of the user object.