Un Client à plusieurs Commande et nous souhaitons pouvoir récupérer l’ensemble des commandes d’un client depuis un endpoint REST GET /customers/{id}/orders
. En nous appuyant sur Spring et une architecture en couche nous allons voir qu’un cas aussi simpliste que celui-ci va nous réserver quelque surprise et nous interroger sur l’organisation de notre code.
Entités Link to heading
Commençons par représenter nos entités métiers. Par soucis de simplicité nous nous affranchissons de DTO de retour.
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@JsonManagedReference // to avoid infinite reference
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Order> orders;
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
@JsonBackReference // to avoid infinite reference
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
H1 : récupérer le client puis utiliser le getOrders() Link to heading
Une première hypothèse consisterait à utiliser tous simplement le getter après avoir récupérer le client
@Service
public class ClientService {
@Autowired
private CustomerRepository customerRepository;
public List<Order> getOrdersByCustomerId(Long customerId) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new RuntimeException("Customer not found with ID: " + customerId));
List<Order> orders = customer.getOrders(); // Use getter
return orders;
}
}
@RestController
@RequestMapping("/customers")
public class CustomerController {
@Autowired
private CustomerService customerService;
@GetMapping("/{customerId}/orders")
public List<Order> getCustomerOrders(@PathVariable Long customerId) {
List<Order> orders = customerService.getOrdersByCustomerId(customerId);
return orders;
}
}
Si nous exécutons ce code, il fonctionne et nous renvoie bien la liste des clients, MAIS
Désactiver le Open Session In View Link to heading
Par défaut, Spring active le Open Session In View. Or, cette option est considérée comme un anti-pattern (voir sources en bas de page); Ainsi, dans votre application.properties
désactivez cette option en ajoutant
spring.jpa.open-in-view=false
En rappelant l’endpoint vous obtenez l’exception suivante LazyInitializationException
[org.springframework.http.converter.HttpMessageNotWritableException:
Could not write JSON: failed to lazily initialize a collection of role:
fr.adriencaubel.springdatajpatest.entity.Customer.orders: could not initialize proxy - no Session]
Explication Link to heading
FetchType.LAZY par défaut
- Dans une relation
@OneToMany
, le chargement par défaut est défini surFetchType.LAZY
. Cela signifie que les données associées (dans ce cas, la liste orders) ne sont pas chargées immédiatement lorsque l’entité principale (Customer) est récupérée. - Au lieu de cela, Hibernate crée un proxy pour la collection orders, qui n’est initialisé que si l’on tente d’y accéder explicitement.
- Dans une relation
Fermeture de la transaction
- Lorsque la méthode findById(customerId) est appelée, Hibernate ouvre une session pour exécuter la requête et retourne l’entité Customer. Cependant, si l’option Open Session In View est désactivée (spring.jpa.open-in-view=false), la session est fermée immédiatement après la fin de la transaction.
- Une fois la session fermée, les relations paresseuses (LAZY) ne peuvent plus être initialisées car Hibernate ne peut pas interagir avec la base de données pour récupérer les données manquantes.
LazyInitializationException
- Lorsque vous essayez d’accéder à
customer.getOrders()
après la fermeture de la session, Hibernate tente de charger les données associées à partir de la base de données. Cependant, comme la session est déjà fermée, une exceptionLazyInitializationException
est levée.
- Lorsque vous essayez d’accéder à
Résolution du LazyInitializationException Link to heading
Fetch.EAGER Link to heading
Une première solution consisterait à utiliser FETCH.EAGER
, mais ceci impactera les performances de notre application.
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<Order> orders;
Encapsuler dans une transaction Link to heading
Rajouter l’annotation @Transactional
sur notre méthode que la session Hibernate reste ouverte jusqu’à la fin de la méthode.
@Transactional
public List<Order> getOrdersByCustomerId(Long customerId) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new RuntimeException("Customer not found with ID: " + customerId));
List<Order> orders = customer.getOrders(); // Use getter
return orders;
}
Utiliser un JOIN FETCH Link to heading
Nous pouvons écrire une requête avec JOIN FETCH
pour récupérer explicitement l’association LAZY.
public interface CustomerRepository extends JpaRepository<Customer, Long> {
@Query("SELECT c FROM Customer c JOIN FETCH c.orders WHERE c.id = :id")
Optional<Customer> findByIdWithOrders(@Param("id") Long id);
}
Utiliser @EntityGraph Link to heading
Nous pouvons utiliser @EntityGraph
de JPA pour spécifier que certaines relations LAZY
doivent être “fetched eagerly” pour une requête spécifique.
public interface CustomerRepository extends JpaRepository<Customer, Long> {
@EntityGraph(attributePaths = "orders")
Optional<Customer> findById(Long id);
}
Conclusion Link to heading
L’hypothèse 1, nous a conduis à l’exception LazyInitializationException
et plusieurs hypothèse pour la résoudre. Les deux dernières semblent les plus intéressante pour résoudre notre problème.
H2 : coder un OrderRepository Link to heading
Pour le moment nous nous sommes intéressé qu’au CustomerRepository
et en supposant que c’était le rôle de ce repository de nous fournir la liste des commandes pour un client. Nous pouvons envisager de déléguer cette fonctionnalité à un autre repository
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomer(Customer customer);
}
public class OrderService implements IOrderService {
public List<Order> findByCustomer(Long customerId) {
return orderRepository.findByCustomer(customer);
}
}
@Service
public class CustomerService {
@Autowired
private final IOrderService orderService;
public List<Order> getOrdersByCustomerId(Long customerId) {
// Fetch the customer by ID
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new RuntimeException("Customer not found with ID: " + customerId));
return orderService.findByCustomer(customer);
}
}
Contrainte de couplage Link to heading
La principale contrainte de cette solution est le couplage logiciel entre CustomerService
et OrderService
là où avec JOINT FETCH
ou @EntityGraph
il était au niveau de la base de données.
A mon sens si vous êtes dans une application monolithe ce couplage n’est pas dérangeant. Il advient de se poser la question si nous souhaitons avoir deux microservices (appel réseau) distinct. Dans ce cas là l’appel http pour contacter le service commande depuis un client sera coûteux.
Conclusion Link to heading
Depuis un petit moment je me pose la question “où doit-on coder la récupération d’une sous-ressource” ?
- est-ce le rôle de
CustomerRepostiory
- ou préférer un
List<Order> findOrdersByCustomer(Customer customerId)
dans leOrderRepostiory
Le fait, de désactiver le Open Session In View, m’a permis de répondre à cette question (moyennement un compromis)
- Si nous mettons la récupération de la commande dans le
CustomerRepository
alors nous devons soit utiliserJOINT FETCH
soit@EntityGraph
- D’un autre côté si le couplage entre
CustomerService
etOrderRepository
n’est pas dérangeant alors cette solution est à préférer.