An endpoint that returns a JPA entity works in a unit test, then fails the first time it serializes real data. The client gets a 500 and the log shows something like:
WARN ... .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError)]Further down, Jackson’s own exception names the loop with a reference chain such as Order["items"]->...->OrderItem["order"]->Order["items"]->..., repeated until the stack ran out. It means Jackson walked an object graph that contains a cycle: an Order holds OrderItems, each OrderItem holds its Order, and Jackson, which serializes every property it can see, kept going in circles until the JVM threw StackOverflowError. Sometimes the response is already partly written, so the client may receive truncated JSON instead of a clean 500.
Quick fix checklist
- Return a DTO or Java record from the controller instead of the entity (preferred).
- If you must serialize entities, put
@JsonManagedReferenceon the parent’s collection and@JsonBackReferenceon the child’s parent field. - Or put
@JsonIgnoreon the side the client does not need. - Use
@JsonIdentityInfoonly when clients can handle id references in place of objects. - Seeing
No serializer found for class org.hibernate.proxy...ByteBuddyInterceptor? A lazy proxy was serialized: map to a DTO inside the transaction. - Also check Lombok:
@DatageneratestoString/hashCodethat recurse the same way.
Before you start
You should know JPA associations (@OneToMany(mappedBy = ...), @ManyToOne) and how Spring MVC turns a return value into JSON with Jackson. This article targets Spring Boot 3.x with Hibernate 6, Jackson 2 and jakarta.persistence imports.
Why it happens
Jackson serializes a bean by calling each getter (or reading each visible field) and recursing into the result. It has no idea which fields are JPA relations, and by default it does not track objects it has already written. A bidirectional association is, by design, a cycle in memory:
Order ──items──▶ OrderItem ──order──▶ Order ──items──▶ ...Hibernate made this worse by being helpful: inside an open session, accessing items loads the collection, so the cycle is fully navigable. Spring Boot enables spring.jpa.open-in-view by default, which keeps the session open until the response is written. That is why Boot logs this warning at startup:
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warningWith open-in-view on, serialization triggers lazy loading and then recursion. With it off, you more often hit the related lazy-loading errors instead: LazyInitializationException for an unloaded collection, or, for an uninitialized @ManyToOne proxy:
No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)That message comes from Jackson finding the proxy’s internal hibernateLazyInitializer property. All three errors share one root cause: the HTTP response is coupled to the persistence model.
Step-by-step walkthrough
Step 1: Reproduce with a bidirectional mapping
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
private String customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
// getters and setters
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
private String sku;
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
// getters and setters
}
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderRepository orders;
OrderController(OrderRepository orders) {
this.orders = orders;
}
@GetMapping("/{id}")
Order one(@PathVariable Long id) {
return orders.findById(id).orElseThrow();
}
}GET /api/orders/1 for an order with at least one item fails. An order with no items serializes fine, which is why the bug often survives the first tests.
Step 2: Read the reference chain
Look in the stack trace for through reference chain:. The repeating pair of property names identifies the two fields that form the loop. In larger graphs (Customer -> Order -> OrderItem -> Product -> ...) there may be several cycles; fix every pair the chain shows.
Step 3: Return a DTO shaped for the client
The durable fix is to stop serializing entities. Define records for exactly what the client needs and map inside a transaction:
import java.math.BigDecimal;
import java.util.List;
public record OrderResponse(Long id, String customer, List<Line> items) {
public record Line(String sku, BigDecimal price) {}
static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
order.getCustomer(),
order.getItems().stream()
.map(i -> new Line(i.getSku(), i.getPrice()))
.toList());
}
}
@Service
class OrderQueries {
private final OrderRepository orders;
OrderQueries(OrderRepository orders) {
this.orders = orders;
}
@Transactional(readOnly = true)
public OrderResponse find(Long id) {
return orders.findById(id).map(OrderResponse::from).orElseThrow();
}
}The controller returns OrderResponse. Records have no back-pointer, so there is nothing to recurse into, and because mapping happens inside @Transactional, lazy collections load before the session closes. You can now set spring.jpa.open-in-view=false.
Step 4: If you must serialize entities, annotate one direction
Sometimes an internal admin API or a legacy endpoint returns entities and a rewrite is not on the table. Jackson offers three tools:
// Parent side: serialized normally
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
private List<OrderItem> items = new ArrayList<>();
// Child side: omitted from output, restored on deserialization
@ManyToOne(fetch = FetchType.LAZY)
@JsonBackReference
private Order order;@JsonIgnore on OrderItem.order gives the same output and is simpler, but deserialization will not set the back-reference. @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") on both classes serializes the first occurrence fully and later occurrences as just the id, which preserves the graph but forces clients to resolve ids.
Step 5: Prevent regressions
Make “no entities in controller signatures” a team rule, enforce it with an ArchUnit test if you use one, and turn off open-in-view so lazy-loading mistakes fail in tests rather than silently issuing queries during serialization.
Worked scenario
A team’s GET /api/customers/42 returns a Customer entity. It worked for months. A developer adds a bidirectional mapping so a report can navigate from orders back to customers:
// in Order
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
// in Customer (new)
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();The customer endpoint now fails with infinite recursion, and only for customers with orders. The reference chain shows Customer["orders"]->...->Order["customer"]->Customer["orders"].
A quick @JsonIgnore on Customer.orders would restore the old output. But the team notices a second problem in the logs: with open-in-view on, each customer response was also loading every order, an extra query per request that nobody asked for. They introduce CustomerResponse(Long id, String name, String email), map it in a read-only transactional service, and change the controller signature. The new orders field never reaches Jackson, the extra query disappears, and future entity changes cannot change the public JSON by accident.
Common mistake
The most tempting wrong fix is @JsonIgnore on whichever field the stack trace mentions first. It stops the crash, but the choice is often arbitrary: ignore the parent’s collection and the client loses the items; ignore the child’s back-pointer and a later endpoint that returns OrderItem alone can no longer show its order. It also keeps the API shape tied to entity fields.
Second: disabling SerializationFeature.FAIL_ON_EMPTY_BEANS to silence the ByteBuddyInterceptor error. The response then contains an empty hibernateLazyInitializer object or a half-populated proxy. The jackson-datatype-hibernate module (Hibernate6Module in its jackson-datatype-hibernate6 artifact) can serialize lazy proxies more gracefully, but it treats a symptom; a DTO avoids the proxy entirely.
Third: Lombok’s @Data or @ToString on both entities. Even after fixing JSON, logging an Order overflows the stack through generated toString methods. Exclude relationship fields with @ToString.Exclude and avoid @EqualsAndHashCode over associations.
Verify the behavior
A @WebMvcTest with the query service mocked proves the response shape, and it cannot recurse because it serializes the record:
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mvc;
@MockitoBean
OrderQueries queries;
@Test
void returnsOrderWithoutBackReference() throws Exception {
given(queries.find(1L)).willReturn(new OrderResponse(1L, "Asha",
List.of(new OrderResponse.Line("SKU-1", new BigDecimal("9.99")))));
mvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].sku").value("SKU-1"))
.andExpect(jsonPath("$.items[0].order").doesNotExist());
}
}Add one @SpringBootTest (with Testcontainers or H2) that saves an order with items and calls the real endpoint, because the bug only appears with real, populated associations. With spring.jpa.open-in-view=false set, the startup warning disappears too.
Interview exercise
“Your teammate fixed a StackOverflowError in JSON serialization by adding @JsonManagedReference and @JsonBackReference. In review, you suggest DTOs instead. Justify the extra code.”
Answer and reasoning
The annotations fix the immediate cycle, but the controller still serializes persistence objects, so three problems remain. First, the API contract is whatever the entity fields happen to be: adding a column or an association changes the JSON, and fields like internal flags can leak. Second, serialization still drives lazy loading, which either issues hidden queries under open-in-view or fails with LazyInitializationException or the ByteBuddyInterceptor error without it. Third, each new bidirectional mapping can reintroduce recursion somewhere else. A record mapped inside a read-only transaction fixes all three: the shape is explicit and versionable, the queries happen where they can be seen and optimised (for example with a fetch join), and there are no back-pointers. The cost is a small mapping layer, which is cheaper than debugging serialization side effects in production.