javaspring-boothibernatespring-data-jpajackson

Jackson Annotations not working in Spring Boot


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" }
  ]
}

Solution

  • 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);
        }
    
    }