JavaRush /Курсы /Spring Data JPA /JPA Auditing: createdAt и updatedAt

JPA Auditing: createdAt и updatedAt

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

1. Ручное время — путь к рутине

Смысл createdAt и updatedAt уже понятен: это технические поля, а не бизнес-состояние, и тянуть LocalDateTime.now() по сервисам — плохая идея. Как только таких мест становится несколько, audit-логика расползается по use case’ам, а меткам времени перестаёшь доверять.

Здесь и появляется естественный вопрос: если время должно ставиться по факту INSERT и UPDATE, может ли это делать сама persistence-инфраструктура? Может. Spring Data JPA умеет подвязывать audit-поля к lifecycle сущности, так что они обновляются там, где реально происходит работа с БД, а не там, где разработчик вспомнил про now().

Это не только меньше кода. Главное — один источник правды по audit-полям.

2. Spring Data JPA Auditing для createdAt/updatedAt

Чтобы не воспринимать auditing как «магическую аннотацию, которая всё делает», полезно представить простую картину. В JPA у сущностей есть lifecycle-события: перед вставкой (INSERT) и перед обновлением (UPDATE) ORM вызывает специальные callbacks. Spring Data JPA умеет подключаться к этим событиям через entity listener и автоматически заполнять поля, которые мы пометили как audit-поля.

То есть auditing — это не отдельный сервис, не отдельный репозиторий и не «где-то там в контроллере». Это инфраструктура, которая работает в момент, когда сущность реально участвует в persistence lifecycle. Вы меняете бизнес-поле, Hibernate готовится отправить UPDATE, и прямо перед этим auditing проставляет updatedAt. Вы создаёте новую сущность, Hibernate готовится отправить INSERT, и прямо перед этим auditing проставляет createdAt и (обычно) updatedAt.

Очень удобно, что этот механизм «привязан» к реальности базы: нет INSERT/UPDATE — нет и причин менять audit-времена.

Чтобы у вас сложилась чёткая ментальная модель, вот маленькая табличка (она заменяет десяток абстрактных фраз):

Ситуация в ORM Что происходит в БД Какое audit-поле заполняется
сущность новая INSERT createdAt и updatedAt
сущность изменена (dirty) UPDATE updatedAt

Внутри Spring Data это завязано на три ключевые части, которые сегодня мы сделаем «видимыми»:

@EnableJpaAuditing включает auditing-инфраструктуру в приложении. Без этого ваши @CreatedDate и @LastModifiedDate будут просто красивыми наклейками на поле.

@CreatedDate и @LastModifiedDate — это маркеры для полей. Вы явно говорите: «это не бизнес-поле, это metadata».

AuditingEntityListener — entity listener, который в нужный момент lifecycle проставляет значения.

3. Включение @EnableJpaAuditing

Самый частый beginner-фейл с auditing выглядит так: студент аккуратно аннотировал поля @CreatedDate и @LastModifiedDate, запустил приложение, сохранил сущность — а поля createdAt и updatedAt остались null. Почему? Потому что auditing — это не «просто аннотации на entity». Ему нужно включение на уровне Spring-конфигурации.

В нашем проекте shop-data-jpa мы держим инфраструктурные настройки в common-зоне. Поэтому логично завести конфигурационный класс, например, в пакете com.example.shopdatajpa.common.config.

Минимальная конфигурация выглядит почти подозрительно короткой (и это хорошо — меньше шансов сломать):

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing // Включаем инфраструктуру auditing (без этого аннотации на полях не сработают)
public class JpaAuditingConfig {
    // Класс может быть пустым: нам важен сам факт включения через аннотацию
}

Несколько важных пояснений, которые обычно «неочевидны, пока не наступишь»:

Spring Boot должен увидеть этот класс при component scan. Самый надёжный способ — держать его внутри базового package вашего приложения (того, откуда стартует @SpringBootApplication). Если положить конфиг «куда-нибудь в сторону», он может не подхватиться, и вы будете смотреть на null-поля как на загадку вселенной.

@EnableJpaAuditing включает auditing как возможность, но не означает, что всё автоматически заработает у любой сущности. Сущность должна участвовать в auditing через entity listener — без него поля останутся обычными полями.

И ещё одна хорошая новость: никаких дополнительных зависимостей ради базового auditing обычно не нужно. Если у вас уже есть spring-boot-starter-data-jpa, то нужные классы и аннотации уже в стеке.

4. Аннотации @CreatedDate и @LastModifiedDate

Когда инфраструктура включена, пора сказать ей: «вот эти поля — audit-поля, заполняй их автоматически». Делается это в два слоя: мы добавляем аннотации на поля и подключаем AuditingEntityListener, чтобы JPA lifecycle события доходили до Spring Data auditing.

Пока полезно видеть audit-поля прямо в самой сущности: так легче заметить, какие аннотации отвечают за заполнение, а какие — за mapping колонок. Вынесение общей части в базовый класс имеет смысл только после того, как сама механика уже понятна.

Ниже — компактный «кусочек» сущности Product, который показывает именно auditing-часть. Обратите внимание: здесь нет ручных вызовов now(), только декларация намерения.

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@EntityListeners(AuditingEntityListener.class) // Подключаем listener: он реагирует на PrePersist/PreUpdate
class Product {

    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt; // Заполняется автоматически при INSERT

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

Здесь есть несколько тонких моментов, которые сильно помогают новичкам держать всё в голове правильно.

Аннотации @CreatedDate и @LastModifiedDate лежат в пакете org.springframework.data.annotation. Это именно Spring Data, а не JPA. Они не «создают колонку в БД», не «включают миграции», они только размечают поле как участник auditing.

@EntityListeners(AuditingEntityListener.class) — это мост между JPA lifecycle и Spring Data auditing. Сам AuditingEntityListener находится в org.springframework.data.jpa.domain.support. Можно сказать так: без listener’а вы дали полям роли, но забыли подключить актёров к сцене, и спектакль не начался.

createdAt часто помечают updatable = false, чтобы оно не менялось при UPDATE. Это не «обязательное правило вселенной», но практичный guardrail. Иначе можно случайно сделать product.setCreatedAt(...) (даже не специально), а ORM послушно запишет это в базу. Audit-поля тем и хороши, что должны быть максимально предсказуемыми.

nullable = false в примере — это цель, к которой мы обычно приходим. Но реальный проектный нюанс такой: если таблицы уже существуют и в них есть строки, то добавление NOT NULL колонки требует аккуратной миграции и backfill. Мы это сегодня обязательно будем учитывать на уровне проекта, но прямо в этой лекции мы фокусируемся на механике Spring Data auditing и не делаем миграционные сценарии главным героем.

Кстати, часто на первом сохранении createdAt и updatedAt получаются одинаковыми. Это ожидаемо: сущность «создалась» и «последний раз изменялась» в один и тот же момент.

5. updatedAt: транзакции и flush

Есть один момент, который почти гарантированно вызывает у новичка лёгкий когнитивный диссонанс. Вы меняете поле сущности в сервисе, а updatedAt… как будто «не меняется прямо сейчас». Возникает желание снова вставить ручной now() и жить спокойно. Но тут важно вспомнить, как мы уже обсуждали persistence context и flush: SQL улетает в базу не «на каждом сеттере», а когда ORM синхронизирует изменения с БД (обычно на flush/commit).

Auditing работает на тех же правилах. @LastModifiedDate проставляется не в момент вызова setName(...), а в момент, когда Hibernate реально готовится выполнить обновление.

Чтобы не было ощущения магии, можно представить это как небольшую цепочку событий:

flowchart TD
    A["Вы в @Transactional меняете entity"] --> B["Entity становится dirty"]
    B --> C["В конце транзакции: flush/commit"]
    C --> D["AuditingEntityListener: PreUpdate"]
    D --> E["updatedAt = now"]
    E --> F["Hibernate отправляет UPDATE в БД"]

Вот почему updatedAt — это именно persistence-метка. Она говорит: «данные действительно фиксировались как обновление».

Посмотрим на простой сервисный метод в стиле нашего проекта. Мы намеренно не трогаем updatedAt руками — это и есть цель лекции.

import org.springframework.transaction.annotation.Transactional;

@Transactional // Граница, внутри которой Hibernate копит изменения и потом делает flush/commit
public void renameProduct(Long id, String newName) {
    Product product = productRepository.findById(id).orElseThrow();
    product.setName(newName); // updatedAt выставится автоматически на flush/commit (перед UPDATE)
}

Если вы включите SQL-логирование (у нас оно уже использовалось в прошлых днях для диагностики), вы увидите, что при завершении транзакции улетит UPDATE, и вместе с изменением name в запрос попадёт и updated_at. Это сильно дисциплинирует мозг: auditing — не «отдельная логика», а часть того же жизненного цикла, что и обычный UPDATE.

А вот сценарий создания сущности обычно даёт вам более «моментальный» эффект: поля проставляются на persist, поэтому после save вы уже можете увидеть заполненные значения.

import org.springframework.transaction.annotation.Transactional;

@Transactional // Persist/INSERT произойдёт в рамках транзакции, и auditing заполнит createdAt/updatedAt
public Product createProduct(Product product) {
    Product saved = productRepository.save(product);
    System.out.println(saved.getCreatedAt()); // 2026-03-21T12:34:56.789 (пример)
    return saved;
}

Здесь важно не перепутать: save возвращает сущность уже в managed-состоянии, и audit-поля будут проставлены, потому что persist-событие произошло. В update-сценариях это чаще «видно» ближе к концу транзакции, когда ORM реально синхронизируется с базой.

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

6. Тип времени для audit-полей

На этом месте не нужно устраивать философский спор про «идеальный» тип времени. Для mini-shop audit-поля остаются LocalDateTime: тип хорошо читается в коде и предсказуемо маппится в timestamp.

Главное здесь — единый проектный договор. Если в одной сущности хранить LocalDateTime, в другой Instant, а в третьей OffsetDateTime без ясной причины, auditing не станет “умнее” — просто появится ещё одна зона путаницы. И да, auditing не чинит часы окружения: он лишь последовательно заполняет поля тем временем, которое доступно приложению.

7. Типичные ошибки при подключении JPA auditing

В финале соберём типовые грабли. Хорошая новость: почти все они диагностируются очень быстро, если вы понимаете два принципа: auditing включается конфигурацией и работает через lifecycle сущности.

Ошибка №1: поля размечены, но @EnableJpaAuditing забыли.
Симптом максимально простой: createdAt и updatedAt остаются null после save. Лечится без магии: добавляете конфигурационный класс с @EnableJpaAuditing и проверяете, что он попадает в component scan.

Ошибка №2: включили auditing, но не подключили AuditingEntityListener.
Это второй по популярности случай. Вы всё сделали «почти правильно», но забыли @EntityListeners(AuditingEntityListener.class). В результате инфраструктура включена, но entity не участвует в auditing-механике, и поля не заполняются. Особенно коварно, если в одной сущности вы listener подключили, а в другой нет: тогда кажется, что auditing «работает через раз».

Ошибка №3: ожидание, что updatedAt меняется сразу после setXxx(...).
Если вы печатаете updatedAt прямо посреди транзакции и ждёте, что оно уже новое, вы можете увидеть старое значение. Это не баг auditing, это следствие того, что auditing живёт в момент flush/commit. Думайте об этом как о «печати на документе»: штамп ставят не когда вы начали заполнять бланк, а когда вы его реально сдаёте.

Ошибка №4: одновременно автоматическое auditing и ручное setUpdatedAt(now()) в сервисе.
Так тоже можно сделать, но это обычно рождает путаницу. Во-первых, вы теряете смысл автоматизации. Во-вторых, вы получаете два источника правды. А два источника правды в программировании — это как два лидера в одной команде: вроде оба умные, но потом начинается борьба за правоту. Выбирайте один стиль. В нашем проекте стиль дня — автоматический auditing.

Ошибка №5: колонок в БД нет (или они названы иначе), а вы уже добавили поля в entity.
Auditing не создаёт колонки. Если в сущности появилось поле createdAt, а в таблице нет created_at, при запуске приложения вы, скорее всего, встретите ошибку маппинга/DDL-несоответствия (в зависимости от настроек). Правильный подход в нашей траектории — добавлять колонки через Flyway миграции и держать схему как source of truth. Если вы пока в dev-режиме жили на автогенерации схемы, это особенно легко пропустить.

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