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 (@ManyToOne — EAGER по умолчанию, но это меняем). 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 транзакция на уровне БД.