1. Detached после транзакции
Почти все реальные приложения живут короткими транзакциями: сервисный метод начал unit of work, что-то сделал, закончил. Поэтому состояние detached — не “редкий edge case”, а обычное состояние сущности после завершения транзакции. Важно поймать эту мысль: detached — это не ошибка и не “сломанная entity”. Это просто объект, который больше не связан с текущей “рабочей памятью” ORM.
В Spring Boot типичный жизненный цикл выглядит так: вы заходите в @Transactional метод, внутри него сущности становятся managed (их “держит” persistence context), а после выхода из метода транзакция завершается и persistence context прекращает свою жизнь. Как только он закончился, ваши объекты никуда не исчезают из памяти — ссылки же остаются — но они перестают быть “живыми” для Hibernate. Это похоже на ситуацию, когда вы закрыли документ в редакторе: файл-то у вас на диске существует, но автосохранение уже не работает, потому что редактор закрыт.
На простых полях это просто раздражает, а на ленивых ассоциациях эта граница ощущается ещё больнее: вне контекста сущность уже не может спокойно дотянуть связанные данные, а внутри живого контекста такие дотягивания легко прячут лишние запросы. То есть проблема здесь не в одном конкретном поле, а в самой границе между managed-миром и обычным Java-объектом.
Чтобы сделать detached-наблюдаемым, давайте (на минутку) напишем “не самый лучший” метод, который возвращает entity наружу. Мы делаем это чисто как лабораторный опыт, чтобы увидеть границу.
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) // Транзакция откроется, но только для чтения
public Product loadProduct(Long id) {
// Внутри метода сущность будет managed (под контролем persistence context)
return productRepository.findById(id).orElseThrow();
// После выхода из метода persistence context закроется, и объект станет detached
}
Теперь представим, что где-то выше в коде (в другом слое, другом методе, неважно) мы вызываем loadProduct(), получаем объект и меняем его:
// Получили entity, но жизненный цикл транзакции уже закончился
Product p = catalogService.loadProduct(1L);
// Меняем поле у detached-объекта: в Java поменяется, но ORM этого уже не отслеживает
p.setName("Changed outside TX");
System.out.println(p.getName()); // Changed outside TX
Ключевой момент: в Java поле действительно поменялось, и println это подтвердит. Но если вы откроете SQL-логи, то никакого UPDATE не произойдёт, потому что вы изменили объект уже после завершения транзакции, то есть после того, как он стал detached.
Можно зафиксировать это простой схемой (мы буквально рисуем “жизнь сущности”):
flowchart TD A["@Transactional метод стартовал"] --> B["Entity загружена: managed"] B --> C["Метод завершился: TX commit/rollback"] C --> D["Entity всё ещё в памяти, но уже detached"] D --> E["setName() меняет поле в Java"] E --> F["Но dirty checking не работает → SQL UPDATE не будет"]
Наблюдение немного неприятное, но честное: JPA — не телепат и не гадалка. Она не может следить за вашим объектом, если вы вынесли его из контекста, в котором она вообще умеет следить.
2. detach(...) и contains(...): проверяем состояние
Когда мы слышим слово detached, многие представляют себе что-то мистическое: “объект вроде тот же, но не тот”. Поэтому полезно научиться делать состояние “пощупаемым”. Для этого у нас есть две очень практичные вещи: entityManager.contains(entity) (проверка, managed ли объект сейчас) и entityManager.detach(entity) (принудительно “выпихнуть” объект из persistence context). Это отличный способ увидеть механику вживую.
Сначала добавим в проект маленький сервис “для демонстраций”. В реальном коде вы так не живёте каждый день, но для учебной лаборатории — идеально, потому что всё прозрачно и без догадок.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
@Service // Обычный Spring-сервис, просто для демонстраций
public class JpaDebugService {
@PersistenceContext // Инъекция EntityManager, связанного с текущей транзакцией
private EntityManager entityManager;
}
Теперь сделаем метод, который загружает товар и проверяет, managed ли он, а затем “отсоединяет” его:
import org.springframework.transaction.annotation.Transactional;
@Transactional // Транзакция нужна, чтобы был жив persistence context
public void showDetach(Long id) {
// При чтении внутри TX объект становится managed
Product p = productRepository.findById(id).orElseThrow();
// Проверяем: managed ли объект прямо сейчас
System.out.println(entityManager.contains(p)); // true
// Принудительно переводим объект в detached
entityManager.detach(p);
// Теперь persistence context уже не "держит" этот экземпляр
System.out.println(entityManager.contains(p)); // false
}
Этот пример важен психологически: detach(...) — это не “удалить из базы” и не “сломать объект”. Это просто сказать ORM: “больше не следи за этим экземпляром”.
И вот теперь можно сделать второй шаг: поменять поле после detach(...) и убедиться, что dirty checking не сработает, хотя код выглядит очень похоже на рабочий.
import org.springframework.transaction.annotation.Transactional;
@Transactional // Даже в транзакции dirty checking не сработает для detached
public void detachAndChange(Long id) {
Product p = productRepository.findById(id).orElseThrow();
// Явно перестаём отслеживать объект
entityManager.detach(p);
// Меняем поле: это изменение происходит только в памяти JVM
p.setName("Detached name");
// Читаем поле: в Java всё поменялось
System.out.println(p.getName()); // Detached name
// Но UPDATE не будет, потому что объект detached
}
В логах SQL при этом часто будет тишина. И это правильная тишина. ORM в этот момент честно говорит: “ты меня попросил не отслеживать — я не отслеживаю”.
Небольшой нюанс: кроме detach(entity) существует ещё entityManager.clear(), который “отцепляет” все managed-сущности текущего persistence context разом. Это полезно в отдельных диагностических сценариях и лабораториях, но в повседневном коде (особенно у новичка) лучше не делать clear() “на всякий случай”, иначе вы сами себе устроите загадку “почему изменения перестали сохраняться”.
3. Stale-состояние в памяти
Следующий частый сюрприз звучит так: “Я когда-то загрузил объект, а потом база изменилась, но мой объект в памяти остался старым”. И здесь снова нет магии: detached-объект — это снимок данных на момент чтения. Он не подписан на обновления в БД, не “слушает” изменения и вообще живёт своей жизнью. Java-объект — это не “окошко в базу данных”, это просто объект.
Давайте покажем stale-состояние на простом сценарии из нашего mini-shop. Пусть у нас есть два сервисных метода: один читает Product (и возвращает его наружу — снова делаем это ради демонстрации), второй меняет цену внутри транзакции.
import java.math.BigDecimal;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) // Тут читаем и выходим: наружу уйдёт detached-снимок
public Product loadSnapshot(Long id) {
return productRepository.findById(id).orElseThrow();
}
@Transactional // Тут меняем в managed-мире, dirty checking сработает
public void changePrice(Long id, BigDecimal newPrice) {
Product p = productRepository.findById(id).orElseThrow();
p.setPrice(newPrice); // Это изменение отследится и превратится в UPDATE
}
А теперь последовательность вызовов:
import java.math.BigDecimal;
// Снимок: после выхода из loadSnapshot объект станет detached
Product snapshot = catalogService.loadSnapshot(1L);
// Отдельная транзакция меняет цену в базе
catalogService.changePrice(1L, new BigDecimal("19.99"));
// Но snapshot — это старый объект в памяти, он не "подтянет" новое значение сам
System.out.println(snapshot.getPrice()); // старое значение (stale)
Почему старое? Потому что snapshot был загружен в одной транзакции, потом вышел из неё и стал detached. Во второй транзакции мы поменяли цену уже у другого managed-экземпляра (или вообще в другом потоке/процессе), а наш snapshot — это как распечатка, которую вы положили в папку. База данных может жить дальше, а бумажка не умеет “самообновляться”.
Что с этим делать на практике? Самый прямой способ — не использовать старый объект как источник правды, а заново прочитать данные внутри текущего use case. Если вам нужно “актуальное значение”, вы читаете его снова:
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) // Новый read use case: читаем актуальное состояние
public BigDecimal getActualPrice(Long id) {
return productRepository.findById(id).orElseThrow().getPrice();
}
Смысл stale-состояния важно понять ещё до сложных сценариев, потому что это влияет на дизайн кода: если вы где-то сохраняете entity “на потом” (в поле, кэше, синглтоне — страшное слово, но некоторые так делают), вы почти гарантированно получите устаревшие данные и странные баги.
4. merge(...): копирование в managed
Когда разработчик впервые узнаёт про merge(...), он часто представляет себе “прицепить обратно этот же объект”. И именно тут рождается главное недопонимание. merge(...) не оживляет detached-экземпляр как есть. merge(...) делает другую вещь: берёт состояние detached-объекта и копирует его в managed-экземпляр, который живёт в текущем persistence context. После этого managed-экземпляр снова начинает участвовать в dirty checking.
Представьте, что detached-объект — это заполненная анкета на бумаге, а persistence context — это электронная база, которая умеет отслеживать изменения. merge(...) — это “перепечатать данные из анкеты в электронную форму”. Бумага остаётся бумагой, но электронная форма становится живой и отслеживаемой.
Сделаем лабораторный пример: загрузили товар, отцепили, поменяли поле, затем сделали merge(...) и сравнили ссылки.
import org.springframework.transaction.annotation.Transactional;
@Transactional // merge имеет смысл только в контексте живого persistence context
public void demoMerge(Long id) {
// Сначала читаем сущность: она managed
Product detached = productRepository.findById(id).orElseThrow();
// Делаем её detached вручную (для эксперимента)
entityManager.detach(detached);
// Меняем состояние detached-объекта: это пока просто данные в памяти
detached.setName("Name from detached");
// merge возвращает ДРУГОЙ объект — managed-экземпляр в текущем persistence context
Product managed = entityManager.merge(detached);
// Ссылки разные: detached != managed
System.out.println(detached == managed); // false
}
Комментарий к этой строчке — важнее самого кода. false означает: merge(...) вернул другой объект (managed), а наш detached остался detached. Поэтому если вы после merge(...) продолжаете менять старую ссылку, вы снова меняете detached-объект, и изменения опять не попадут в БД.
Чтобы зафиксировать “правильный” стиль, можно написать так:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void mergeAndWork(Long id) {
Product detached = productRepository.findById(id).orElseThrow();
entityManager.detach(detached);
// Это состояние будет "влито" в managed во время merge
detached.setName("A");
Product managed = entityManager.merge(detached);
// А вот это изменение уже делается по managed-ссылке и будет tracked
managed.setName("B"); // это уже tracked
}
Здесь “A” — это состояние, которое мы “принесли” из detached, а “B” — дополнительное изменение уже в managed-мире.
Практический вывод: merge(...) — полезный инструмент, но он требует дисциплины. Он не “включает обратно” старый объект. Он создаёт (или получает) managed-экземпляр и возвращает вам его. И дальше жизнь есть только у returned instance.
5. save(...) и ловушка с return value
У save(...) здесь не новая роль, а новый нюанс. Мы уже видели, что update managed-сущности делает dirty checking, а для detached-объекта save(...) идёт по merge-пути. Здесь ловушка тоньше: какой именно экземпляр после save(...) оказывается живым для ORM.
Давайте воспроизведём: загрузим продукт, отцепим его, поменяем поле, затем вызовем save(...) и сравним ссылки.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void saveDetached(Long id) {
// Читаем объект: он managed
Product p = productRepository.findById(id).orElseThrow();
// Делаем detached, чтобы save пошёл по ветке merge-семантики
entityManager.detach(p);
// Меняем detached-состояние
p.setName("Detached update");
// save может вернуть ДРУГОЙ экземпляр (результат merge)
Product saved = productRepository.save(p);
// Проверяем, что ссылки могут не совпасть
System.out.println(p == saved); // false
}
Если вы увидели false, значит, save(...) в этом сценарии вернул managed-экземпляр (результат merge), а p остался detached. И вот здесь рождается “тихий” баг, который выглядит максимально невинно:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void wrongSave(Product detached) {
// Здесь save вернёт managed-экземпляр, но мы его игнорируем
productRepository.save(detached); // вернувшийся managed мы игнорируем
// И дальше продолжаем менять detached — ORM уже не следит за этим объектом
detached.setName("Changed after save"); // это уже не tracked
}
В этой ситуации в базе окажется только то состояние, которое попало в merge на момент save(detached). А всё, что вы поменяли на старой ссылке после этого, ORM не увидит, потому что вы продолжаете работать с detached-экземпляром.
Как пишут в мемах, “код компилируется — значит, работает” (шутка плохая, но жизненная). Именно поэтому здесь важно выработать привычку: если вы используете save(...) для detached-сущности, то относитесь к возвращаемому значению серьёзно. Либо вы присваиваете результат и работаете дальше с ним, либо (что ещё чаще лучше) вы не таскаете detached-сущности вообще, а делаете обновление через “загрузить managed → изменить поля”.
6. Дизайн сервисов без detached-сущностей
В этой теме есть техническая часть (что делает detach/merge), и есть архитектурная часть: как сделать так, чтобы вам реже приходилось вообще думать про merge(...). Обычно победа здесь достигается не “более правильным merge”, а более спокойным API сервиса: не принимать entity “снаружи”, а принимать идентификатор и новые значения, затем внутри транзакции загрузить managed-объект и применить изменения через dirty checking.
Это особенно важно для новичка: merge(...) — инструмент, с которым легко ошибиться, потому что ошибки не всегда проявляются сразу. А подход “загрузил → изменил managed” проще, читабельнее и обычно прозрачнее по SQL-логам. Это ещё и хороший способ не получать случайные сюрпризы на чтении связей: когда сущность не гуляет между слоями, граница контекста остаётся там, где вы её и задумали.
Вот мини-таблица, которая помогает принимать решение:
| Что хочется сделать в коде | Что лучше сделать в сервисе | Почему так спокойнее |
|---|---|---|
| “У меня есть Product, я его поменяю и сохраню” | Передать productId и новые поля, загрузить managed внутри @Transactional | Сущность не выходит за границы persistence context |
| “Я вернул entity из сервиса, а потом изменил” | Возвращать наружу не entity, а read-модель (или хотя бы id) | Detached-объект не выглядит как “живой” |
| “После save(...) я продолжаю менять старую ссылку” | Работать с результатом save(...) или вообще не использовать save для managed | Возвращаемое значение перестаёт быть “декорацией” |
Давайте применим это к нашему CatalogService. Вместо “обновить товар по данным объекта” мы делаем маленькую команду (можно record), и обновление становится почти скучным — а скучный код в data-layer часто лучший комплимент.
import java.math.BigDecimal;
// Команда: отдельная модель данных, не entity
public record ChangeProductPriceCommand(Long productId, BigDecimal newPrice) {
}
И сервис:
import org.springframework.transaction.annotation.Transactional;
@Transactional // Вся работа — внутри одной транзакции
public void changeProductPrice(ChangeProductPriceCommand cmd) {
// Загружаем managed-сущность в текущем persistence context
Product p = productRepository.findById(cmd.productId()).orElseThrow();
// Меняем поле у managed: dirty checking сделает UPDATE при коммите
p.setPrice(cmd.newPrice());
}
Обратите внимание, как приятно это читается: сервис управляет unit of work, объект гарантированно managed, dirty checking гарантированно сработает, а detached-сущности даже не появляются в истории. При этом вы не теряете ничего полезного: вы всё равно меняете цену товара, просто делаете это в правильном месте, в правильное время и с правильным состоянием сущности.
Если уж очень нужно “принять данные извне” как объект, пусть это будет не entity, а отдельная модель данных (команда/DTO), которую вы сами контролируете. Entity тогда остаётся внутренним “живым организмом” persistence layer, а не транспортной коробкой.
7. Типичные ошибки при работе с detached и merge
В этой теме ошибки особенно коварны тем, что код часто выглядит “логично”, компилируется и даже иногда работает, пока не появляется реальный сценарий с несколькими транзакциями. Поэтому лучше заранее запомнить несколько типовых граблей: они встречаются у новичков почти всегда, и каждый раз выглядят как “Hibernate опять что-то сломал”. Обычно он ничего не ломал — он просто честно перестал следить за detached-объектом.
Ошибка №1: менять detached-объект и ждать, что dirty checking всё сделает.
Это самая частая ловушка. Вы загрузили Product в одном @Transactional методе, вышли из него, а потом где-то ещё сделали product.setName(...) и ждёте UPDATE. Не будет, потому что dirty checking работает только для managed. Исправление почти всегда простое: переносите изменение внутрь транзакции и работайте с сущностью, загруженной в текущем persistence context.
Ошибка №2: считать, что merge(...) “прицепляет обратно тот же экземпляр”.
merge(...) возвращает managed-экземпляр, а исходный объект остаётся detached (и это легко увидеть по detached == managed). Если после merge(...) вы продолжите менять старую ссылку, изменения снова не попадут в БД. Исправление: после merge(...) работать только с returned instance или, чаще, не делать merge вовсе и обновлять через “загрузить managed”.
Ошибка №3: игнорировать возвращаемое значение save(...) в сценарии detached.
Когда save(...) внутри делает merge, он может вернуть другой экземпляр. Если вы вызвали save(detached) и продолжили менять detached, вы на самом деле редактируете уже “мертвую” для ORM копию. Исправление: присваивать результат save(...) и работать с ним, или переписать метод так, чтобы save(...) был нужен только для создания новой сущности, а обновления шли через managed + dirty checking.
Ошибка №4: принимать entity как входные данные в сервисный метод.
На практике это быстро превращается в “зоопарк состояний”: иногда пришёл managed (если вызвали внутри транзакции), иногда detached (если принесли извне), иногда вообще transient с заполненным id (да, так тоже бывает). Исправление: принимать команды/DTO и внутри транзакции загружать managed-сущность.
Ошибка №5: путать stale-состояние с “база не обновилась”.
Иногда база обновилась, просто ваш объект в памяти — это снимок, и он не обязан “подтягивать” изменения. Исправление: если нужен актуальный state, перечитывайте данные в новом read use case. Detached-объект не является источником истины, даже если у него красивый id и он выглядит “как настоящий”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ