Kihagyás

JPA Lazy/Eager & Tranzakciókezelés Spring kontextusban

Az alapprobléma: Session lifecycle

A JPA persistence context (Hibernate session) élettartama alapvetően meghatározza, hogy mikor és hogyan lehet lazy asszociációkat betölteni.

Alapértelmezetten Spring-ben a session a tranzakció határával egyezik meg (OSIV = false esetén). Ha nincs aktív session és lazy proxy-t próbálsz elérni → LazyInitializationException.


Lazy vs Eager – a döntés szempontjai

Fetch típusok alapértelmezései

Asszociáció Default
@ManyToOne EAGER
@OneToOne EAGER
@OneToMany LAZY
@ManyToMany LAZY

A @ManyToOne EAGER alapból – ez csapda, mert N+1 problémát okozhat ha nem figyelsz rá.

Eager – mikor rossz ötlet

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.EAGER)  // mindig betölti a Customer-t
    private Customer customer;

    @OneToMany(fetch = FetchType.EAGER)  // mindig betölti az összes OrderItem-t
    private List<OrderItem> items;
}

Ha csak az Order ID-jára van szükséged egy listában – mégis joinol mindent. Eager = mindig fizetsz, függetlenül attól, kell-e.

A helyes megközelítés: mindent Lazy + dedikált query

public interface OrderRepository extends JpaRepository<Order, Long> {

    // Lista nézet – csak alapadatok kellenek
    List<OrderSummaryProjection> findAllByStatus(OrderStatus status);

    // Részletes nézet – minden kell
    @Query("SELECT o FROM Order o JOIN FETCH o.items i JOIN FETCH o.customer WHERE o.id = :id")
    Optional<Order> findByIdWithDetails(@Param("id") Long id);

    // Számlázáshoz – csak customer kell
    @EntityGraph(attributePaths = {"customer"})
    Optional<Order> findWithCustomerById(Long id);
}

Az entitás struktúráját a doménnek, a fetch stratégiát a use case-nek kell meghatároznia.


Session vs Tranzakció élettartama

Ez az egyik legfontosabb különbség amit érteni kell:

@Transactional metódus:

  [session nyílik]
  [tranzakció nyílik]

      repo.findById()
      order.getItems()  ← session él, lazy betöltődik

  [tranzakció zárul]
  [session zárul]

  return order  ← az order már "detached"

@Transactional esetén a session és a tranzakció egyszerre nyílik és zárul. Lazy mezők szabadon elérhetők a metóduson belül.


LazyInitializationException – hogyan fordul elő

1. Nincs @Transactional a service metóduson

// ❌ Nincs @Transactional
public OrderDTO getOrder(Long id) {
    Order order = repo.findById(id).orElseThrow();
    // ^ a repo saját belső tranzakciója itt már lezárult, session closed
    order.getItems().size();  // LazyInitializationException
    return toDTO(order);
}

A repo hívás mindig lefut (saját mini-tranzakciójában), visszaadja az entitást, de a session bezárul. Az entitás detached állapotba kerül. Ha ezután lazy mezőre nyúlsz → exception.

2. Tranzakción kívül éred el a lazy mezőt

// ✅ Tranzakció a service-ben lezárul
public Order getOrder(Long id) {
    return repo.findById(id).orElseThrow();
}

// ❌ Controller – nincs session
@GetMapping("/{id}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long id) {
    Order order = orderService.getOrder(id);
    order.getItems().size(); // LazyInitializationException
}

3. Self-invocation – a Spring AOP csapdája

@Service
public class OrderService {
    @Transactional
    public void processAll() {
        // ❌ Self-invocation! A proxy megkerülődik,
        // a @Transactional nem lesz aktív
        this.processOne(1L);
    }

    @Transactional(propagation = REQUIRES_NEW)
    public void processOne(Long id) {
        // Ez tranzakció nélkül fut le!
    }
}

A @Transactional Spring AOP proxy-n keresztül működik. Ha this-en hívsz metódust, megkerülöd a proxy-t.

4. Async kontextus

@Async
public CompletableFuture<OrderDTO> processAsync(Long id) {
    // Új thread, nincs ThreadLocal-ban session
    Order order = repo.findById(id).orElseThrow();
    order.getCustomer().getName(); // ❌ LazyInitializationException
}

Tranzakció propagáció – több service-en át

Ha több service-en megy keresztül a hívás és mindenhol van @Transactional, végig egyetlen tranzakcióban és sessionben dolgozol – ez a REQUIRED propagáció, ami az alapértelmezett.

@Transactional // új tranzakció és session nyílik
public void orderService.process(Long id) {
    Order order = repo.findById(id).orElseThrow();
    paymentService.charge(order);    // csatlakozik a meglévőhöz
    inventoryService.reserve(order); // csatlakozik a meglévőhöz
    // lazy mezők végig elérhetők
}

@Transactional // nem nyit újat, a meglévőhöz csatlakozik
public void paymentService.charge(Order order) { ... }

@Transactional // nem nyit újat, a meglévőhöz csatlakozik
public void inventoryService.reserve(Order order) { ... }

Ha bármelyik service-ben exception dobódik, az egész visszagörget – atomicitás.

Propagáció típusok

Propagáció Viselkedés
REQUIRED (default) Ha van tranzakció, abba száll be; ha nincs, újat nyit
REQUIRES_NEW Mindig új tranzakciót nyit (a régit felfüggeszti)
SUPPORTS Ha van tranzakció, abban fut; ha nincs, anélkül fut
NOT_SUPPORTED Mindig tranzakció nélkül fut (felfüggeszti a meglévőt)

REQUIRES_NEW tipikus use case: audit log, ami akkor is mentődjön ha a fő tranzakció rollback-el.


Fetch stratégiák

JOIN FETCH – a legtöbbször helyes megoldás

@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.customer c " +
       "JOIN FETCH o.items i " +
       "WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);

Explicit, előre meghatározott, session-független.

Entity Graph – rugalmasabb

@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<Order> findById(Long id);

Ugyanazt az entitást különböző gráfokkal lehet lekérdezni különböző use case-ekben.

DTO Projection – a legtisztább megoldás

// Interface projection – Spring megcsinálja
public interface OrderSummaryProjection {
    Long getId();
    String getStatus();
    String getCustomerName(); // customer.name → Spring lefordítja
}

// Record projection – explicitebb, jobban tesztelhető
public record OrderSummaryDTO(Long id, String status, String customerName) {}

@Query("SELECT new com.example.dto.OrderSummaryDTO(o.id, o.status, c.name) " +
       "FROM Order o JOIN o.customer c")
List<OrderSummaryDTO> findAllSummaries();

Nincs entitás → nincs lazy proxy → nincs probléma.

DTO előnyei az entitáshoz képest: - Nincs lazy exception - Nincs null surprise – amit látsz, az van benne - Nincs felesleges adat – csak amit a hívó tényleg használ - Olvashatóbb – a DTO neve leírja az use case-t

Ez az irány vezet el a CQRS gondolkodásmódhoz is: a read side mindig DTO/projection, a write side dolgozik entitással.


N+1 probléma

// ❌ N+1 klasszikus eset
List<Order> orders = orderRepo.findAll();  // 1 query
for (Order o : orders) {
    System.out.println(o.getCustomer().getName());  // N query (1 per order)
}

Ez lazy és eager esetén is előfordul – az OSIV-hez nincs köze, általános fetch probléma.

Detektálás:

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql: TRACE

Megoldás – @BatchSize: ahelyett hogy N query-t indít, csoportokban tölti be az asszociációkat IN clause-szal. Legacy kódban jól jön.

@BatchSize(size = 20)
@OneToMany(fetch = FetchType.LAZY)
private List<OrderItem> items;

OSIV – Open Session In View

spring:
  jpa:
    open-in-view: true  # Spring Boot default (!!)

Az OSIV filter a HTTP kérés elejétől végéig nyitva tartja a Hibernate session-t (nem a tranzakciót!).

Tranzakció:  [——Service metódus——]
Session:     [————————Teljes HTTP request————————]

A tranzakció ugyanúgy lezárul a service metódus végén. De a session nyitva marad, így a lazy proxy-k még be tudnak töltődni – csak épp tranzakción kívül, ami azt jelenti hogy minden lazy access egy külön query lesz.

Miért veszélyes

// Controller – tranzakció már rég lezárult
@GetMapping("/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
    Order order = orderService.getOrder(id);
    order.getCustomer(); // ← csendben lefut egy query
}

Nem kapod a LazyInitializationException-t – ezért veszélyes. A DB logika kiszivárog a prezentációs rétegbe, nehezen tesztelhető és optimalizálható.

Ajánlás

spring:
  jpa:
    open-in-view: false  # production kódban ezt használd

Kényszeríti a helyes fetch stratégiát – fail fast fejlesztés közben, nem production-ban.


readOnly = true – nem csak szemantika

@Transactional(readOnly = true)
public List<OrderDTO> findAll() { ... }
  • Hibernate dirty checking ki van kapcsolva → jobb teljesítmény
  • Egyes DB driver-ek read replica felé routolnak
  • Flush mode NEVER lesz → nem kísérli meg a sync-et

@Transactional tesztelésben

@SpringBootTest
@Transactional  // minden teszt rollback-el automatikusan
class OrderServiceTest {

    @Test
    void testOrderCreation() {
        // DB állapot visszaáll minden teszt után
    }
}

Csapda: ha a service REQUIRES_NEW propagációt használ, az külön tranzakcióban fut és a rollback nem vonatkozik rá → adatok maradnak a DB-ben a teszt után.


Összefoglaló táblázat

Szituáció Ajánlott megközelítés
Read-only API endpoint DTO Projection + readOnly = true
Komplex domain logika Entity + JOIN FETCH a szükséges asszociációkra
Különböző use case-ek eltérő gráfja @EntityGraph
N+1 gyanú legacy kódban @BatchSize + SQL logging
OSIV Kapcsold ki production kódban
Self-invocation probléma Bontsd külön bean-ekre
Több service-en átmenő logika REQUIRED propagáció (default) – egy tranzakció
Független mellékhatás (pl. audit log) REQUIRES_NEW propagáció