JavaRush /Курсы /Spring Data JPA /Audit-поля в Mini Shop

Audit-поля в Mini Shop

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

1. Audit-поля: схема сначала, код потом

Когда проект уже дорос до Flyway, constraints и @Version, добавление audit-полей становится не «косметикой», а изменением контракта данных. В этот момент нельзя вести себя как человек, который на проде делает ALTER TABLE руками ночью «ну там две колонки, чего такого». Мы будем действовать как взрослый data-layer: сначала меняем схему через миграцию, затем приводим entity mapping к этой схеме, и только потом радуемся автоматическому auditing — желательно без сюрпризов.

Главная мысль: audit-поля — это физические колонки в таблицах. Если в Java вы добавили createdAt, а в PostgreSQL колонки нет, Hibernate не «телепат», он просто упадёт или начнёт вести себя странно. Если вы добавили колонку в БД, но забыли mapping, вы получите данные, которые не читаете и не пишете. Поэтому порядок действий здесь почти «ритуальный», но полезный: схема → backfill → constraints → mapping.

Для наглядности можно представить процесс так:

flowchart TD
    A[Нужно добавить createdAt/updatedAt] --> B[Flyway migration: добавить колонки]
    B --> C[Backfill существующих строк]
    C --> D[Сделать NOT NULL / defaults, если нужно]
    D --> E[Обновить entity: @CreatedDate/@LastModifiedDate]
    E --> F[Проверить сценарий: insert/update и SQL в БД]

2. Flyway: добавляем created_at и updated_at

Самая частая поломка при добавлении audit-полей — попытка сделать всё «красиво» за один шаг: ADD COLUMN created_at TIMESTAMP NOT NULL на таблице, в которой уже лежат строки. PostgreSQL на это отвечает вполне логично: «NOT NULL? Отлично. А какие значения поставить в существующие строки?». И дальше начинается боль.

Правильный junior-friendly путь — сначала добавить колонки как nullable (или с дефолтом), затем заполнить существующие строки, и только потом (если реально нужно) затянуть гайки до NOT NULL. В учебном проекте мы можем позволить себе backfill через now(), потому что у нас нет исторических данных с «настоящим временем создания». В реальном продукте вы часто будете искать более осмысленный источник или принимать решение «колонка nullable для старых строк — нормально».

Важно: имена колонок в БД обычно в snake_case, а поля в Java — в camelCase. Мы будем держать этот стиль последовательно: created_atcreatedAt, updated_atupdatedAt.

-- V6__add_product_audit_fields.sql

-- 1) Сначала добавляем колонки nullable: так миграция не упадёт на существующих строках
ALTER TABLE product ADD COLUMN created_at TIMESTAMP;
ALTER TABLE product ADD COLUMN updated_at TIMESTAMP;

-- 2) Backfill: заполняем существующие строки (в учебном проекте достаточно now())
UPDATE product
SET created_at = now(), updated_at = now()
WHERE created_at IS NULL OR updated_at IS NULL;

-- 3) И только после этого затягиваем до NOT NULL, чтобы дальше данные были дисциплинированными
ALTER TABLE product ALTER COLUMN created_at SET NOT NULL;
ALTER TABLE product ALTER COLUMN updated_at SET NOT NULL;

Здесь три идеи. Сначала мы добавили колонки без NOT NULL, чтобы миграция не упала на существующих строках. Потом сделали backfill, чтобы все строки получили значения. Затем сделали поля обязательными, чтобы дальше проект жил дисциплинированно.

Иногда вы увидите альтернативный стиль: ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now(). Он тоже рабочий, но у него есть нюанс: дефолт останется как часть схемы, и потом команда начинает спорить «а дефолт нужен или auditing сам заполнит?». Я обычно в учебных проектах предпочитаю backfill + NOT NULL, а дефолт не ставлю, чтобы не появлялся второй источник истины. В нашем проекте источником истины будет auditing на уровне приложения.

Чтобы не превращать лекцию в простыню SQL, зафиксируем тот набор таблиц, который берём в текущий baseline:

Таблица Поля Почему там полезно
category created_at, updated_at справочник живёт долго, меняется редко, но важно понимать «когда правили»
product created_at, updated_at товар часто меняют по цене/статусу, удобно видеть последнюю правку
customer_order created_at, updated_at заказ — важный объект домена, аудит помогает расследовать «что было когда»
order_item пока без audit-полей строка живёт внутри заказа; без явного use case не добавляем аудит ради симметрии
stock_item updated_at для остатков важнее «когда меняли», а корректность записи защищает @Version

Чтобы проект не расползался в варианты, для mini-shop на этом шаге держим такой baseline:

  • Category, Product и CustomerOrder наследуются от BaseAuditEntity;
  • StockItem остаётся special case: @Version + только updatedAt;
  • OrderItem пока оставляем без audit-полей;
  • createdBy и lastModifiedBy в обязательную рабочую конфигурацию не включаем: это дополнительный слой, а не текущий baseline проекта.

И отдельно: если вы добавляете audit-поля в существующую таблицу, не забывайте, что миграция должна оставаться additive. Мы не переписываем историю миграций и не «подправляем старую V1», потому что Flyway живёт в логике «что применили — то применили».

3. Base audit для сущностей

После миграций наступает момент, когда код должен начать соответствовать схеме. Здесь легко впасть в крайность «ну auditing же сам поставит, давайте вообще не думать». Но auditing — это инфраструктура, а не телепатия: ему нужно дать корректный mapping и понятную семантику полей.

С таким baseline проще увидеть, зачем вообще нужен общий base-класс: он идёт туда, где действительно нужен полный набор полей, а не туда, где хочется просто сделать всё одинаково.

Соберём тонкий base-класс (если вы сделали его в лекции 3 — отлично, сейчас просто доводим до боевой формы). Я покажу вариант, который хорошо подходит для Category, Product, CustomerOrder и вообще большинства долгоживущих сущностей.

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@MappedSuperclass // колонки этого класса "встраиваются" в таблицы наследников
@EntityListeners(AuditingEntityListener.class) // включаем Spring Data auditing для наследников
public abstract class BaseAuditEntity {

    @CreatedDate // заполняется при первом сохранении
    @Column(name = "created_at", nullable = false, updatable = false) // created_at не должен меняться при UPDATE
    private LocalDateTime createdAt;

    @LastModifiedDate // обновляется при каждом изменении сущности
    @Column(name = "updated_at", nullable = false) // updated_at меняется при UPDATE (это ожидаемо)
    private LocalDateTime updatedAt;
}

Обратите внимание на два маленьких, но важных решения. Для createdAt стоит updatable = false, потому что «время создания» не должно меняться при обновлениях. Для updatedAt — можно обновлять, потому что это и есть «последнее изменение». В сумме получается простая и честная модель.

Теперь подключим этот base к Product. У нас здесь цель не показать «весь Product целиком», а показать ровно то, как меняется структура класса после рефакторинга.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class Product extends BaseAuditEntity { // audit-поля "приезжают" сюда через наследование

    @Id
    private Long id;

    private String sku;
}

Да, этот пример выглядит «слишком коротким», но именно это и хорошо: аудит не должен делать сущность тяжелее. Мы просто добавили наследование от BaseAuditEntity, и теперь product получает два поля, которые реально живут в таблице product как created_at и updated_at.

Если вы хотите прямо увидеть, что @MappedSuperclass не создаёт отдельную таблицу, можно держать в голове мысль: базовый класс — это как «шаблон колонок», который разворачивается в таблицу потомка. Он не является сущностью и сам по себе не сохраняется.

classDiagram
    class BaseAuditEntity {
        <<MappedSuperclass>>
        createdAt
        updatedAt
    }
    class Product {
        id
        sku
    }
    BaseAuditEntity <|-- Product

4. StockItem: updatedAt и @Version

Когда мы говорим про остатки, хочется сделать «как у всех»: пусть StockItem тоже extends BaseAuditEntity. Но это один из тех моментов, где одинаковость не равна качеству. StockItem в нашем проекте — сущность, вокруг которой крутится optimistic locking. Для неё пара @Version + updatedAt — почти идеальный минимум: версия защищает от lost update, а updatedAt даёт человеку метку «когда это последний раз трогали».

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

Тогда StockItem может выглядеть так:

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.time.LocalDateTime;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@Entity
@EntityListeners(AuditingEntityListener.class) // auditing нужен, чтобы обновлять updated_at автоматически
public class StockItem {

    @Id
    private Long id;

    @Version // optimistic locking: защитит от lost update при конкурентных изменениях остатков
    private long version;

    @LastModifiedDate // заполняется при каждом UPDATE
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;
}

Да, здесь мы снова повесили EntityListeners(AuditingEntityListener.class). Если вы не любите дублирование, можно вынести «только updatedAt» в отдельный @MappedSuperclass, например LastModifiedEntity, и от него наследоваться — но, пожалуйста, не строите иерархию из пяти этажей. Один этаж — норм, небоскрёбы оставим для корпоративных легенд.

Теперь — миграция для stock_item. Мы добавляем только updated_at и делаем backfill. Если таблица уже существует и в ней есть строки, действуем так же аккуратно, как раньше.

-- V7__add_stock_item_updated_at.sql

-- Добавляем колонку nullable, чтобы не упасть на существующих строках
ALTER TABLE stock_item ADD COLUMN updated_at TIMESTAMP;

-- Backfill: проставляем updated_at для старых записей
UPDATE stock_item
SET updated_at = now()
WHERE updated_at IS NULL;

-- Закрепляем контракт: дальше updated_at обязателен
ALTER TABLE stock_item ALTER COLUMN updated_at SET NOT NULL;

И вот здесь получается приятная «инженерная композиция»: StockItem защищён от конфликтов версией, и при каждом изменении количества у нас автоматически будет обновляться updatedAt.

5. Проверка в работе и границы

После миграций и обновления entity-классов хочется убедиться, что auditing работает не «в теории». Это момент, где начинающий разработчик часто попадает в ловушку ожиданий: он изменил поле у объекта, посмотрел на объект в отладчике и не увидел updatedAt, после чего решил «аудит сломан». На самом деле auditing обычно заполняет поля в момент сохранения/flush, то есть когда сущность реально участвует в persistence lifecycle.

Покажу минимальный сервисный сценарий «переименовать товар» — без ручного setUpdatedAt, потому что мы же ради этого всё и затевали.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;

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

    @Transactional // важно: внутри транзакции сущность будет managed, сработает dirty checking и auditing
    public void renameProduct(Long id, String newName) {
        Product p = productRepository.findById(id).orElseThrow(); // managed entity после загрузки
        p.setName(newName); // меняем поле: Hibernate увидит dirty state и сделает UPDATE
        // updated_at выставится AuditingEntityListener при flush/commit
    }
}

Если сущность Product managed (а внутри @Transactional она именно managed), то dirty checking сделает своё дело, Hibernate отправит UPDATE, а auditing обновит updated_at на актуальное значение. В идеале вы включаете SQL-логи в dev-профиле и видите, что в UPDATE product ... присутствует колонка updated_at.

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

-- Проверяем, что created_at не меняется, а updated_at меняется после обновлений
SELECT id, sku, created_at, updated_at
FROM product
WHERE id = 1;

И вы увидите, что created_at не меняется, а updated_at меняется после обновлений.

Похожий смысл имеет проверка для StockItem. Если у вас есть метод, который резервирует остаток или списывает доступное количество, после выполнения операции можно сделать:

-- Проверяем, что @Version инкрементится, а updated_at обновляется
SELECT id, version, updated_at
FROM stock_item
WHERE id = 1;

Если optimistic locking включён и @Version работает, то версия увеличивается при каждом UPDATE. А updatedAt обновляется примерно в тот же момент. В реальном проекте это сочетание часто помогает очень быстро понять «почему конфликт версии случился именно сейчас» и «когда последний раз кто-то менял остаток».

И вот здесь появляется уже не вопрос аннотаций, а вопрос доказательства. Раз audit-поля, миграции и @Version влияют на реальный SQL и состояние таблиц, их полезно закреплять не только ручной проверкой в базе, но и data-layer тестами. Иначе следующий рефакторинг легко сломает то, что сейчас кажется очевидным.

Граница soft delete

Тут обычно происходит стандартная сцена: кто-то видит createdAt/updatedAt, вспоминает слово «аудит», а дальше мозг сам достраивает «значит надо soft delete, ведь удалять нельзя, вдруг пригодится». И вот на этом месте мы делаем паузу и вспоминаем, что наш курс — про базовый, поддерживаемый data-layer, а не про коллекцию «самых спорных паттернов индустрии за 20 лет».

Soft delete — это не «ещё одно audit-поле». Это смена смысла операции удаления и смена смысла чтения. С точки зрения БД и запросов, вы как будто начинаете жить в мире, где таблица постоянно хранит «мёртвые записи», а каждый запрос обязан помнить «и не забудь WHERE deleted = false». И если вы хоть где-то забудете — вы получите «зомби-данные», которые внезапно вылезают в отчётах, списках и проверках уникальности.

Особенно неприятно soft delete конфликтует с UNIQUE. Представьте product.sku уникален. Вы «удалили» товар soft delete-ом, но строка осталась, значит sku всё ещё занят. В какой-то момент бизнес скажет «создай новый товар с тем же SKU» (да, так бывает в реальности, когда SKU переиспользуют или ошиблись), и вы внезапно упираетесь в уникальность, хотя «товар же удалён». После этого начинается отдельная большая инженерная история про partial unique indexes, фильтры и договорённости. Мы сейчас туда сознательно не идём.

Здесь достаточно зафиксировать границу: auditing отвечает за «когда/кем меняли», а soft delete — за модель удаления и правила чтения. Это разные оси решений, и смешивать их не стоит.

6. Типичные ошибки при audit-полях

Ошибка №1: добавить поля в entity и забыть миграцию.
Это выглядит как самая невинная ошибка: вы добавили createdAt и updatedAt в Product, запустили приложение — и оно либо упало на старте, либо начало ругаться при первом сохранении. Причина простая: ORM не умеет «материализовать» колонки из воздуха. Лечится дисциплиной: сначала Flyway-миграция, потом Java-код. Если хочется «быстро проверить», можно временно включить ddl-auto, но мы уже договорились, что в production-like версии проекта это не источник правды.

Ошибка №2: добавить NOT NULL колонки в таблицу с данными без backfill.
Это классика уровня «PostgreSQL всё сломал». На самом деле PostgreSQL просто честный: если вы требуете NOT NULL, он хочет значения. Поэтому либо добавляйте колонки nullable, делайте UPDATE ... SET ... WHERE ... IS NULL, и только потом ставьте NOT NULL, либо добавляйте с дефолтом и потом осознанно решайте, оставлять дефолт в схеме или нет.

Ошибка №3: ожидать, что audit-поля заполнятся “сразу после new”.
Auditing привязан к persistence lifecycle. Если вы сделали new Product() и тут же посмотрели createdAt, он будет null, и это нормально. Поля заполняются при persist/update, чаще всего на flush/commit. Если вам нужно увидеть значения в коде прямо сейчас — убедитесь, что сущность действительно сохранена (и что транзакция дошла до flush).

Ошибка №4: смешать два источника истины — auditing и ручной setUpdatedAt(now).
Часто это происходит после рефакторинга: часть сервисов уже «почистили», а часть ещё ставит updatedAt вручную. В итоге у вас логика времени размазана по коду, и вы сами не знаете, кто победит. Хороший стиль — выбрать один подход. В нашем проекте это Spring Data auditing, а ручные setter’ы времени мы не используем.

Ошибка №5: сделать одинаковый audit-шаблон для всех сущностей, даже когда это бессмысленно.
Да, можно заставить всё наследоваться от одного base-класса, включая StockItem, OrderItem и вообще «каждую строку во вселенной». Но это превращает аудит в формальность и добавляет шум. Гораздо полезнее держать аудит там, где он реально читается и помогает: каталог, заказ, остатки. Для StockItem вполне нормально иметь только updatedAt, потому что это сущность про «текущее состояние», и её история нам важнее по версии (@Version), чем по дате создания.

1
Задача
Spring Data JPA, 26 уровень, 4 лекция
Недоступна
Additive-миграция товара на audit base
Additive-миграция товара на audit base
1
Задача
Spring Data JPA, 26 уровень, 4 лекция
Недоступна
`StockItem` с `updatedAt`, `@Version` и физическим удалением
`StockItem` с `updatedAt`, `@Version` и физическим удалением
1
Опрос
Аудит сущностей, 26 уровень, 4 лекция
Недоступен
Аудит сущностей
Поля времени и автора
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ