💾 Розділ 16 · Питання #25

Як уникнути нескінченної рекурсії при серіалізації Entity

При спробі повернути JPA-сутність із двонапрямленим зв'язком напряму з REST-контролера Spring Boot серіалізатор JSON (за замовчуванням Jackson) потрапляє в нескінченний цикл обх...

При спробі повернути JPA-сутність із двонапрямленим зв’язком напряму з REST-контролера Spring Boot серіалізатор JSON (за замовчуванням Jackson) потрапляє в нескінченний цикл обходу взаємних посилань: User посилається на Order, Order посилається на User, і так до нескінченності. Це неминуче призводить до аварійного завершення потоку з винятком java.lang.StackOverflowError.


🟢 Junior Level

Механіка виникнення проблеми

Серіалізатор Jackson працює через Reflection, обходячи всі гетери Java-об’єкта:

@Entity
public class User {
    @Id
    private Long id;
    
    @OneToMany(mappedBy = "user")
    private List<Order> orders; // Гетер getOrders() повертає список Order
}

@Entity
public class Order {
    @Id
    private Long id;

    @ManyToOne
    private User user; // Гетер getUser() повертає об'єкт User
}
                   ЦЕНТРАЛЬНИЙ ЦИКЛ РЕКУРСІЇ JACKSON
                   
  User { id: 1 } 
       └── orders: [ Order { id: 10 } ]
                          └── user: User { id: 1 }
                                      └── orders: [ Order { id: 10 } ]
                                                         └── user: ... ──> ❌ StackOverflowError

3 швидкі способи розірвати цикл анотаціями Jackson

Спосіб 1: @JsonIgnore

Ігнорує поле під час серіалізації, виключаючи його з підсумкового JSON:

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    @JsonIgnore // Поле 'user' ніколи не потрапить у JSON замовлення
    private User user;
}

Спосіб 2: @JsonManagedReference та @JsonBackReference

Спеціалізована зв’язка Jackson для двонапрямлених відносин:

  • @JsonManagedReference (на стороні батька): поле серіалізується в JSON як зазвичай.
  • @JsonBackReference (на стороні дочірньої сутності): поле виключається із серіалізації, запобігаючи зацикленню. ```java @Entity public class User { @OneToMany(mappedBy = “user”) @JsonManagedReference // Включається в JSON користувача private List orders = new ArrayList<>(); }

@Entity public class Order { @ManyToOne(fetch = FetchType.LAZY) @JsonBackReference // Виключається з JSON замовлення private User user; }


#### Спосіб 3: `@JsonView`
Дозволяє динамічно включати або виключати поля залежно від ендпоїнта контролера.

---

## 🟡 Middle Level

### Використання `@JsonIdentityInfo`

Якщо бізнес-вимоги вимагають, щоб у JSON були присутні обидва боки зв'язку, анотація **`@JsonIdentityInfo`** серіалізує об'єкт цілком лише один раз, а в усіх подальших циклічних посиланнях підставляє тільки його первинний ключ:

```java
@Entity
@JsonIdentityInfo(
    generator = ObjectIdGenerators.PropertyGenerator.class,
    property = "id"
)
public class User {
    @Id
    private Long id;
    private String name;

    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}

@Entity
@JsonIdentityInfo(
    generator = ObjectIdGenerators.PropertyGenerator.class,
    property = "id"
)
public class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
}

Результуючий JSON:

{
  "id": 1,
  "name": "Іван",
  "orders": [
    {
      "id": 10,
      "user": 1  // Замість повторного об'єкта User серіалізується лише ID!
    }
  ]
}

🔴 Senior Level

Архітектурний стандарт: Чому Entities взагалі не можна повертати з API

Навішування анотацій Jackson на сутності JPA — класичний Code Smell («запах коду») і технічний борг. У професійній архітектурі сутності бази даних ніколи не виходять за межі транзакційного шару сервісів.

5 причин, чому повернення Entity в контролері є руйнівним:

  1. LazyInitializationException: Jackson звертається до лінивих полів (FetchType.LAZY) за межами @Transactional (під час рендерингу HTTP-відповіді), коли сесія Hibernate вже закрита.
  2. Пастка Open-Session-In-View (OSIV): Розробники вмикають spring.jpa.open-in-view=true, щоб заглушити LazyInitializationException. У результаті мережеві HTTP-з’єднання утримують фізичні JDBC-конекти з пулу HikariCP, викликаючи повну відмову БД під навантаженням.
  3. N+1 проблема при серіалізації: Jackson обходить усі гетери колекцій, тригерячи каскад десятків одиночних SQL-запитів на кожну HTTP-відповідь.
  4. Витік конфіденційних даних (Overfetching): Хеші паролів, токени, внутрішні прапорці та системні колонки випадково віддаються назовні у форматі JSON.
  5. Порушення контракту API: Будь-яка зміна схеми таблиці (додавання/перейменування колонки) ламає контракт фронтенду та мобільних клієнтів.

Канонічне рішення: DTO Projections та Java Records

Для кожного сценарію API створюється легковажний, плаский DTO (Java Record):

// Плаский DTO без циклічних посилань і без накладних витрат ORM:
public record UserResponseDto(
    Long id,
    String name,
    List<OrderSummaryDto> orders
) {
    public record OrderSummaryDto(Long id, BigDecimal amount, String status) {}
}

Мапінг безпосередньо на рівні бази даних через JPQL-конструктори:

public interface UserRepository extends JpaRepository<User, Long> {

    @Query("""
        SELECT new com.example.dto.UserResponseDto$OrderSummaryDto(o.id, o.amount, o.status)
        FROM Order o 
        WHERE o.user.id = :userId
        """)
    List<UserResponseDto.OrderSummaryDto> findOrdersByUserId(@Param("userId") Long userId);
}

Переваги:

  • База даних вибирає лише запитані колонки (SELECT id, amount, status).
  • Нульові накладні витрати на створення Entity-знімків у L1 кеші.
  • Жодних циклічних посилань, жодних LazyInitializationException та абсолютна безпека контракту API.

4 Tricky Questions

1. Чому Jackson викидає InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor при спробі повернути сутність із лінивим зв’язком?

Відповідь:

  1. При FetchType.LAZY поле сутності містить не реальний бізнес-об’єкт, а динамічний проксі Hibernate, згенерований бібліотекою ByteBuddy.
  2. Усередині цього проксі-класу знаходяться службові технічні поля Hibernate (включно з перехоплювачем ByteBuddyInterceptor), у яких немає стандартних публічних гетерів.
  3. Jackson при спробі серіалізувати цей проксі виявляє приватні поля без гетерів і за замовчуванням викидає InvalidDefinitionException.
  4. Костильне рішення: Підключити модуль jackson-datatype-hibernate6 або повісити @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}).
  5. Архітектурне рішення: Використовувати DTO і не повертати сутності напряму.

2. У чому принципова різниця в поведінці @JsonIgnore та @JsonBackReference при ДЕСЕРІАЛІЗАЦІЇ (коли контролер приймає JSON у @RequestBody)?

Відповідь:

  1. @JsonIgnore: Повністю ігнорує поле в обох напрямках. Якщо передати JSON із позицією замовлення, поле user залишиться null і зв’язок не відновиться.
  2. @JsonBackReference: Інтелектуальний при десеріалізації. Коли Jackson десеріалізує батька з @JsonManagedReference та дочірні елементи з @JsonBackReference, він автоматично проставляє посилання на батька в поле дитини прямо під час парсингу JSON!

3. Чому використання @JsonIdentityInfo вважається поганою практикою для публічних REST API, орієнтованих на мобільні або вебклієнти?

Відповідь:

  1. Клієнти API (TypeScript / Swift / Kotlin) очікують статичну, сувору структуру даних у контракті.
  2. При використанні @JsonIdentityInfo перша поява об’єкта повертається як повноцінний JSON-об’єкт { "id": 1, "name": "Іван" }, а друга поява того самого об’єкта в цій же відповіді повертається як просте число 1.
  3. Фронтенд-розробникам доводиться писати складну кастомну логіку денормалізації на клієнті, щоб парсити поліморфний тип User | number, що ламає типізацію Swagger/OpenAPI.

4. Чи допомагає анотація Spring @Transactional на рівні методу контролера вирішити проблему LazyInitializationException при серіалізації Jackson?

Відповідь:

  1. Ні, не допомагає!
  2. Вебфреймворк Spring MVC викликає метод контролера всередині транзакції, метод повертає Java-сутність, і транзакція закривається.
  3. Серіалізація в JSON за допомогою Jackson (HttpMessageConverter) відбувається ПІСЛЯ повернення з методу контролера, коли транзакція та сесія Hibernate вже зафіксовані й закриті.
  4. Спроба серіалізувати ліниве поле все одно призведе до LazyInitializationException (якщо тільки глобально не ввімкнено небезпечний антипатерн Open-Session-In-View).

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Нескінченна рекурсія при серіалізації JPA-сутностей виникає через циклічні посилання у двонапрямлених зв’язках, коли Jackson нескінченно обходить гетери батька й дитини, викликаючи StackOverflowError. На рівні анотацій Jackson проблему вирішують через @JsonIgnore, зв’язку @JsonManagedReference / @JsonBackReference або @JsonIdentityInfo. Проте головним архітектурним стандартом у продакшені є повна відмова від повернення JPA-сутностей із REST-контролерів. Замість сутностей завжди використовуються пласкі DTO (Java Records), що мапляться через MapStruct або конструктори JPQL, що виключає витоки схеми БД, проблеми з лінивим завантаженням та зайві SQL-запити».

Ключові метрики для Production-моніторингу

  • StackOverflowError count: аварійні падіння потоків серіалізації Jackson.
  • Тривалість фази серіалізації HTTP-відповідей: затримки рендерингу JSON сигналізують про прихований обхід графа JPA-сутностей.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Щоб Jackson не падав із рекурсією, я вмикаю EAGER на всіх колекціях» — Це погіршить проблему, призведе до викачування всієї бази даних у пам’ять та N+1.
  • ❌ «Я повертаю сутності прямо з REST контролера, тому що це швидше писати» — Призводить до StackOverflowError, витоків паролів та падінь через LazyInitializationException.
  • ❌ «Для лікування рекурсії я вмикаю spring.jpa.open-in-view=true» — OSIV виснажує пул з’єднань бази даних і не вирішує рекурсію.
  • ❌ «@JsonManagedReference ставиться на дочірню сутність, а @JsonBackReference на батька» — Рівно навпаки: Managed на батька (@OneToMany), Back на дитину (@ManyToOne).

Пов’язані теми