JavaRush /Курсы /Hibernate deep-dive /Список без managed-entity

Список без managed-entity

Hibernate deep-dive
9 уровень , 0 лекция
Открыта

1. Список как read use case

Когда мы говорим «список», мы почти всегда подразумеваем что-то очень конкретное: таблицу в админке, выдачу «20 последних заказов», страницу каталога «товары со статусом ACTIVE». И у любого списка есть очень приземлённая правда жизни: на экране видно несколько колонок, а не весь мир. Поэтому правильный первый вопрос звучит не «какой репозиторий вернуть?», а «какие поля реально нужны пользователю прямо сейчас?».

В Commerce Persistence Lab это ощущается особенно хорошо. У Product есть не только id и name, но и статус, цена (и дальше будет Money), soft delete, связи с категориями, потенциально ProductDetails. У PurchaseOrder есть клиент, позиции, снапшоты, сумма, статус, версия, даты. Список же обычно просит «плоскую» информацию: sku, name, status — и всё. Если мы в ответ на такой запрос грузим managed-entity, мы, по сути, тащим в память «живой объект с жизненным циклом», хотя нам нужна «строка отчёта».

Чтобы почувствовать, как это выглядит в коде, достаточно вспомнить самый привычный стиль из “обычного CRUD-мышления”:

package com.example.commerce.orders.repository;

import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

// Важно: этот репозиторий возвращает entity, то есть managed-объекты.
// Для списков это часто означает лишние колонки, lazy-риски и "право" на случайные изменения.
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    // Возвращаем полный агрегат заказа, хотя для таблицы обычно нужны 3–5 колонок.
    // Это пример того, почему "список = entity" почти всегда слишком тяжёлое решение.
    List<PurchaseOrder> findAllByOrderByIdDesc();
}

Этот метод формально честный: он возвращает заказы. Проблема только в том, что таблице «список заказов» редко нужен заказ как агрегат, со всем его характером и привычками. Таблице чаще нужен orderNumber, status, createdAt, customerEmail, totalAmount. И если мы возвращаем PurchaseOrder, мы даже сигнатурой метода говорим всем остальным: «берите заказ, делайте с ним что угодно». А потом удивляемся, что “что угодно” включает N+1, lazy загрузку в неожиданный момент и случайные обновления.

2. Entity как write-model и persistence context

Многие проблемы со списками начинаются с очень человеческой (и понятной) ошибки: воспринимать entity как DTO. Мол, ну это же Java-объект с полями, я его прочитал, значит это мои данные. Но Hibernate воспринимает entity иначе. Entity — это, если угодно, «контракт на управление»: когда объект становится managed, он попадает под ответственность persistence context, а это значит snapshots, dirty checking, возможный flush и целый набор неявного поведения, который уже знаком по обычной жизни managed-сущности.

Управляемая сущность в списке — это как дать каждому участнику списка пропуск в закрытый клуб с возможностью менять декорации. Вы хотели просто переписать фамилии в таблицу, а получили людей, которые могут «чуть-чуть подвинуть стул», и внезапно начнётся ремонт (то есть UPDATE на flush). И чем больше людей вы привели, тем больше шанс, что кто-то что-то заденет.

Здесь важно зафиксировать простую мысль: entity в Hibernate в первую очередь удобна как write-model, то есть модель для сценариев, где вы действительно собираетесь работать с её жизненным циклом и изменять состояние. В write-сценарии managed-сущность — ваш союзник: вы нашли её, поменяли поля, Hibernate аккуратно синхронизировал изменения. Но в read-сценарии «показать список» это часто лишняя тяжесть.

Визуально это можно представить так:

flowchart TD
    A["Сервисный метод 'показать список'"] --> B[Repository]
    B --> C["EntityManager/Session"]
    C --> D[Persistence Context]
    D --> E[Managed entities + snapshots]
    E --> F["Dirty checking на flush/commit"]

Пока вы возвращаете List<PurchaseOrder>, вы автоматически включаете весь этот конвейер, даже если список — это просто «вывести 20 строк и забыть».

3. Цена entity: overfetching и N+1

У entity как read-model есть несколько видов цены. И коварство в том, что часть этой цены не видна, пока всё маленькое и «на локалке летает». Но лучше понять механику заранее, чем разбираться с ней уже на боевом сценарии.

Первый тип цены — overfetching по колонкам. Даже если вы ничего не трогаете из связей, entity почти всегда означает SELECT “шире”, чем нужно списку. Списку нужно 35 колонок, а сущность обычно маппится на 1025 колонок (а иногда и больше, если есть embeddable, статусы, флаги, audit-поля). База отдаёт больше данных, сеть несёт больше байт, JVM создаёт больше объектов. И всё это ради того, чтобы вы в итоге взяли getName() и getSku().

Второй тип цены — случайная материализация графа. Вы уже видели N+1: «взяли список заказов, затем в цикле полезли в order.getCustomer().getEmail() — и получили по запросу на каждую строку». Это происходит не потому, что вы «плохой человек», а потому что entity подталкивает к навигации по графу: “ну раз объект тут, давайте по нему пройдёмся”. И вот вы уже читаете Customer, потом OrderItem, потом Product… а на уровне списка вам это не было нужно.

Пример такого «невинного» кода выглядит примерно так (и почти всегда встречается в ранних проектах):

import com.example.commerce.orders.entity.PurchaseOrder;

import java.util.List;

public void printCustomerEmails(List<PurchaseOrder> orders) {
    // Важно: если customer — LAZY, то обращение к getCustomer() может триггерить доп. запросы.
    // В списках это легко превращается в N+1.
    for (PurchaseOrder o : orders) {
        System.out.println(o.getCustomer().getEmail()); // может превратиться в N+1 (lazy loading в цикле)
    }
}

Да, мы можем чинить это JOIN FETCH или EntityGraph. Мы уже умеем. Но сегодня важнее другой вопрос: а нужно ли было вообще получать PurchaseOrder? Если цель — напечатать email и статус, то самый дешёвый способ победить N+1 — не создавать ситуацию, где N+1 возможно.

Третий тип цены — шум в persistence context. Когда вы грузите много сущностей, Hibernate хранит их в first-level cache (identity map), а ещё держит snapshots для dirty checking. Даже если вы ничего не обновляете, контекст становится «толще». В списках на тысячи строк это уже не философия, а память и время. Список — это обычно массовое чтение. Массовое чтение — это тот случай, где “я просто хотел вывести таблицу” неожиданно начинает конкурировать за ресурсы с более важными write-сценариями.

4. Как «чтение» приводит к UPDATE: dirty checking

Если вы когда-нибудь видели в логах “почему он обновил строку, я же только читал”, то поздравляю: вы столкнулись не с мистикой, а с нормальной работой dirty checking. И это место, где entity как read-model становится особенно опасной: список не предполагает изменений, но managed-объект технически разрешает их.

Самый простой (и почти смешной) пример — «подчистить строку». Вы хотели вывести имя товара, но где-то по пути сделали trim(), чтобы красиво было. В Java это выглядит безобидно. В Hibernate это может стать UPDATE.

import com.example.commerce.catalog.entity.Product;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void showProductName() {
    // Важно: в рамках транзакции сущность будет managed, а изменения — отслеживаться.
    Product product = productRepository.findById(1L).orElseThrow();

    // Просто чтение — безопасно, пока мы не меняем состояние managed-сущности.
    System.out.println(product.getName());          // например: "Coffee mug "

    // Даже такая "косметика" меняет состояние entity -> dirty checking -> UPDATE на flush/commit.
    product.setName(product.getName().trim());      // -> dirty checking -> UPDATE на flush/commit
}

На уровне «человеческой логики» это всё ещё “чтение”: мы просто выводим имя. На уровне Hibernate это уже “write”: вы изменили состояние managed-сущности, Hibernate честно это заметит и синхронизирует.

И здесь важно не впасть в крайность “тогда не будем менять ничего никогда”. В write-сценариях менять — нормально. Проблема в другом: когда вы возвращаете entity как результат списка, вы размазываете ответственность. Любой код выше по стеку может случайно вызвать setter, маппер может скопировать что-то из DTO, логирование может дёрнуть toString(), а дальше — flush, и привет.

DTO / projection в этом месте работает как простая страховка. DTO не managed. DTO не участвует в dirty checking. DTO не может «случайно обновиться в базе», потому что Hibernate о нём не знает. Это не магия, это просто разная природа объектов.

Как entity размывает границу read/write

Есть ещё одна проблема, которую редко замечают в момент написания кода, но почти всегда ощущают через пару месяцев сопровождения. Когда метод чтения возвращает entity, граница между “мы читаем” и “мы изменяем” становится размытой. А в persistence layer это особенно неприятно, потому что изменения могут происходить неявно и не в том месте, где вы ожидаете.

Представьте, что вы сделали метод listOrders() и возвращаете List<PurchaseOrder>. Сервис «вроде бы» читает. Но дальше:

— один разработчик начинает в контроллере (или в другом сервисе) навигировать по ассоциациям и ловит N+1;
— второй добавляет “удобный формат” и случайно меняет строку в entity;
— третий решает «давайте отсортируем по клиенту» и начинает дергать getCustomer().getLastName(), что внезапно вызывает дополнительную загрузку;
— четвёртый переносит этот список в другое место и получает LazyInitializationException, потому что он теперь работает уже за пределами транзакции (в нашем проекте OSIV выключен, и это правильно).

В итоге виноватых нет, потому что сигнатура метода “разрешала всё”: вы отдали наружу сущность, то есть write-модель. Это как если бы вы вместо фотографии паспорта (read-model для проверки) всегда выдавали человеку настоящий паспорт и ключи от квартиры (write-model). Формально тоже “информация о человеке”, но уровень доступа совсем другой.

Поэтому зрелая идея дня 9 звучит так: read use case должен возвращать read-модель. Сигнатура метода должна быть честной. Если вы возвращаете “строку списка заказов”, пусть тип результата будет “строка списка заказов”, а не “весь заказ”.

5. Read-model vs write-model: правило выбора

Сейчас самое важное — не запомнить названия «projection/DTO», а начать думать критерием выбора. Когда вы выбираете между entity и read-моделью, не надо превращать это в религию. Нужен простой инженерный вопрос: нужен ли вам managed-объект в этом сценарии? То есть будете ли вы в рамках этой операции менять его состояние, и хотите ли вы, чтобы Hibernate отслеживал эти изменения.

Если ответ “да”, entity — отличный инструмент. Вы сделали find(), поменяли поля, получили предсказуемый flush. Если ответ “нет”, то entity обычно не лучшая стартовая гипотеза. Для списка это почти всегда “нет”: список показывает данные, а не редактирует их прямо сейчас.

Эту логику удобно держать в голове как маленькую развилку:

flowchart TD
    A[Нужен список/таблица] --> B{Будем менять эти данные в этой операции?}
    B -->|Да| C["Entity как write-model (managed, dirty checking)"]
    B -->|Нет| D["Read-model: projection/DTO (тонкий результат)"]

И вот тут появляется «скелет» read-модели: отдельный тип, который описывает именно строку списка, без претензии на “всё про домен”. Например, строка каталога может быть такой:

package com.example.commerce.catalog.dto;

// Read-model для списка: ровно те поля, которые нужны строке таблицы.
// Это не entity: не managed, без lazy-прокси и без участия в dirty checking.
public record ProductListRow(Long id, String sku, String name) {
}

Почему record здесь уместен? Потому что это простой неизменяемый контейнер данных. Он не является entity, не имеет ленивых прокси, его toString() безопасен (в отличие от entity, где toString() может случайно инициировать lazy loading). Для read-моделей record часто даже проще, чем класс с кучей шаблонного кода. И да, это тот редкий случай, когда автогенерация equals()/hashCode()/toString() нам не вредит, а помогает.

Пока здесь достаточно зафиксировать сам мотив: список — это read use case, и он не обязан возвращать managed-сущности.

Если вернуться к примеру с заказами, то уже на уровне намерения видно, что List<PurchaseOrder> — это “слишком много прав”. Более честно мыслить в сторону “строки списка заказов”, вроде OrderListRow (номер, статус, email). Дальше останется только подобрать запрос, который вернёт именно такую read-модель, а не весь агрегат.

6. Типичные ошибки при работе со списками

Ошибка №1: «Верну entity, а потом уже где-нибудь смаплю в DTO».
Это выглядит как компромисс, но на практике часто превращается в ловушку. Как только вы вернули наружу entity, у кода выше по стеку появляется искушение «взять ещё одно поле» прямо из объекта, затем ещё одно, затем пройти в ассоциацию. А там уже и N+1 рядом стоит, и LazyInitializationException подмигивает. Если вы знаете, что сценарий списочный, честнее сразу возвращать read-модель.

Ошибка №2: «Это же чтение, значит @Transactional безопасен».
@Transactional действительно может быть полезным для предсказуемого чтения, но managed-сущность внутри транзакции остаётся managed-сущностью. Любая случайная правка в объекте может привести к UPDATE на flush/commit. Если вы в чтении не хотите даже теоретического риска accidental write, не возвращайте entity как результат и не работайте с managed-объектом “внутри списочной логики”.

Ошибка №3: лечить списки только fetch-настройками.
Когда в голове уже есть JOIN FETCH, EntityGraph и @BatchSize, возникает естественное желание: «а давайте просто подгоним fetch-план — и список будет быстрым». Иногда это правда, особенно для detail-страниц. Но у списка другой характер: он часто должен быть тонким и плоским. В таких сценариях правильное решение может быть не “ещё один fetch join”, а “вообще не грузить entity”.

Ошибка №4: сделать один “универсальный DTO” на все случаи жизни.
Когда вы переходите к read-моделям, очень хочется назвать всё ProductDto и использовать его и для списка, и для карточки, и для редактирования. Обычно это заканчивается тем, что DTO начинает разрастаться до размеров сущности — и вы снова в той же проблеме, только теперь уже без managed-семантики. Хорошая read-модель — это контракт под конкретный use case. Одна таблица — один тип строки.

Ошибка №5: думать, что projection — это “обрезанная entity”.
Projection — это не “entity, но поменьше”. Это другой тип результата, который специально выбирается ради контроля: по колонкам, по join’ам, по форме данных и по отсутствию влияния persistence context. Как только вы начинаете ожидать от projection поведения сущности (“а почему я не могу поменять поле и чтобы само сохранилось?”), вы возвращаетесь к путанице read/write в голове.

1
Задача
Hibernate deep-dive, 9 уровень, 0 лекция
Недоступна
Тонкий список активных товаров
Тонкий список активных товаров
1
Задача
Hibernate deep-dive, 9 уровень, 0 лекция
Недоступна
Сводка последних заказов как read-model
Сводка последних заказов как read-model
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ