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.
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);
}
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()
);
}
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
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
}
@Entity
public class Customer {
@Id
private Long id;
private String name;
// getters
}
@Entity
public class OrderItem {
@Id
private Long id;
private String sku;
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
// getters
}
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));
}
}
The controller maps the entity:
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
Order order = orderService.getOrder(id);
return OrderResponse.from(order);
}
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()
);
}
}
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);
The log says:
Service call took 20 ms
But the whole request takes 300 ms.
Where did the time go?
It may be here:
order.getCustomer().getName()
order.getItems().stream()
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();
}
DTO mapper:
public record OrderSummaryResponse(
Long id,
String customerName
) {
public static OrderSummaryResponse from(Order order) {
return new OrderSummaryResponse(
order.getId(),
order.getCustomer().getName()
);
}
}
Service:
@Transactional(readOnly = true)
public List<Order> findRecentOrders() {
return orderRepository.findTop50ByOrderByCreatedAtDesc();
}
The first query loads 50 orders.
Then this line:
order.getCustomer().getName()
may trigger one query per order.
Result:
1 query for orders
50 queries for customers
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);
}
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
-
LazyInitializationExceptionwhen 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
or:
spring:
jpa:
open-in-view: true
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
or:
spring:
jpa:
open-in-view: false
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
For older Hibernate versions, bind logging may use:
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
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;
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
Then run the endpoint.
If you get:
LazyInitializationException
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);
}
Service:
@Transactional(readOnly = true)
public Order getOrderWithCustomer(Long id) {
return orderRepository.findByIdWithCustomer(id)
.orElseThrow(() -> new NotFoundException("Order not found: " + id));
}
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
) {}
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);
Controller:
@GetMapping("/orders/{id}/summary")
public OrderSummaryDto getOrderSummary(@PathVariable Long id) {
return orderService.getOrderSummary(id);
}
Service:
@Transactional(readOnly = true)
public OrderSummaryDto getOrderSummary(Long id) {
return orderRepository.findOrderSummary(id)
.orElseThrow(() -> new NotFoundException("Order not found: " + id));
}
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);
}
Controller:
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.getOrderResponse(id);
}
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);
}
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);
}
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
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:
- Add SQL logging locally.
- Identify endpoints with lazy loading during mapping or serialization.
- Convert direct entity responses to DTOs.
- Add fetch joins, entity graphs, or projections.
- Move DTO mapping inside service transactions.
- Disable OSIV in local/test.
- Fix
LazyInitializationExceptioncases. - 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();
}
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);
}
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
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
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());
}
}
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:
- Does the controller return entities directly?
- Does DTO mapping happen outside the service transaction?
- Does the mapper access lazy relationships?
- Does SQL appear during JSON serialization?
- Does the endpoint work only when
spring.jpa.open-in-view=true? - Are there list endpoints that access lazy fields inside a loop?
- Would a projection or fetch join make the query intent clearer?
- Can this endpoint pass tests with OSIV disabled?
If several answers are “yes”, there is probably a hidden boundary problem.
Top comments (0)