← назад к разделу

Spring Data JPA убирает 90% boilerplate'а вокруг JPA. Это удобно, пока запросы простые. Когда запросы усложняются — приходится понимать, что под капотом, иначе появляются мистические тормоза (N+1, OSIV-побочки, lazy loading после транзакции).

В стеке Use Case Pattern на write-side обычно используется JPA (агрегаты, инварианты, dirty tracking), на read-side — jOOQ (свобода SQL, projections без overhead'а Hibernate). Эта статья — про JPA-часть.

Repository — иерархия интерфейсов

Repository<T, ID>             — маркерный
  └── CrudRepository           — save, findById, deleteById, count
       └── PagingAndSortingRepository  — Pageable + Sort
            └── JpaRepository           — findAll(Example), flush, getReferenceById, saveAndFlush

В большинстве случаев — JpaRepository. В DDD-проектах (где репозиторий объявляется в домене) — собственный интерфейс, реализующий Repository<T, ID> через прокси Spring Data + кастомные методы через @Query или *Impl-классы.

Query methods

Spring парсит имя метода и генерирует JPQL:

public interface OrderRepository extends JpaRepository<Order, UUID> {

    List<Order> findByCustomerIdAndStatus(UUID customerId, OrderStatus status);

    Optional<Order> findFirstByCustomerIdOrderByCreatedAtDesc(UUID customerId);

    Slice<Order> findByStatus(OrderStatus status, Pageable pageable);

    long countByCustomerId(UUID customerId);

    boolean existsByOrderNumber(String orderNumber);
}

Поддерживаемые ключевые слова: And, Or, Between, LessThan, GreaterThan, Like, NotLike, In, NotIn, IsNull, OrderBy{Field}{Asc|Desc}, Top<N>, Distinct.

Когда имя становится больше 5-6 слов — заменяем на @Query:

@Query("""
    SELECT o FROM Order o
    JOIN FETCH o.lines
    WHERE o.customerId = :customerId
      AND o.status IN :statuses
      AND o.createdAt > :since
    ORDER BY o.createdAt DESC
""")
List<Order> findRecent(UUID customerId, Collection<OrderStatus> statuses, Instant since);

Native query

Когда JPQL не хватает (window functions, CTE, специфичные функции PG):

@Query(value = """
    SELECT o.* FROM orders o
    WHERE EXISTS (
        SELECT 1 FROM payments p
        WHERE p.order_id = o.id AND p.status = 'FAILED'
          AND p.created_at > NOW() - INTERVAL '24 hours'
    )
""", nativeQuery = true)
List<Order> findOrdersWithRecentPaymentFailures();

Минусы: непереносимость между БД, нет проверки в compile-time. В UCP-стеке нативные запросы — обычно через jOOQ (типобезопасно).

Проекции

Когда нужны не все поля entity, а ограниченный набор — projections экономят память и трафик.

Interface-based projection

public interface OrderSummary {
    UUID getId();
    String getCustomerName();
    BigDecimal getTotalAmount();
    @Value("#{target.lines.size()}")
    int getLineCount();
}

public interface OrderRepository extends JpaRepository<Order, UUID> {
    List<OrderSummary> findByCustomerId(UUID customerId);
}

Hibernate генерирует SQL только с нужными полями.

Class-based projection (DTO)

public record OrderSummary(UUID id, String customerName, BigDecimal totalAmount) {}

public interface OrderRepository extends JpaRepository<Order, UUID> {

    @Query("""
        SELECT new com.example.OrderSummary(o.id, c.name, o.totalAmount)
        FROM Order o JOIN o.customer c
        WHERE c.id = :customerId
    """)
    List<OrderSummary> findSummariesByCustomer(UUID customerId);
}

Конкретнее и быстрее, чем interface-based.

Dynamic projection

<T> List<T> findByCustomerId(UUID customerId, Class<T> type);

// Использование:
List<Order> entities = repo.findByCustomerId(id, Order.class);
List<OrderSummary> summaries = repo.findByCustomerId(id, OrderSummary.class);

Один метод — разные типы возврата.

Pagination

Page<Order> page = repo.findByStatus(OrderStatus.PENDING,
    PageRequest.of(0, 20, Sort.by("createdAt").descending()));

page.getContent();        // 20 элементов
page.getTotalElements();  // общее количество (вызывает COUNT-запрос!)
page.getTotalPages();

Page делает два запроса: SELECT + COUNT. На больших таблицах COUNT может быть дорогим.

Альтернатива — Slice (без COUNT):

Slice<Order> slice = repo.findByStatus(OrderStatus.PENDING, PageRequest.of(0, 20));
slice.hasNext();  // true, если есть ещё страница

Использовать Slice для infinite-scroll UI, Page — когда правда нужна общая пагинация с номерами страниц.

Fetch strategies и N+1

JPA по умолчанию делает связи LAZY (@ManyToOneEAGER по умолчанию, но это меняем). Lazy-загрузка значит: при обращении к полю Hibernate делает дополнительный SELECT.

Классический N+1:

List<Order> orders = repo.findAll();                  // 1 SELECT
for (var order : orders) {
    System.out.println(order.getCustomer().getName());  // +N SELECT'ов (по одному на каждый order)
}

Решения:

JOIN FETCH в JPQL

@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findWithCustomer(OrderStatus status);

Один SQL с JOIN.

@EntityGraph

@EntityGraph(attributePaths = {"customer", "lines.product"})
List<Order> findByStatus(OrderStatus status);

Декларативное указание, что подгрузить вместе.

Подключение в коде

List<Order> orders = repo.findAll();
List<UUID> orderIds = orders.stream().map(Order::getId).toList();
List<OrderLine> lines = lineRepo.findByOrderIdIn(orderIds);
// сводим в коде вручную

Более громоздко, но даёт полный контроль над запросами. Особенно если связи N-to-N и JOIN FETCH ведёт к декартовому произведению.

OSIV — Open Session In View

По умолчанию Spring Boot держит EntityManager открытым на весь HTTP-запрос (spring.jpa.open-in-view=true). Это значит: lazy-загрузка работает в контроллере, в шаблоне, везде.

Звучит удобно, но в production почти всегда вред:

  • Контроллер делает order.getLines() — Hibernate генерирует SELECT прямо во время рендеринга ответа. На N orders × M lines — каскад дополнительных запросов уже после Handler'а.
  • Транзакция уже закрыта (transactional повесили на Handler), но сессия открыта в read-only — ваши «безопасные» reads запутывают auditor'ов.
  • Source n+1-проблем растёт, потому что «всегда работало».

Рекомендация: отключить OSIV:

spring.jpa.open-in-view=false

После этого order.getLines() в контроллере сразу даст LazyInitializationException — это правильно. Лучше получать всё нужное в Handler'е (через JOIN FETCH / EntityGraph / DTO-проекцию) и контроллер видит уже готовый DTO.

Когда уходить от Spring Data JPA на jOOQ

В UCP-стеке write-side — JPA, read-side — jOOQ. Причины:

  • Сложные запросы: 5+ join'ов, CTE, window functions → jOOQ красивее и быстрее.
  • Projections без overhead'а: jOOQ возвращает напрямую records, без entity-overhead'а.
  • Type-safe SQL: компилятор ловит ошибки имён колонок.
  • MULTISET для nested fetch: один SQL вместо N+1 для Order со списком OrderLine.

jOOQ Style Guide — детально о применении.

Что почитать дальше

  • @Transactional глубоко — как readOnly = true влияет на Hibernate.
  • jOOQ Style Guide — альтернатива для read-side.
  • Spring Testing — @DataJpaTest, тестирование репозиториев.
  • ACID и уровни изоляции в PostgreSQL — что значит read-only транзакция на уровне БД.