I'm encountering a challenge with Jackson annotations in my User class while managing a self-referencing @ManyToMany relationship. Despite applying the appropriate annotations, Jackson fails to handle the relationship correctly. When I retrieve the User entity, the followers and following fields enter a recursive loop, leading to an infinite cycle of references.
Here’s the User entity:
import com.fasterxml.jackson.annotation.*;
import com.pubfinder.pubfinder.models.enums.Role;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* The type User.
*/
@Entity
@Table(name = "users")
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonIdentityInfo(generator = ObjectIdGenerators.UUIDGenerator.class, property = "id")
public class User {
@Id
@GeneratedValue
@Column(unique = true, nullable = false)
private UUID id;
@Column(unique = true, nullable = false)
private String username;
private String firstname;
private String lastname;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
@Enumerated(EnumType.ORDINAL)
private Role role;
@ManyToMany(
fetch = FetchType.LAZY,
cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH}
)
@JoinTable(
name = "user_following",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "following_id")
)
@Builder.Default
@JsonManagedReference
Set<User> following = new HashSet<>();
@ManyToMany(
mappedBy = "following",
fetch = FetchType.LAZY,
cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH}
)
@Builder.Default
@JsonBackReference
Set<User> followers = new HashSet<>();
@PreRemove
private void cleanupFollowerRelationshipsBeforeDeletion() {
for (User user : this.following) {
user.getFollowers().remove(this);
}
}
public void addFollowing(User user) {
if (!this.following.contains(user)) {
this.following.add(user);
user.getFollowers().add(this);
}
}
public void removeFollowing(User user) {
if (this.following.contains(user)) {
this.following.remove(user);
user.getFollowers().remove(this);
}
}
// Getters and Setters......
}
Dependencies: Here are the main dependencies in my build.gradle file:
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.15.4")
implementation("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
testImplementation("com.h2database:h2")
Jackson should handle the bidirectional relationships using @JsonManagedReference and @JsonBackReference correctly, and the serialization/deserialization process should avoid infinite recursion.
I have also tried @JsonIdentityInfo and @JsonIgnore, but nothing seems to work.
This is how I want the output to look:
{
"id": "1",
"username": "user1",
"following": [
{
"id": "2",
"username": "user2",
"following": [
{
"id": "1",
"username": "user1",
"following": [ /* infinite nesting */ ]
}
]
}
]
}
This how I want it to look:
{
"id": "1",
"username": "user1",
"following": [
{ "id": "2", "username": "user2" }
],
"followers": [
{ "id": "3", "username": "user3" }
]
}
Here is the solution shown below
Using DTOs
(Data Transfer Objects) is an effective way to handle self-referencing relationships in your User entity and avoid infinite recursion
during serialization.
1 ) Create DTO Classes
UserBasicDTO.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserBasicDTO {
private UUID id;
private String username;
public static UserBasicDTO fromEntity(User user) {
return new UserBasicDTO(
user.getId(),
user.getUsername()
);
}
}
UserDTO.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private UUID id;
private String username;
private String firstname;
private String lastname;
private String email;
private Set<UserBasicDTO> following;
private Set<UserBasicDTO> followers;
public static UserDTO fromEntity(User user) {
return new UserDTO(
user.getId(),
user.getUsername(),
user.getFirstname(),
user.getLastname(),
user.getEmail(),
user.getFollowing().stream()
.map(UserBasicDTO::fromEntity)
.collect(Collectors.toSet()),
user.getFollowers().stream()
.map(UserBasicDTO::fromEntity)
.collect(Collectors.toSet())
);
}
}
2 ) Define UserService
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserDTO getUserById(UUID id) {
return userRepository.findById(id)
.map(UserDTO::fromEntity)
.orElseThrow(() -> new RuntimeException("User not found"));
}
public List<UserDTO> getAllUsers() {
return userRepository.findAll().stream()
.map(UserDTO::fromEntity)
.collect(Collectors.toList());
}
}
3 ) Define Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable UUID id) {
UserDTO userDTO = userService.getUserById(id);
return ResponseEntity.ok(userDTO);
}
@GetMapping
public ResponseEntity<List<UserDTO>> getAllUsers() {
List<UserDTO> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}
}