DEV Community

Thellu
Thellu

Posted on

Open Session in View Is Convenient — Until It Hides Your Lazy Loading Problem

Spring Boot makes many things easy.

Sometimes too easy.

One example is Open Session in View.

If you use Spring Boot with Spring Data JPA, you may have seen this warning during 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 warning.
Enter fullscreen mode Exit fullscreen mode

A lot of developers ignore it because the application “works”.

The controller returns data.

Lazy relationships load.

No LazyInitializationException.

Everything seems fine.

Until one day an endpoint becomes slow, Dynatrace shows unexpected SQL after the service method, or a simple response suddenly creates hundreds of database queries.

This post explains what Open Session in View is, why it hides problems, and how to move toward safer API design.


What Open Session in View does

In a typical Spring Boot web application, Open Session in View keeps the JPA persistence context open for the whole HTTP request.

That means the persistence context can remain available even after your service method has returned.

So this can work:

@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id) {
  Order order = orderService.getOrder(id);
  return OrderDto.from(order);
}
Enter fullscreen mode Exit fullscreen mode

And inside OrderDto.from(order):

public static OrderDto from(Order order) {
  return new OrderDto(
      order.getId(),
      order.getCustomer().getName(),
      order.getItems().stream()
          .map(item -> item.getSku())
          .toList()
  );
}
Enter fullscreen mode Exit fullscreen mode

If customer and items are lazy relationships, they may still load successfully because the session is still open.

That is the convenience.

But it also means database access can happen outside the service layer, during DTO mapping or JSON serialization.


The problem it hides

Without Open Session in View, lazy access outside a transaction often fails quickly:

LazyInitializationException: could not initialize proxy - no Session
Enter fullscreen mode Exit fullscreen mode

That exception is annoying, but it is useful.

It tells you:

You are loading data outside the intended persistence boundary.

With Open Session in View enabled, the same code may not fail.

It silently queries the database later.

So instead of an obvious exception, you get hidden database behavior.

That is much harder to detect.


A simple example

Assume these entities:

@Entity
public class Order {

  @Id
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  private Customer customer;

  @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
  private List<OrderItem> items = new ArrayList<>();

  // getters
}
Enter fullscreen mode Exit fullscreen mode
@Entity
public class Customer {

  @Id
  private Long id;

  private String name;

  // getters
}
Enter fullscreen mode Exit fullscreen mode
@Entity
public class OrderItem {

  @Id
  private Long id;

  private String sku;

  @ManyToOne(fetch = FetchType.LAZY)
  private Order order;

  // getters
}
Enter fullscreen mode Exit fullscreen mode

The service method looks clean:

@Service
public class OrderService {

  private final OrderRepository orderRepository;

  public OrderService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }

  @Transactional(readOnly = true)
  public Order getOrder(Long id) {
    return orderRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("Order not found: " + id));
  }
}
Enter fullscreen mode Exit fullscreen mode

The controller maps the entity:

@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
  Order order = orderService.getOrder(id);
  return OrderResponse.from(order);
}
Enter fullscreen mode Exit fullscreen mode

The DTO mapping touches lazy fields:

public record OrderResponse(
    Long id,
    String customerName,
    List<String> skus
) {
  public static OrderResponse from(Order order) {
    return new OrderResponse(
        order.getId(),
        order.getCustomer().getName(),
        order.getItems().stream()
            .map(OrderItem::getSku)
            .toList()
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

With Open Session in View enabled, this may work.

But the SQL may happen after orderService.getOrder(id) has already returned.

That means your service method timing can look fast while the request is still slow.


Why this makes performance debugging confusing

Imagine you add timing logs:

long start = System.currentTimeMillis();
Order order = orderService.getOrder(id);
log.info("Service call took {} ms", System.currentTimeMillis() - start);

return OrderResponse.from(order);
Enter fullscreen mode Exit fullscreen mode

The log says:

Service call took 20 ms
Enter fullscreen mode Exit fullscreen mode

But the whole request takes 300 ms.

Where did the time go?

It may be here:

order.getCustomer().getName()
order.getItems().stream()
Enter fullscreen mode Exit fullscreen mode

Those lines can trigger additional SQL.

If you only measure the service layer, you miss the real cost.

This is especially painful when an APM tool shows SQL activity after the service method or near response serialization.


The N+1 problem becomes easier to create

Open Session in View also makes N+1 queries easier to miss.

Example:

@GetMapping("/orders")
public List<OrderSummaryResponse> listOrders() {
  List<Order> orders = orderService.findRecentOrders();

  return orders.stream()
      .map(OrderSummaryResponse::from)
      .toList();
}
Enter fullscreen mode Exit fullscreen mode

DTO mapper:

public record OrderSummaryResponse(
    Long id,
    String customerName
) {
  public static OrderSummaryResponse from(Order order) {
    return new OrderSummaryResponse(
        order.getId(),
        order.getCustomer().getName()
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Service:

@Transactional(readOnly = true)
public List<Order> findRecentOrders() {
  return orderRepository.findTop50ByOrderByCreatedAtDesc();
}
Enter fullscreen mode Exit fullscreen mode

The first query loads 50 orders.

Then this line:

order.getCustomer().getName()
Enter fullscreen mode Exit fullscreen mode

may trigger one query per order.

Result:

1 query for orders
50 queries for customers
Enter fullscreen mode Exit fullscreen mode

That is N+1.

With Open Session in View enabled, the endpoint may still return successfully, so nobody notices until traffic grows.


Why returning entities directly is even worse

This is one of the riskiest patterns:

@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
  return orderService.getOrder(id);
}
Enter fullscreen mode Exit fullscreen mode

If you return entities directly, Jackson may touch lazy fields during serialization.

That means your JSON serializer can accidentally trigger SQL.

The controller does not show it.

The service does not show it.

The repository does not show it.

The serialization layer pulls data from the database.

That is a bad boundary.

It can cause:

  • unexpected SQL
  • N+1 queries
  • slow JSON responses
  • circular reference problems
  • huge response payloads
  • LazyInitializationException when OSIV is disabled later
  • accidental exposure of internal entity fields

For APIs, DTOs are usually the safer boundary.


How to check whether Open Session in View is enabled

In Spring Boot, check your configuration:

spring.jpa.open-in-view=true
Enter fullscreen mode Exit fullscreen mode

or:

spring:
  jpa:
    open-in-view: true
Enter fullscreen mode Exit fullscreen mode

If you never configured it, your Spring Boot version may enable it by default for web applications and print a startup warning.

To disable it:

spring.jpa.open-in-view=false
Enter fullscreen mode Exit fullscreen mode

or:

spring:
  jpa:
    open-in-view: false
Enter fullscreen mode Exit fullscreen mode

Do not flip this in production without testing.

If your code relies on lazy loading in controllers or serializers, disabling OSIV can expose many hidden issues.

That is exactly why it is useful — but also why migration should be deliberate.


How to prove lazy loading is happening outside the service

1) Enable SQL logging locally

logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
Enter fullscreen mode Exit fullscreen mode

For older Hibernate versions, bind logging may use:

logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
Enter fullscreen mode Exit fullscreen mode

Then watch when SQL appears.

If SQL happens after the service method returns, your persistence boundary is leaking.


2) Add timing around DTO mapping

Order order = orderService.getOrder(id);

long mapStart = System.currentTimeMillis();
OrderResponse response = OrderResponse.from(order);
log.info("DTO mapping took {} ms", System.currentTimeMillis() - mapStart);

return response;
Enter fullscreen mode Exit fullscreen mode

If DTO mapping is slow and SQL logs appear during mapping, you found the issue.


3) Temporarily disable Open Session in View locally

Set:

spring.jpa.open-in-view=false
Enter fullscreen mode Exit fullscreen mode

Then run the endpoint.

If you get:

LazyInitializationException
Enter fullscreen mode Exit fullscreen mode

that means your controller or mapper was relying on the session staying open.

This is not necessarily “bad code” in the moral sense.

It is a boundary problem.

You now know where to fix it.


Better pattern 1: fetch exactly what the API needs

If the endpoint needs customer data, fetch it intentionally.

Repository:

public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("""
      select o
      from Order o
      join fetch o.customer
      where o.id = :id
      """)
  Optional<Order> findByIdWithCustomer(Long id);
}
Enter fullscreen mode Exit fullscreen mode

Service:

@Transactional(readOnly = true)
public Order getOrderWithCustomer(Long id) {
  return orderRepository.findByIdWithCustomer(id)
      .orElseThrow(() -> new NotFoundException("Order not found: " + id));
}
Enter fullscreen mode Exit fullscreen mode

Now the service method makes the data requirement explicit.


Better pattern 2: use DTO projections

For read APIs, DTO projections are often cleaner than loading entities.

public record OrderSummaryDto(
    Long orderId,
    String customerName
) {}
Enter fullscreen mode Exit fullscreen mode

Repository:

@Query("""
    select new com.example.api.OrderSummaryDto(o.id, c.name)
    from Order o
    join o.customer c
    where o.id = :id
    """)
Optional<OrderSummaryDto> findOrderSummary(Long id);
Enter fullscreen mode Exit fullscreen mode

Controller:

@GetMapping("/orders/{id}/summary")
public OrderSummaryDto getOrderSummary(@PathVariable Long id) {
  return orderService.getOrderSummary(id);
}
Enter fullscreen mode Exit fullscreen mode

Service:

@Transactional(readOnly = true)
public OrderSummaryDto getOrderSummary(Long id) {
  return orderRepository.findOrderSummary(id)
      .orElseThrow(() -> new NotFoundException("Order not found: " + id));
}
Enter fullscreen mode Exit fullscreen mode

This is explicit, fast, and hard to accidentally turn into N+1.


Better pattern 3: map DTOs inside the transaction

If you still want entity mapping, do it inside the service transaction:

@Transactional(readOnly = true)
public OrderResponse getOrderResponse(Long id) {
  Order order = orderRepository.findByIdWithItemsAndCustomer(id)
      .orElseThrow(() -> new NotFoundException("Order not found: " + id));

  return OrderResponse.from(order);
}
Enter fullscreen mode Exit fullscreen mode

Controller:

@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
  return orderService.getOrderResponse(id);
}
Enter fullscreen mode Exit fullscreen mode

This keeps database access inside the service layer.

The controller receives a finished DTO, not a live entity proxy.


Better pattern 4: use @EntityGraph

For some cases, @EntityGraph is cleaner than writing JPQL fetch joins.

public interface OrderRepository extends JpaRepository<Order, Long> {

  @EntityGraph(attributePaths = {"customer", "items"})
  Optional<Order> findWithCustomerAndItemsById(Long id);
}
Enter fullscreen mode Exit fullscreen mode

Then:

@Transactional(readOnly = true)
public OrderResponse getOrderResponse(Long id) {
  Order order = orderRepository.findWithCustomerAndItemsById(id)
      .orElseThrow(() -> new NotFoundException("Order not found: " + id));

  return OrderResponse.from(order);
}
Enter fullscreen mode Exit fullscreen mode

This makes fetch requirements visible at the repository boundary.


Should you always disable Open Session in View?

For APIs, I generally prefer:

spring.jpa.open-in-view=false
Enter fullscreen mode Exit fullscreen mode

because it forces clean boundaries:

  • repository fetches data
  • service controls transaction
  • DTO mapping happens intentionally
  • controller returns stable response objects
  • serialization does not accidentally query the database

But the migration should be controlled.

If your existing application has relied on OSIV for years, disabling it overnight can break many endpoints.

A safe migration plan:

  1. Add SQL logging locally.
  2. Identify endpoints with lazy loading during mapping or serialization.
  3. Convert direct entity responses to DTOs.
  4. Add fetch joins, entity graphs, or projections.
  5. Move DTO mapping inside service transactions.
  6. Disable OSIV in local/test.
  7. Fix LazyInitializationException cases.
  8. Disable OSIV in production when tests and monitoring look clean.

The goal is not to win a framework debate.

The goal is to make database access predictable.


What about read-only transactions?

You may think:

@Transactional(readOnly = true)
public Order getOrder(Long id) {
  return repository.findById(id).orElseThrow();
}
Enter fullscreen mode Exit fullscreen mode

is enough.

But if you return the entity and map it outside the method, the transaction boundary has already ended.

Open Session in View may keep the session open, but the service transaction is no longer controlling all data access.

That is the hidden problem.

A better approach:

@Transactional(readOnly = true)
public OrderResponse getOrder(Long id) {
  Order order = repository.findByIdWithDetails(id)
      .orElseThrow();

  return OrderResponse.from(order);
}
Enter fullscreen mode Exit fullscreen mode

Now the data access and DTO mapping live in the same transaction boundary.


A common production smell

A common smell looks like this:

Service method time: 40 ms
Controller total time: 300 ms
Dynatrace SQL time appears after service return
No obvious slow repository method
Endpoint returns entity or maps lazy relationships in controller
spring.jpa.open-in-view=true
Enter fullscreen mode Exit fullscreen mode

When you see this, check for lazy loading outside the service layer.

The problem may not be the initial query.

The problem may be all the small queries that happen later.


Testing for accidental lazy loading

One simple approach is to disable OSIV in tests:

spring.jpa.open-in-view=false
Enter fullscreen mode Exit fullscreen mode

Then controller tests will fail if they rely on lazy loading after the transaction.

For example, this test may reveal hidden lazy access:

@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest {

  @Autowired
  MockMvc mockMvc;

  @Test
  void getOrder_shouldReturnWithoutLazyInitializationException() throws Exception {
    mockMvc.perform(get("/orders/1"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.id").exists());
  }
}
Enter fullscreen mode Exit fullscreen mode

If the endpoint fails after OSIV is disabled, fix the data fetch or mapping boundary.

This is a good failure.

It tells you your API depends on a persistence context leaking into the web layer.


Practical checklist

When reviewing a Spring Data JPA API, ask:

  1. Does the controller return entities directly?
  2. Does DTO mapping happen outside the service transaction?
  3. Does the mapper access lazy relationships?
  4. Does SQL appear during JSON serialization?
  5. Does the endpoint work only when spring.jpa.open-in-view=true?
  6. Are there list endpoints that access lazy fields inside a loop?
  7. Would a projection or fetch join make the query intent clearer?
  8. Can this endpoint pass tests with OSIV disabled?

If several answers are “yes”, there is probably a hidden boundary problem.

Top comments (0)