1. От ревизий к читаемой истории
Когда номер ревизии, snapshot и RevisionType уже не выглядят магией, остаётся самый практичный вопрос: как превратить всё это в историю, которую можно читать глазами. Если вы прямо сейчас откроете базу и посмотрите на PRODUCT_AUD или PURCHASE_ORDER_AUD, вы увидите таблицы, строки, REV, REVTYPE, колонки сущности… и внутреннего гуманитария в вас может слегка придавить тоской. Это нормально: Envers хранит историю как данные, но наша задача — превратить эти данные в понятную прикладную историю: «цена менялась так», «статус заказа шёл таким путём». Мы сделаем это без UI, без отчётных систем и без “вынесем в отдельный сервис и перепишем всё на Kotlin”. Просто аккуратный сервисный код, который читает ревизии и собирает timeline.
В голове полезно держать простую цепочку. Бизнес-операция в сервисе меняет entity, Hibernate делает dirty checking, на flush / commit уходят SQL-команды, а Envers рядом добавляет записи в audit-таблицы. И уже потом мы можем читать историю как отдельный read-use-case.
flowchart TD
%% Поток появления ревизий и чтения истории через Envers
A["@Transactional сервис
меняет Product / PurchaseOrder"] --> B["flush / commit"]
B --> C["Основные таблицы:
UPDATE product, UPDATE purchase_order"]
B --> D["Envers:
INSERT REVINFO
INSERT *_AUD"]
D --> E["AuditReader читает ревизии
и восстанавливает снимки"]
E --> F["Мы собираем timeline DTO
для истории цены/статуса"]
Обратите внимание на одну вещь, которая здесь важна только как напоминание: ревизия создаётся не “на entity”, а на транзакцию. Если вы в одном @Transactional-методе изменили и товар, и заказ, они могут получить одинаковый номер ревизии. Это удобно: один номер ревизии начинает вести себя как один логический шаг изменения.
Но здесь важно не сделать слишком широкий вывод. Envers хорош именно как узкий инфраструктурный механизм: он детерминированно фиксирует уже случившееся изменение persistence layer и не принимает бизнес-решения за приложение. Как только в ORM lifecycle начинают прятать произвольные side effects, прозрачность модели быстро исчезает.
2. История Product: цена и статус
Когда мы говорим «история товара», на практике мы почти всегда имеем в виду историю бизнес-состояния: цена, видимость, статус (ACTIVE/ARCHIVED). Именно за этим обычно и приходят в audit: кто-то поменял цену, а потом все спорят, «какая была вчера» и «почему маржа ушла в минус». Базовая механика уже знакома: AuditReader умеет достать ревизию, snapshot и время изменения. Для товара удобнее сразу собрать один read-model, в котором рядом лежат цена, статус, момент ревизии и RevisionType.
Сырой путь через getRevisions(...) и find(...) никуда не делся. Но для истории товара обычно полезнее сразу читать ревизионные строки целиком: так не приходится в каждом следующем методе отдельно склеивать snapshot, timestamp и тип операции.
История товара как read-model
import org.hibernate.envers.RevisionType;
import java.math.BigDecimal;
import java.time.Instant;
// Одна строка истории товара (read-model), которую удобно выводить как timeline
public record ProductRevisionRow(
Number revision, // Номер ревизии
Instant revisionTime, // Время ревизии
RevisionType revisionType, // Тип операции Envers
BigDecimal price, // Цена на момент ревизии
String status // Статус на момент ревизии
) {
}
Такой DTO сразу задаёт правильную границу: наружу уходит не историческая entity, а конкретная строка timeline. Исторический snapshot по-прежнему остаётся «фотографией прошлого», а не managed-объектом для save(). Поэтому Product лучше использовать внутри query-сервиса как промежуточный материал для маппинга, а не как публичный контракт.
import jakarta.persistence.EntityManager;
import org.hibernate.envers.AuditReaderFactory;
import org.hibernate.envers.DefaultRevisionEntity;
import org.hibernate.envers.RevisionType;
import org.hibernate.envers.query.AuditEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Service
public class ProductAuditQueryService {
private final EntityManager entityManager;
public ProductAuditQueryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public List<ProductRevisionRow> loadProductRevisionRows(Long productId) {
// Берём сразу строки истории с метаданными ревизии, а не только список номеров
List<?> rows = AuditReaderFactory.get(entityManager)
.createQuery()
.forRevisionsOfEntity(Product.class, false, true)
.add(AuditEntity.id().eq(productId))
.addOrder(AuditEntity.revisionNumber().asc())
.getResultList();
List<ProductRevisionRow> result = new ArrayList<>();
for (Object row : rows) {
// Исторически Envers возвращает Object[], это нормальная часть API
Object[] triple = (Object[]) row;
// 0: снимок сущности на момент ревизии
Product snapshot = (Product) triple[0];
// 1: ревизионная сущность (REVINFO)
DefaultRevisionEntity revInfo = (DefaultRevisionEntity) triple[1];
// 2: тип изменения (ADD/MOD/DEL)
RevisionType type = (RevisionType) triple[2];
result.add(new ProductRevisionRow(
revInfo.getId(),
Instant.ofEpochMilli(revInfo.getTimestamp()),
type,
snapshot.getPrice().getAmount(),
snapshot.getStatus().name()
));
}
return result;
}
}
Пара слов про .forRevisionsOfEntity(Product.class, false, true). false говорит: нужны не только сами сущности, но и метаданные ревизии с RevisionType. true оставляет в выдаче и физические удаления, если они вообще бывают. В проекте с soft delete DEL может не встретиться, но сам контракт от этого не становится хуже.
И да, Object[] здесь — нормальная часть API Envers, а не повод переписывать всё из принципа. Важна дисциплина: разобрали строку рядом с запросом, сразу превратили её в ProductRevisionRow и дальше по коду живёте уже с типизированной моделью.
История цены как timeline
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class ProductHistoryFormatter {
private final ProductAuditQueryService auditQueryService;
public ProductHistoryFormatter(ProductAuditQueryService auditQueryService) {
this.auditQueryService = auditQueryService;
}
@Transactional(readOnly = true)
public List<String> formatProductPriceTimeline(Long productId) {
// Сначала получаем “сырой” read-model (DTO-строки) из Envers
List<ProductRevisionRow> rows = auditQueryService.loadProductRevisionRows(productId);
// Затем форматируем в строки, удобные для чтения глазами/логирования
List<String> lines = new ArrayList<>();
for (ProductRevisionRow row : rows) {
lines.add("rev=" + row.revision()
+ " time=" + row.revisionTime()
+ " type=" + row.revisionType()
+ " price=" + row.price()
+ " status=" + row.status());
}
return lines;
}
}
Если вы выведете такие строки (например, в debug-логах лаборатории), они будут выглядеть примерно так:
rev=101 time=2026-03-21T12:40:10Z type=ADD price=199.99 status=ACTIVE
rev=105 time=2026-03-21T13:05:42Z type=MOD price=179.99 status=ACTIVE
rev=112 time=2026-03-21T14:10:03Z type=MOD price=179.99 status=ARCHIVED
Здесь уже хорошо видно, зачем вообще нужен read-model поверх Envers: история начинает читаться как timeline, а не как техническая таблица. И если цена и статус изменились в одной транзакции, это нормально попадёт в одну ревизию: Envers фиксирует состояние на момент коммита, а не каждое промежуточное движение внутри метода. Если нужно понять, что именно поменялось, сравнивают соседние строки timeline, а не ждут от Envers скрытого “diff по полям”.
3. История PurchaseOrder: статусы заказа
С заказами обычно интереснее, чем с товарами: статус заказа — это почти всегда отражение реального бизнес-процесса. Даже в лабораторном проекте статусы выглядят как “принят”, “оплачен”, “в сборке”, “отменён”, “доставлен” (или упрощённый набор). И когда что-то пошло не так, вопрос звучит не “какая цена была”, а “в каком порядке и когда менялись статусы”. Здесь нам обычно не нужен RevisionType на каждой строке: чаще важнее сам маршрут статусов во времени. Поэтому для заказа вполне хватает прямого чтения списка ревизий и snapshot на каждой из них. Это как раз хороший контраст с товаром: где-то нужен более богатый ряд событий, а где-то достаточно честной timeline статусов.
DTO для истории статуса заказа
Сделаем максимально ясный DTO, чтобы история статуса выглядела как timeline.
import java.time.Instant;
// Одна точка истории статуса заказа (минимально нужные поля)
public record OrderStatusHistoryLine(
Number revision, // Номер ревизии Envers
Instant revisionTime, // Время ревизии
String status // Статус заказа на момент ревизии
) {
}
Теперь query-сервис для заказов. Он будет почти таким же, как для товаров, только читаем PurchaseOrder и берём status.
import jakarta.persistence.EntityManager;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.hibernate.envers.DefaultRevisionEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Service
public class OrderAuditQueryService {
private final EntityManager entityManager;
public OrderAuditQueryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public List<OrderStatusHistoryLine> loadOrderStatusHistory(Long orderId) {
// AuditReader позволяет читать историю по ревизиям
AuditReader reader = AuditReaderFactory.get(entityManager);
List<OrderStatusHistoryLine> lines = new ArrayList<>();
for (Number rev : reader.getRevisions(PurchaseOrder.class, orderId)) {
// Снимок заказа на момент rev (читать можно, менять бессмысленно)
PurchaseOrder snapshot = reader.find(PurchaseOrder.class, orderId, rev);
// Timestamp ревизии берём из REVINFO
DefaultRevisionEntity revInfo = reader.findRevision(DefaultRevisionEntity.class, rev);
// В read-model выносим только статус и метаданные ревизии
lines.add(new OrderStatusHistoryLine(
rev,
Instant.ofEpochMilli(revInfo.getTimestamp()),
snapshot.getStatus().name()
));
}
return lines;
}
}
И это уже практически готовая “дорожная карта заказа”. Если заказ менял статус четыре раза, у вас будет четыре строки. Если статус меняли “взад-вперёд” (такое в реальных системах бывает чаще, чем хочется), история покажет это честно, а не “последний updatedAt”.
Если заказ аудируется целиком, но нужен только статус
Это частый реальный вопрос. Мы можем аудировать PurchaseOrder целиком (потому что он важен), но прикладной запрос может хотеть только статус. И здесь опять выигрывает DTO: мы читаем snapshot целиком (он всё равно формируется Envers), но наружу отдаём только нужное поле.
Если вы начнёте возвращать наружу “целый исторический заказ”, вы быстро придёте к проблемам сериализации и лишнего объёма. Поэтому и для заказа лучше сразу держать историю как read-model: статус, время, ревизия.
Ревизии группируют изменения
Для заказа это особенно полезно. Представьте, что в одной транзакции вы изменили статус заказа и обновили какие-то поля (например, totalAmount или snapshot адреса). Всё попадёт в одну ревизию. Это означает: “в рамках одного логического шага было такое состояние”.
Если вы позже захотите объяснять аудит как “события”, ревизия почти готова быть “id события”. Но сегодня мы не превращаем Envers в event sourcing (это другой жанр кино). Мы просто используем ревизию как порядок и “момент”.
Soft delete: MOD вместо DEL
Очень легко ожидать, что если сущность “удалили”, то в истории обязательно будет REVTYPE=DEL. В системах с физическим DELETE так часто и будет. Но в нашем проекте мы уже познакомились с soft delete, где “удаление” — это изменение состояния (deleted=true или аналог). Поэтому Envers видит это как обычную модификацию audited-сущности. Для истории это выглядит как ещё одна точка timeline, где, например, товар был активен, а потом стал скрытым/удалённым.
Это не баг, а очень логичное отражение модели. Если бизнес говорит “мы не удаляем товар, мы его архивируем”, то и история должна показывать “изменили состояние на архивное”, а не “стерли строку из мира”. Практически это означает: когда вы читаете историю товара, стоит включать в timeline поле, которое отражает “видимость” или deleted, если это важно. Но даже если вы этого не делаете, вы увидите дополнительную ревизию, и это уже сигнал: “что-то случилось”.
4. Типичные ошибки при чтении прикладной истории через Envers
Ошибка №1: возвращать наружу “исторические entity” как есть.
Технически AuditReader.find(Product.class, id, rev) возвращает объект типа Product или PurchaseOrder, и очень хочется “сэкономить” и отдать его дальше. Но это почти гарантированно приводит к путанице: кто-то воспринимает его как managed, кто-то пытается сериализовать граф, кто-то лезет в lazy-связи. Гораздо безопаснее сразу превращать историю в DTO и возвращать только нужные поля.
Ошибка №2: ожидать, что getRevisions(Class, id) вернёт снимки, а не номера.
getRevisions(Class, id) даёт список номеров ревизий, а не список сущностей. Это как оглавление книги: там номера страниц, но не текст. Снимок вы получаете отдельным вызовом find(Entity, id, revision).
Ошибка №3: путать ревизию Envers с @Version для optimistic locking.
Ревизия — это “историческая запись”, создаваемая при изменениях audited-сущностей. @Version — это механизм конкурентной защиты от lost update. Они могут обе выглядеть как “какой-то номер”, но семантика совершенно разная. Если перепутать, можно построить очень красивую, но очень неверную интерпретацию данных.
Ошибка №4: пытаться увидеть DEL там, где используется soft delete.
Если вы “удаляете” сущность через флаг deleted=true, Envers запишет MOD, потому что с точки зрения ORM это обычный UPDATE. Это нормально. Если вам принципиально нужен “исторический факт удаления”, то он отражается в значении audited-поля, а не в REVTYPE.
Ошибка №5: оставлять в коде Object[] из Envers-запроса как публичный контракт.
Envers query API действительно отдаёт Object[], но это не значит, что такой массив должен путешествовать по коду дальше пары строк. Сразу преобразуйте Object[] в нормальный DTO (record) — и ваш будущий вы (или коллега) скажет вам спасибо, возможно даже без сарказма.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ