1. Audit-поля: смысл и границы
Когда разработчик слышит слово auditing, мозг иногда сразу рисует гигантскую систему: «храним все версии каждой строки», «ревизии», «кто что изменил», «отчёты для службы безопасности» — и вот уже вместо курса по Spring Data JPA мы внезапно строим мини-банкинг с комплаенсом. Спойлер: здесь мы так не делаем, и это хорошо.
Здесь auditing — это прежде всего поддержка технических полей текущего состояния сущности. Мы не собираем «полную хронику» каждой правки, а фиксируем простые факты: когда запись появилась и когда последний раз менялась. Это маленький, но очень практичный шаг: такой аудит нужен почти в любом backend-проекте, даже если он далёк от требований «всё логировать навечно».
Мы уже разобрали, что @Version защищает нас от конфликтов записи. Но даже когда конфликтов нет, данные всё равно живут во времени: запись когда-то появилась и потом менялась. Auditing закрывает именно эту часть картины — не конкуренцию, а наблюдаемость и поддержку данных.
Чтобы не было путаницы, зафиксируем границу прямо сейчас: мы говорим о metadata записи, а не о version history. Схематично это можно представить так:
flowchart LR %% Текущая сущность и её «технические следы» (не история версий) A["Entity (текущее состояние)"] --> B["createdAt / updatedAt"] A --> C["@Version (optimistic locking)"] %% История изменений — отдельная тема, в этот день не лезем A -. "не сегодня" .-> D["История изменений / ревизии"]
Тут важно почувствовать тонкую разницу. @Version защищает нас от конфликтов записи. updatedAt делает запись «читаемой» в плане жизненного цикла: когда менялась, как давно, можно ли считать данные свежими. История ревизий — отдельная вселенная, и мы её сознательно не открываем, чтобы не потерять фокус курса.
Роль createdAt и updatedAt
Пока вы учитесь, легко воспринимать сущность как «набор бизнес-полей»: sku, price, status, availableQuantity. Но как только проект становится хоть немного похожим на настоящий, появляются вопросы, которые звучат не как бизнес-логика, а как эксплуатация и поддержка: «Когда это создали?», «Когда это меняли?», «Почему заказ “висит” уже два часа?». Именно тут audit-поля начинают окупаться.
createdAt обычно отвечает на вопрос «когда запись появилась в базе». Это важно и для диагностики (когда завезли “битые” данные), и для простых сценариев (новинки каталога), и для расследований «почему оно так». updatedAt отвечает на вопрос «когда это меняли в последний раз». А это уже почти универсальная вещь: от поиска «что изменилось сегодня» до отслеживания «что давно не обновлялось».
В проекте mini-shop это тоже звучит очень жизненно. Представьте ситуацию: менеджер жалуется, что цены «прыгают». Без updatedAt вы видите только текущее значение. С updatedAt вы хотя бы понимаете, когда оно стало таким. И это уже улучшает диагностику, даже без сложной истории изменений.
Минимальный «скелет» сущности с аудитом может выглядеть так:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
@Entity
class Product {
@Id
private Long id;
// Когда запись впервые появилась в таблице (обычно ставится один раз)
private LocalDateTime createdAt;
// Когда запись последний раз реально менялась (обновляется при UPDATE)
private LocalDateTime updatedAt;
}
Да, пока эти поля «просто лежат». Но даже их наличие в модели — это уже проектное решение: мы признаём, что сущность живёт во времени, а не появляется «из воздуха» и не меняется «сама по себе».
2. Категории полей в сущности
На практике новички чаще всего спотыкаются не о JPA-аннотации, а о смысл. В одном классе начинают жить и бизнес-смысл, и техническая инфраструктура, и «ещё что-то, потому что так надо». Поэтому полезно заранее разложить поля по ролям, иначе через месяц вы будете читать сущность как роман на 800 страниц: вроде интересно, но непонятно, зачем.
Бизнес-поля — это то, что описывает предметную область: у товара есть sku, name, price, у заказа — status, customerEmail, у остатка — availableQuantity. Audit-поля описывают не домен, а факт существования и изменения записи: когда создана, когда обновлена, иногда кем. Технические поля вроде id и @Version живут ещё ниже: они помогают ORM и базе обеспечить идентичность и корректность записи.
Небольшая таблица, чтобы закрепить, кто за что отвечает:
| Категория полей | Примеры | На какой вопрос отвечают | Типичная ошибка новичка |
|---|---|---|---|
| Бизнес-поля | price, status, availableQuantity, customerEmail | «Что это такое?» и «В каком бизнес-состоянии?» | Пытаться заменить их техполями (updatedAt вместо статуса) |
| Audit-поля | createdAt, updatedAt | «Когда создано/обновлено?» | Считать, что это “мусор”, который не нужен |
| Технические поля | id, @Version version | «Как объект идентифицируется?» и «Как защититься от конкуренции?» | Пытаться “сэкономить” и использовать updatedAt вместо @Version |
И ещё одно важное: audit-поля — часть persistence-модели, а не «временные переменные для логов». Если поле важно, оно должно быть в базе, и тогда оно становится реальным контрактом данных.
3. Семантика createdAt
С createdAt всё кажется простым: поставили дату — и забыли. Но именно из-за этой кажущейся простоты его легко испортить. Например, кто-то однажды решит «пересоздать» запись, или случайно перезатрёт поле при апдейте, или начнёт менять createdAt «потому что мы импортировали данные». В итоге поле перестаёт отвечать на вопрос «когда сущность появилась», и превращается в случайное число.
Нормальная семантика createdAt — это метка момента, когда запись впервые стала persisted. Это значит: запись появилась в таблице. Почти всегда createdAt должен устанавливаться один раз и больше не меняться. И, что важно, он относится к слою данных: это не «когда пользователь нажал кнопку», а «когда эта сущность реально оказалась в базе как строка».
В терминах нашего проекта: Product можно создать сегодня, обновлять цену завтра, менять статус послезавтра — но createdAt не должен прыгать как лягушка в панике. Он должен оставаться якорем: когда этот товар впервые появился в каталоге.
Мини-пример того, как выглядит «не трогаем createdAt руками» (пока без автоматизации, просто мысль):
import java.time.LocalDateTime;
public class Product {
private LocalDateTime createdAt;
public void markCreatedNow() {
// createdAt ставим только один раз — при первом создании записи
if (createdAt == null) {
// В реальном проекте «now()» лучше централизовать (Clock/инфраструктура),
// здесь — просто иллюстрация семантики.
createdAt = LocalDateTime.now();
}
}
}
Это не финальная архитектура и не призыв писать бизнес-логику в entity. Это иллюстрация семантики: createdAt устанавливается один раз. Здесь важна сама семантика: createdAt устанавливается один раз. Только после этого имеет смысл автоматизировать его заполнение.
4. Семантика updatedAt
updatedAt звучит как «ну это же просто дата последнего обновления», но у новичка тут обычно два крайних состояния. Первое: «а зачем это вообще нужно?» Второе: «давайте обновлять updatedAt вообще при каждом чихе, включая чтение». В нормальном проекте мы делаем третье: обновляем метку при реальной модификации persisted состояния сущности.
Что считается изменением? Практически любая запись, которая приводит к изменению строки в таблице: смена цены товара, смена статуса заказа, изменение количества на складе. А вот чтение, сортировка, выдача списка — не изменение. Иначе вы получите эффект «посмотрел каталог — все товары “обновились”», что звучит примерно как «я открыл холодильник — у еды изменился срок годности».
В mini-shop updatedAt особенно полезен для сущностей, которые реально живут и меняются: Product, CustomerOrder, StockItem. Простейший вид StockItem с audit-меткой уже становится понятнее для человека, который смотрит в базу:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.time.LocalDateTime;
@Entity
class StockItem {
@Id
private Long id;
// Механизм optimistic locking: защищает от lost update
@Version
private long version;
// Когда строка в таблице в последний раз менялась (наблюдаемость)
private LocalDateTime updatedAt;
}
Обратите внимание: updatedAt «про время», version «про конкуренцию». Вместе они дают более читаемую картину: запись менялась, и менялась столько-то раз (потому что версия растёт). Но одно не заменяет другое.
5. Audit-поля в схеме и конкуренции
Audit-поля в SQL и запросах
Сущность в JPA — это красиво, но картина всё равно SQL-backed. Поэтому важно сразу мысленно увидеть, как audit-поля выглядят в таблице. Это не «атрибуты для Java», это реальные колонки, которые будут жить рядом с вашими sku, price, status. А значит, у них будут типы, ограничения и даже индексы, если по ним часто ищут.
Если смотреть на уже приведённую в порядок схему, audit-поля выглядят как обычные колонки рядом с бизнес-полями. Например, так:
-- Фрагмент итоговой схемы: audit-колонки живут рядом с бизнес-полями
create table product (
id bigint primary key,
-- ... бизнес-колонки ...
created_at timestamp not null,
updated_at timestamp not null
);
Здесь нам важна именно форма колонок в готовой схеме. Если таблица уже не пустая, к такой форме приходят поэтапно: сначала добавляют колонки, потом делают backfill, и только потом ставят NOT NULL.
Даже если вы пока не включили автоматическое заполнение, смысл простой: база хранит эти метки, и они становятся частью контракта данных.
А теперь самое интересное: как только поле есть, его начинают использовать в read-сценариях. Например, «покажи товары, обновлённые после определённого момента». У нас уже был модуль про derived queries, поэтому пример будет вполне в рамках пройденного:
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Ищем сущности, которые реально обновлялись после указанного момента
List<Product> findByUpdatedAtAfter(LocalDateTime point);
}
Это хороший момент для смены ощущения: audit-поля — не «служебный мусор», который мешает. Это нормальные данные, которые иногда очень нужны. И если их нет, вы либо не сможете ответить на вопрос, либо будете выдумывать костыли уровня «давайте читать по id, потому что он растёт» (что, мягко говоря, не всегда правда).
updatedAt и @Version: разные задачи
После дня про optimistic locking очень хочется сказать: «А давайте вместо @Version просто посмотрим на updatedAt. Если время поменялось — значит кто-то обновлял!» Идея звучит логично, пока вы не вспомните, что часы — это не замок на двери. Они показывают, что кто-то приходил, но не мешают двум людям одновременно перетащить один и тот же стул в разные стороны.
@Version защищает от lost update технически: Hibernate добавляет версию в WHERE при UPDATE. Если кто-то уже изменил запись, версия не совпадёт, и обновление не пройдёт. updatedAt же просто поле, которое можно перезаписать последним, и оно никак не предотвращает «затирание» чужих изменений.
Условная иллюстрация того, что делает optimistic locking (приближённо, но по смыслу верно):
-- optimistic locking: обновляем строку только если версия всё ещё ожидаемая
update stock_item
set available_quantity = 9,
version = version + 1
where id = 10
and version = 3;
Если строка уже стала version = 4, запрос затронет 0 строк — и ORM поймёт: «конфликт». Это защита.
А теперь представьте вариант «на timestamp»: два параллельных потока читают available_quantity = 10, оба уменьшают до 9, оба пишут 9 и ставят updated_at = now(). Последний победит, но вы даже не узнаете, что был конфликт. В лучшем случае updatedAt покажет «время последней записи», но это не решает проблему lost update, а только делает её более заметной постфактум.
В итоге правильно держать в голове простую мысль: @Version — про корректность конкуренции, updatedAt — про наблюдаемость и обслуживание данных. Они дружат, но не подменяют друг друга.
6. Где держать аудит и тип времени
Где «живёт» аудит: entity vs сервисы
Один из самых частых «скрытых» анти-паттернов — ручное обновление audit-полей в сервисах. Поначалу кажется нормальным: «ну я же знаю, что здесь мы меняем продукт — поставлю updatedAt». Потом появляется ещё один сервис. Потом bulk-операция. Потом новый разработчик. Потом метод, который обновляет сразу три сущности. И где-то в этом лесу вы забываете поставить updatedAt, а данные начинают жить своей жизнью.
Кроме того, ручное LocalDateTime.now() в каждом месте делает код шумным и создаёт ложную связанность. Сервис должен отвечать за use case, а не за техническую мета-информацию. И ещё один момент, который особенно важен после темы dirty checking: изменения могут улетать в базу не «там, где вы думаете». Если вы обновляете поле сущности в транзакции, Hibernate может сделать UPDATE позже, при flush/commit. Ручной updatedAt = now() в начале метода может легко стать «не совсем временем обновления».
Посмотрите на типичный кусок кода, который очень хочется написать:
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Transactional
public void changePrice(Long id, BigDecimal newPrice) {
Product p = productRepository.findById(id).orElseThrow();
p.setPrice(newPrice);
// Анти-паттерн для роста проекта: легко забыть, сложно поддерживать единообразие
// Плюс «now()» здесь — это время выполнения кода, а не обязательно время реального UPDATE в БД
p.setUpdatedAt(LocalDateTime.now()); // хочется "прибить гвоздём"
}
Он не «катастрофа» сам по себе, но он плохо масштабируется. Если сегодня мы осознанно добавляем audit-поля, то логичный следующий шаг — перестать ставить их вручную и дать это инфраструктуре. Но инфраструктура не должна появиться «магией», поэтому в первой лекции дня мы фиксируем именно смысл: зачем поля нужны и почему сервисы не должны быть их главным владельцем.
Тип времени и проектный договор
Дата и время — это классическая тема, где можно спорить до утра, а потом всё равно получить баг в полночь. Поэтому здесь важно не выучить «идеально правильный тип», а зафиксировать понятный и единый стиль для проекта. Если в одной сущности LocalDateTime, в другой Instant, в третьей OffsetDateTime, а где-то ещё java.util.Date, то вы не строите auditing — вы строите музей экспонатов «как люди страдали в Java до 2014 года».
Для mini-shop удобно держаться простого и читабельного варианта: LocalDateTime для createdAt/updatedAt. Это не единственный вариант в мире, но для учебного проекта он даёт понятную модель и хорошую читаемость в коде. Главное, чтобы выбор был единым, а не «где рука дрогнула — там и тип».
Ещё один договор — не пытаться сделать audit-поля одинаковыми абсолютно везде. Некоторым сущностям полезны оба поля, некоторым можно оставить только updatedAt (например, где создание не так интересно, а изменения важны), а где-то аудит вообще не имеет смысла. В mini-shop чаще всего логично иметь createdAt и updatedAt у Category, Product, CustomerOrder, а у StockItem особенно ценно видеть updatedAt рядом с @Version. Это не «закон JPA», а проектное решение: его важно принять осознанно, а не по инерции.
7. Типичные ошибки при audit-полях
Ошибка №1: считать audit-поля «мусором», который можно добавить потом.
Когда проект маленький, кажется, что без createdAt/updatedAt можно жить. Но как только появляются реальные вопросы «когда это изменилось» или «что было затронуто вчера», вы начинаете гадать по косвенным признакам, логам или id. Проблема в том, что аудит лучше закладывать заранее — тогда он становится естественной частью модели, а не болезненной переделкой схемы.
Ошибка №2: пытаться заменить бизнес-состояние техническими метками.
Иногда хочется сказать: «Если updatedAt недавно — значит товар активный». Или «Если updatedAt давно — значит заказ отменён». Это разные измерения. status и active описывают бизнес-смысл, а audit-поля — техническую информацию. Если смешать эти роли, модель данных станет нечитаемой и начнёт обманывать.
Ошибка №3: пытаться заменить optimistic locking полем updatedAt.
updatedAt не защищает от lost update. Он может помочь заметить, что запись менялась, но не предотвращает конфликт и не даёт ORM механизма корректно отреагировать. Для StockItem (и любых данных, где важна конкуренция) @Version остаётся отдельной и обязательной частью модели.
Ошибка №4: обновлять audit-поля вручную в каждом сервисе и радоваться, что «контроль в наших руках».
На короткой дистанции это кажется «явным». На длинной дистанции это приводит к копипасте, забытым местам, разной семантике в разных методах и странным расхождениям. Если поле должно жить как системная мета-информация, оно должно заполняться системно, а не «по настроению разработчика».
Ошибка №5: хаотично смешивать типы времени и семантики в разных сущностях.
Если в одном месте LocalDateTime, в другом Instant, и при этом никто в команде не может объяснить, почему так — это верный путь к путанице в запросах, в миграциях и в понимании данных. Лучше выбрать один стиль и держаться его, чем устроить «зоопарк времени» прямо в persistence-модели.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ