JavaRush /Курсы /Spring Data JPA /Методы репозитория в Spring Data JPA

Методы репозитория в Spring Data JPA

Spring Data JPA
16 уровень , 2 лекция
Открыта

1. Контракт методов репозитория

Даже когда выбор между derived, JPQL, projection и native перестаёт быть магией, репозиторий всё равно может врать кодом. Самая частая путаница здесь уже не в тексте запроса, а в базовых глаголах: когда код хотел прочитать сущность, когда — взять ссылку, когда — просто спросить «существует ли?», а когда — посчитать.

Если честно, у многих новичков с репозиториями примерно такая картина: «Ну там есть save, есть findById, есть ещё какие-то методы… главное, чтобы компилировалось». Понимаю. Так же в детстве кажется, что правила дорожного движения — это для взрослых, а не для тебя. Но как только вы выезжаете на реальную дорогу (читай: реальный проект), выясняется, что контракт методов важнее, чем их название.

Spring Data JPA даёт вам базовый контракт репозитория (CrudRepository, JpaRepository) — у него заранее задана семантика. Рядом с ним живут привычные derived-паттерны вроде countBy..., где контракт собирается уже из имени метода. Эти вещи легко смешать и начать выбирать методы по звучанию, а не по смыслу.

Давайте соберём «карту намерений» (очень полезная штука, когда вы читаете код через полгода и пытаетесь вспомнить, кто это написал — вы или ваш кот). Только держим в голове уточнение: findById, getReferenceById и existsById — часть базового repository contract, а countBy... — derived-паттерн под агрегатный запрос.

Метод Что вы на самом деле хотите сказать кодом Тип результата Когда обычно происходит SQL Если записи нет
findById(id) «Мне нужна сущность, но она может отсутствовать» Optional<T> Обычно сразу SELECT Optional.empty()
getReferenceById(id) «Мне нужна ссылка на сущность по id (не обещаю, что данные уже прочитаны)» T (часто proxy) Может быть отложено до обращения к полям Ошибка может случиться позже
existsById(id) «Мне нужен ответ да/нет по существованию» boolean SELECT EXISTS/SELECT 1/COUNT (зависит от провайдера) false
countBy...(...) «Мне нужен счётчик, а не данные» long SELECT COUNT(...) 0

И вот тут появляется важная мысль дня: хороший репозиторий — это не «кладбище методов», а язык намерений. Если вы выбрали метод правильно, ваш сервис читается как человеческое предложение. Если выбрали «что под руку попалось», сервис превращается в шифровку.

Чтобы ещё лучше закрепить, держите мини-диаграмму выбора (не про инструменты запроса вообще, а именно про эти четыре метода):

flowchart TD
    %% Выбор метода по смыслу: чтение / ссылка / проверка / статистика
    A["Нужен результат по id"] --> B{"Нужно 'да/нет'?"}
    B -->|Да| E["existsById(id)"]
    B -->|Нет| C{"Нужна сущность (читать поля)?"}
    C -->|Да| D["findById(id) -> Optional"]
    C -->|Нет| F["getReferenceById(id) (ссылочный объект)"]

2. findById: чтение и Optional

Когда вы впервые видите Optional в Java-коде, есть два частых сценария. Первый — человек радуется: «О, null больше не будет!». Второй — человек грустит: «О, теперь надо писать на одну строчку больше…». В data-layer мире Optional — это не украшение, а прямой сигнал: отсутствие записи является нормальным исходом запроса.

findById — это метод, который вы выбираете, когда вам действительно нужно прочитать сущность (и, скорее всего, поработать с её полями), но вы готовы к ситуации, что строки с таким id нет. Например, товар мог быть удалён, или запрос пришёл с неправильным id, или в тестах вы забыли создать нужные данные (классика жанра).

Мини-пример для нашего проекта: читаем товар и превращаем отсутствие в понятную доменную ошибку (пока без REST-ошибок и контроллеров — мы всё ещё в data-layer мире).

import org.springframework.stereotype.Service;

@Service
public class CatalogService {

    // Репозиторий — наш доступ к данным (контракт Spring Data JPA)
    private final ProductRepository productRepository;

    public CatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public Product getProductOrThrow(long productId) {
        // findById -> Optional: отсутствие записи тут считается нормальным исходом
        return productRepository.findById(productId)
                // Явно превращаем "нет строки в БД" в доменную/сервисную ошибку
                .orElseThrow(() -> new IllegalArgumentException("Product not found: " + productId));
    }
}

Обратите внимание: тут нет «магии» — мы прямо говорим, что отсутствие товара превращается в исключение. И это куда честнее, чем вернуть null и надеяться, что где-то дальше код «сам догадается».

Часто findById используется и в read-сценариях (получить карточку товара) и в write-сценариях (изменить цену). Даже если вы пока делаете обновления через save (а не через dirty checking — это отдельная большая тема позже), findById всё равно остаётся вашим главным «входом» в работу с сущностью по id.

Вот пример «изменить цену товара» в учебно-прямолинейном стиле:

import java.math.BigDecimal;

public void changePrice(long productId, BigDecimal newPrice) {
    // 1) Сначала достаём сущность (или падаем с понятной ошибкой)
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new IllegalArgumentException("Product not found: " + productId));

    // 2) Меняем данные в объекте
    product.setPrice(newPrice);

    // 3) В учебном варианте сохраняем явно (позже обсудим dirty checking)
    productRepository.save(product);
}

Да, в реальном мире мы потом обсудим, почему не всегда нужен save после каждого set... (и почему ORM иногда отправляет UPDATE без явного save). Но даже когда вы станете «продвинутее», логика выбора findById останется такой же: «я хочу прочитать объект и работать с ним как с данными».

Ещё один момент, который стоит знать, не уходя в дебри: если вы в рамках одной транзакции дважды вызываете findById для одного и того же id, Hibernate часто не будет делать два SELECT, потому что у него есть first-level cache (persistence context). Это хорошо, но не стоит строить на этом архитектуру «я могу дёргать find сколько угодно» — выбирайте методы по смыслу, а оптимизация в рамках транзакции пусть будет приятным бонусом, а не вашей стратегией.

3. getReferenceById: ссылка на сущность

Название getReferenceById звучит так, будто сейчас вам выдадут что-то вроде «указателя» в C/C++. И по смыслу это близко: вы получаете объект, который представляет сущность по id, но не обязан быть полностью загруженным здесь и сейчас. В Hibernate это обычно proxy-объект, который умеет «догрузить» данные из БД, когда вы начнёте обращаться к полям.

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

Классический кейс в нашем mini-shop — создание OrderItem, которому нужен Product, но при этом цена и название товара мы, допустим, уже получили отдельно (или вообще не читаем товар, а работаем с заранее рассчитанными данными). Тогда можно «привязать» товар к позиции заказа через reference:

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.ordering.entity.OrderItem;

public OrderItem makeItem(long productId, int qty) {
    // Берем "ссылку" на Product: это может быть proxy и не сделать SELECT сразу
    Product productRef = productRepository.getReferenceById(productId);

    OrderItem item = new OrderItem();
    // Нам важно связать сущности, чтобы корректно записался FK (product_id)
    item.setProduct(productRef);
    item.setQuantity(qty);
    return item;
}

Здесь идея простая: нам нужен FK на product_id в order_item, и reference помогает это сделать без обязательного чтения всех полей Product. При этом важно помнить: если вы позже внезапно решите дёрнуть productRef.getName() (или любой другой не-id getter), ORM может пойти в БД и сделать SELECT.

Чтобы почувствовать разницу «на пальцах», вот маленький кусок кода-иллюстрации. Он не претендует на стопроцентно одинаковое поведение во всех условиях, но даёт правильное ощущение:

// Ссылка на сущность по id (не гарантирует немедленное чтение из БД)
Product ref = productRepository.getReferenceById(10L);

System.out.println(ref.getId());    // 10 (обычно без SELECT, id уже известен)
System.out.println(ref.getName());  // тут ORM может сделать SELECT, чтобы узнать name

Теперь — важная ловушка новичка. getReferenceById не является «более быстрым findById». Он другой по смыслу. Самая болезненная ошибка — заменить им findById «потому что короче и звучит круче».

Если вы получаете reference на несуществующую запись, вы можете не увидеть проблему сразу. Ошибка часто выстрелит позже, когда вы попытаетесь прочитать поле, или когда ORM будет синхронизировать изменения с БД. В результате вы получаете баг с сюжетом: «всё было нормально, а потом внезапно бах».

Поэтому правило простое: getReferenceById — для связывания и ссылок. findById — для чтения и работы с данными.

4. existsById: проверка существования

Метод existsById — прекрасен. Проблема только в том, что его часто используют как магический оберег от ошибок: «сначала проверю exists, потом уже буду делать что-то серьёзное». На практике это часто превращается в два запроса вместо одного, а иногда ещё и в ложное чувство безопасности.

Семантика existsById очень прямолинейная: вы задаёте вопрос «существует ли запись с таким id?». И получаете boolean. Никаких сущностей, никаких графов, никакой «половинчатой загрузки».

Вот пример, где это звучит нормально. Допустим, у нас есть операция «удалить товар», и бизнес-логика хочет сначала дать понятную ошибку «такого товара нет», а уже потом удалять. В учебном коде (без сложных контрактов и REST-слоя) это может выглядеть так:

public void deleteProduct(long productId) {
    // Важно: existsById отвечает только "да/нет" и не загружает сущность
    if (!productRepository.existsById(productId)) {
        // Даём явную ошибку "не найдено"
        throw new IllegalArgumentException("Product not found: " + productId);
    }

    // После проверки делаем действие (но это второй запрос в БД)
    productRepository.deleteById(productId);
}

Это читается: сначала проверка, потом действие. Но у этого подхода есть цена: два похода в БД. Иногда это оправдано (например, вы действительно хотите различать «не найдено» и «удалено»), но часто — нет.

Главный антипаттерн — использовать findById(...).isPresent() вместо existsById. Технически это работает, но по смыслу это «я загрузил (или попытался загрузить) сущность, чтобы ответить на yes/no вопрос». Это примерно как заказать грузовик, чтобы привезти одну булочку: доедет-то до магазина, но выглядит странно.

// Технически можно, но намерение хуже: вы как будто читаете сущность ради "да/нет"
boolean exists = productRepository.findById(productId).isPresent();

И второй антипаттерн — связка existsById + findById в одном сценарии, когда вам в итоге всё равно нужна сущность. Это не делает код «надёжнее», это делает его более многословным и более дорогим по запросам.

Если вам нужна сущность — идите сразу в findById и превращайте отсутствие в ошибку. Если вам нужен yes/no — используйте existsById. И, пожалуйста, не делайте из exists ритуал «на всякий случай», иначе ваш репозиторий начнёт издавать лишние SQL-звуки, как старый принтер, которому не нравится бумага.

5. countBy...: COUNT и счётчики

countBy... — это derived-метод, но по смыслу он ближе к «агрегатным» запросам: вы просите не данные, а число. И это очень здоровая привычка: если вам нужен счётчик, возвращайте счётчик. Не список. Не «список и потом size()». Не «давайте загрузим все товары, а потом посчитаем».

В нашем проекте счётчики могут пригодиться, например, для «мини-дашборда» или для проверок «а есть ли вообще активные товары в категории» (не лучший бизнес-кейс, но методически понятный). Репозиторий может выглядеть так:

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // COUNT по статусу (возвращаем число, а не список)
    long countByStatus(ProductStatus status);

    // COUNT по категории и статусу: полезно для статистики/валидаций
    long countByCategoryIdAndStatus(Long categoryId, ProductStatus status);
}

А сервис — так:

public long countActiveProductsInCategory(long categoryId) {
    // Возвращаем именно число: метод явно говорит, что нам нужна статистика
    return productRepository.countByCategoryIdAndStatus(categoryId, ProductStatus.ACTIVE);
}

Что важно понимать: countBy... не загружает сущности. Он делает SELECT COUNT(...) и возвращает число. Это обычно дешевле по памяти и по сети, чем загрузка списка. Но у COUNT тоже есть цена: если таблица огромная и условие не индексировано, подсчёт может быть не мгновенным. Мы сегодня не уходим в оптимизацию, но общий принцип стоит запомнить: счётчик — это отдельный тип запроса, и вы должны просить его осознанно, а не «на всякий случай».

В качестве бонуса: когда вы видите в коде countByStatus(ACTIVE), вы моментально понимаете, что сервису нужна статистика. А когда вы видите findByStatus(ACTIVE).size(), вы не понимаете ничего, кроме того, что кто-то не выспался.

6. Сценарии выбора метода

Теперь давайте соберём всё в несколько маленьких, но жизненных сюжетов из нашего mini-shop. Тут не будет «идеального DDD», зато будет то, что вы реально будете писать на работе (и потом стыдливо рефакторить, когда станете опытнее — это нормально, мы все так делали).

Первый сюжет: «получить товар и показать/изменить его данные». Здесь нужен findById, потому что мы действительно работаем с полями и хотим корректно обработать отсутствие.

public String getProductName(long productId) {
    // Нам нужны поля сущности -> значит, читаем сущность через findById
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new IllegalArgumentException("Product not found: " + productId));

    return product.getName();
}

Второй сюжет: «создать позицию заказа и просто привязать товар по id». Здесь getReferenceById уместен, потому что нам нужна ссылка для FK, а не чтение полей товара.

public OrderItem addItem(CustomerOrder order, long productId, int qty) {
    // Берем reference, чтобы связать item -> product без обязательного чтения полей Product
    Product productRef = productRepository.getReferenceById(productId);

    OrderItem item = new OrderItem();
    // Связываем позицию заказа с самим заказом
    item.setOrder(order);
    // Связываем позицию заказа с товаром (FK на product_id)
    item.setProduct(productRef);
    item.setQuantity(qty);
    return item;
}

Третий сюжет: «узнать, есть ли вообще такой товар», не читая его данные. Например, вы валидируете входные параметры операции, где сам товар не нужен (пусть это будет условный учебный кейс). Тогда existsById выражает намерение лучше.

public void assertProductExists(long productId) {
    // Нужен именно "да/нет" -> existsById
    boolean exists = productRepository.existsById(productId);
    if (!exists) {
        throw new IllegalArgumentException("Product not found: " + productId);
    }
}

И четвёртый сюжет (короткий): «посчитать количество активных товаров». Здесь countBy... прямо говорит, что нам нужна статистика.

public long countActiveProducts() {
    // Нужна статистика -> COUNT
    return productRepository.countByStatus(ProductStatus.ACTIVE);
}

Если вы заметили, все четыре примера отличаются не синтаксисом, а тем, что мы хотим выразить. Это и есть «семантика API». Хороший репозиторий — это когда метод выбран так, что читатель не задаёт лишних вопросов. Плохой — когда читатель начинает открывать логи SQL, чтобы понять, что автор имел в виду.

Как только эти глаголы встали на место, следующая типичная путаница появляется уже в сигнатурах: одна задача вдруг возвращает List, другая Page, третья nullable-объект, и API снова перестаёт быть честным. Там важно разобраться уже не только с методом, но и с формой ответа.

7. Типичные ошибки при выборе методов репозитория

В этой теме ошибки обычно не «красиво падают» на первой строчке. Они часто проявляются как странные лишние запросы, неожиданные исключения «где-то потом» или сервисный код, который выглядит логично, но делает лишнюю работу. Поэтому полезно заранее научиться замечать эти грабли по форме кода, ещё до того как вы увидели проблемы в логах.

Ошибка №1: заменить findById на getReferenceById «везде, потому что так быстрее».
Такой код часто живёт до первого реального кейса, где нужно обработать «не найдено» аккуратно. Reference хорош для связывания сущностей по id, но он не говорит «запись точно есть» и не обещает, что данные уже прочитаны. В итоге вы получаете ошибки, которые всплывают не в момент вызова репозитория, а позже, при обращении к полю или при синхронизации изменений.

Ошибка №2: проверять существование через findById(...).isPresent() вместо existsById.
Технически это корректно, но по смыслу вы «делаете вид», что читаете сущность, хотя вам нужен только yes/no. Такой код хуже читается, и чаще приводит к лишним действиям на уровне ORM. Если вопрос звучит как «существует ли?», пусть и метод звучит так же.

Ошибка №3: связка existsById(...), а потом сразу findById(...) в одном use case.
Это самый распространённый способ сделать два запроса, когда нужен один. Если вам всё равно нужна сущность — вызывайте findById и обрабатывайте отсутствие. Если вам нужно только «да/нет» — вызывайте existsById. Двойная проверка редко даёт пользу и почти всегда даёт лишний SQL.

Ошибка №4: ожидать, что getReferenceById «проверит существование» прямо сейчас.
Reference — это не «быстрый find». Он может не ходить в БД сразу. А значит, он может не сообщить вам «такой записи нет» именно в момент вызова. Если вам важно немедленно понять «есть/нет» и отреагировать — это зона findById или existsById, а не reference.

Ошибка №5: считать countBy... эквивалентом «прочитать список и взять .size()».
.size() на списке — это размер уже загруженной коллекции. Чтобы получить эту коллекцию, вы сначала прочитали все строки (и, возможно, часть связей), потратили память, время и сеть. countBy... делает агрегатный запрос и возвращает число. Если нужен счётчик — просите счётчик, иначе вы сами себе устраиваете мини-DDOS на базу.

1
Задача
Spring Data JPA, 16 уровень, 2 лекция
Недоступна
Развести по смыслу findById и existsById
Развести по смыслу findById и existsById
1
Задача
Spring Data JPA, 16 уровень, 2 лекция
Недоступна
getReferenceById для связи товара с категорией и countByStatus для счётчика
getReferenceById для связи товара с категорией и countByStatus для счётчика
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ