1. El tiempo manual — el camino a la rutina
El significado de createdAt y updatedAt ya está claro: son campos técnicos, no estado de negocio, y arrastrar LocalDateTime.now() por los servicios es una mala idea. En cuanto hay varios lugares así, la lógica de auditoría se dispersa entre los casos de uso y dejas de confiar en las marcas de tiempo.
Aquí surge una pregunta natural: si la hora debe establecerse según el hecho del INSERT y UPDATE, ¿puede hacerlo la propia infraestructura de persistencia? Sí. Spring Data JPA puede vincular los campos de auditoría al ciclo de vida de la entidad, de modo que se actualicen donde realmente ocurre el trabajo con la BD, no donde el desarrollador se acordó de now().
Esto no solo es menos código. Lo principal es una única fuente de verdad para los campos de auditoría.
2. Spring Data JPA Auditing para createdAt/updatedAt
Para no percibir la auditoría como «una anotación mágica que lo hace todo», es útil imaginar un cuadro simple. En JPA, las entidades tienen eventos de ciclo de vida: antes de la inserción (INSERT) y antes de la actualización (UPDATE), el ORM llama a callbacks especiales. Spring Data JPA puede conectarse a estos eventos a través de un entity listener y rellenar automáticamente los campos que hemos marcado como campos de auditoría.
Es decir, la auditoría no es un servicio separado, ni un repositorio separado, ni «en algún lugar del controlador». Es infraestructura que funciona en el momento en que la entidad realmente participa en el ciclo de vida de persistencia. Cambias un campo de negocio, Hibernate se prepara para enviar un UPDATE, y justo antes, la auditoría asigna updatedAt. Creas una nueva entidad, Hibernate se prepara para enviar un INSERT, y justo antes, la auditoría asigna createdAt y (normalmente) updatedAt.
Es muy cómodo que este mecanismo esté «vinculado» a la realidad de la base de datos: sin INSERT/UPDATE no hay razón para cambiar las horas de auditoría.
Para que tengas un modelo mental claro, aquí tienes una pequeña tabla (sustituye a decenas de frases abstractas):
| Situación en el ORM | Qué ocurre en la BD | Qué campo de auditoría se rellena |
|---|---|---|
| entidad nueva | INSERT | createdAt y updatedAt |
| entidad modificada (dirty) | UPDATE | updatedAt |
Internamente, Spring Data vincula esto a tres partes clave que hoy haremos «visibles»:
@EnableJpaAuditing habilita la infraestructura de auditoría en la aplicación. Sin esto, tus @CreatedDate y @LastModifiedDate serán solo bonitas etiquetas en el campo.
@CreatedDate y @LastModifiedDate son marcadores para los campos. Dices explícitamente: «esto no es un campo de negocio, es metadato».
AuditingEntityListener es el entity listener que asigna los valores en el momento adecuado del ciclo de vida.
3. Habilitación de @EnableJpaAuditing
El error de principiante más común con la auditoría es este: el estudiante anota cuidadosamente los campos con @CreatedDate y @LastModifiedDate, ejecuta la aplicación, guarda la entidad — y los campos createdAt y updatedAt permanecen null. ¿Por qué? Porque la auditoría no son «solo anotaciones en la entidad». Necesita habilitarse a nivel de configuración de Spring.
En nuestro proyecto shop-data-jpa mantenemos las configuraciones de infraestructura en la zona common. Por lo tanto, es lógico crear una clase de configuración, por ejemplo, en el paquete com.example.shopdatajpa.common.config.
La configuración mínima parece sospechosamente corta (y eso es bueno — menos posibilidades de romper algo):
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing // Habilitamos la infraestructura de auditoría (sin esto las anotaciones en los campos no funcionarán)
public class JpaAuditingConfig {
// La clase puede estar vacía: lo importante es el hecho de la habilitación a través de la anotación
}
Varias aclaraciones importantes que normalmente «no son obvias hasta que te topas con ellas»:
Spring Boot debe ver esta clase durante el escaneo de componentes. La forma más segura es mantenerla dentro del paquete base de tu aplicación (desde donde arranca @SpringBootApplication). Si pones la configuración «en cualquier otro sitio», es posible que no se recoja y te quedarás mirando los campos null como un enigma del universo.
@EnableJpaAuditing habilita la auditoría como posibilidad, pero no significa que todo funcione automáticamente en cualquier entidad. La entidad debe participar en la auditoría a través de un entity listener — sin él, los campos seguirán siendo campos normales.
Y otra buena noticia: normalmente no se necesitan dependencias adicionales para la auditoría básica. Si ya tienes spring-boot-starter-data-jpa, las clases y anotaciones necesarias ya están en el stack.
4. Anotaciones @CreatedDate y @LastModifiedDate
Cuando la infraestructura está habilitada, hay que decirle: «estos campos son de auditoría, rellénalos automáticamente». Se hace en dos capas: añadimos anotaciones a los campos y conectamos AuditingEntityListener para que los eventos del ciclo de vida de JPA lleguen a la auditoría de Spring Data.
Es útil ver los campos de auditoría directamente en la propia entidad: así es más fácil notar qué anotaciones se encargan del relleno y cuáles del mapeo de columnas. Tiene sentido extraer la parte común a una clase base solo después de que la mecánica en sí ya esté clara.
A continuación, un «fragmento» compacto de la entidad Product que muestra precisamente la parte de auditoría. Observa: no hay llamadas manuales a now(), solo una declaración de intención.
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) // Conectamos el listener: reacciona a PrePersist/PreUpdate
class Product {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; // Se rellena automáticamente en el INSERT
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt; // Se rellena automáticamente en el UPDATE (en flush/commit)
}
Aquí hay varios detalles sutiles que ayudan mucho a los principiantes a mantener todo en la cabeza correctamente.
Las anotaciones @CreatedDate y @LastModifiedDate están en el paquete org.springframework.data.annotation. Son de Spring Data, no de JPA. No «crean una columna en la BD», no «activan migraciones», solo marcan el campo como participante de la auditoría.
@EntityListeners(AuditingEntityListener.class) es el puente entre el ciclo de vida de JPA y la auditoría de Spring Data. El propio AuditingEntityListener se encuentra en org.springframework.data.jpa.domain.support. Se puede decir así: sin el listener, has dado roles a los campos, pero olvidaste conectar a los actores al escenario, y la función no comenzó.
createdAt a menudo se marca con updatable = false para que no cambie en un UPDATE. No es una «regla obligatoria del universo», pero es una guardarraíl práctico. De lo contrario, podrías hacer accidentalmente product.setCreatedAt(...) (incluso sin querer), y el ORM obedientemente lo escribiría en la base de datos. Los campos de auditoría son buenos precisamente porque deben ser lo más predecibles posible.
nullable = false en el ejemplo es el objetivo al que normalmente llegamos. Pero el matiz real del proyecto es este: si las tablas ya existen y tienen filas, agregar una columna NOT NULL requiere una migración cuidadosa y un backfill. Definitivamente tendremos esto en cuenta a nivel de proyecto hoy, pero en esta lección nos centramos en la mecánica de la auditoría de Spring Data y no hacemos que los escenarios de migración sean el protagonista principal.
Por cierto, a menudo en el primer guardado, createdAt y updatedAt resultan iguales. Esto es esperable: la entidad «se creó» y «se modificó por última vez» en el mismo momento.
5. updatedAt: transacciones y flush
Hay un detalle que casi con toda seguridad provoca una ligera disonancia cognitiva en el principiante. Cambias un campo de la entidad en el servicio, y updatedAt... parece que «no cambia en este mismo momento». Surge el deseo de volver a insertar el manual now() y vivir tranquilo. Pero aquí es importante recordar cómo ya discutimos el contexto de persistencia y flush: el SQL no vuela a la base de datos «en cada setter», sino cuando el ORM sincroniza los cambios con la BD (normalmente en flush/commit).
La auditoría funciona con las mismas reglas. @LastModifiedDate no se asigna en el momento de la llamada a setName(...), sino en el momento en que Hibernate realmente se prepara para ejecutar la actualización.
Para que no haya sensación de magia, podemos imaginar esto como una pequeña cadena de eventos:
flowchart TD
A["En @Transactional cambias la entidad"] --> B["La entidad se vuelve dirty"]
B --> C["Al final de la transacción: flush/commit"]
C --> D["AuditingEntityListener: PreUpdate"]
D --> E["updatedAt = now"]
E --> F["Hibernate envía UPDATE a la BD"]
Por eso updatedAt es precisamente una marca de persistencia. Dice: «los datos realmente se confirmaron como una actualización».
Veamos un método de servicio simple al estilo de nuestro proyecto. Intencionadamente no tocamos updatedAt manualmente — ese es el objetivo de la lección.
import org.springframework.transaction.annotation.Transactional;
@Transactional // Límite dentro del cual Hibernate acumula cambios y luego hace flush/commit
public void renameProduct(Long id, String newName) {
Product product = productRepository.findById(id).orElseThrow();
product.setName(newName); // updatedAt se asignará automáticamente en flush/commit (antes del UPDATE)
}
Si habilitas el registro de SQL (nosotros ya lo usamos en días anteriores para diagnóstico), verás que al finalizar la transacción se envía un UPDATE, y junto con el cambio de name, en la consulta también aparecerá updated_at. Esto disciplina mucho la mente: la auditoría no es una «lógica separada», sino parte del mismo ciclo de vida que un UPDATE normal.
En cambio, el escenario de creación de una entidad suele darte un efecto más «inmediato»: los campos se asignan en el persist, por lo que después de save ya puedes ver los valores rellenos.
import org.springframework.transaction.annotation.Transactional;
@Transactional // Persist/INSERT ocurrirá dentro de la transacción y la auditoría rellenará createdAt/updatedAt
public Product createProduct(Product product) {
Product saved = productRepository.save(product);
System.out.println(saved.getCreatedAt()); // 2026-03-21T12:34:56.789 (ejemplo)
return saved;
}
Aquí es importante no confundir: save devuelve la entidad ya en estado managed, y los campos de auditoría se habrán asignado porque ocurrió el evento persist. En los escenarios de update, esto suele «verse» más cerca del final de la transacción, cuando el ORM realmente se sincroniza con la base de datos.
Otro matiz que es útil mencionar en voz alta: updatedAt no es un temporizador. Si la entidad permanece en la base de datos una semana sin cambios, updatedAt no debe «actualizarse solo». Solo se actualiza cuando hay un cambio real de datos.
6. Tipo de tiempo para los campos de auditoría
En este punto no hay que montar un debate filosófico sobre el «tipo de tiempo ideal». Para la mini-tienda, los campos de auditoría siguen siendo LocalDateTime: el tipo se lee bien en el código y se mapea de forma predecible a timestamp.
Lo principal aquí es un acuerdo de proyecto único. Si en una entidad se almacena LocalDateTime, en otra Instant, y en una tercera OffsetDateTime sin una razón clara, la auditoría no se volverá «más inteligente» — simplemente aparecerá otra zona de confusión. Y sí, la auditoría no arregla los relojes del entorno: solo rellena secuencialmente los campos con la hora disponible para la aplicación.
7. Errores típicos al conectar JPA auditing
Al final, reunimos los rastrillos típicos. La buena noticia: casi todos se diagnostican muy rápidamente si entiendes dos principios: la auditoría se habilita con configuración y funciona a través del ciclo de vida de la entidad.
Error nº 1: los campos están marcados, pero se olvidó @EnableJpaAuditing.
El síntoma es muy simple: createdAt y updatedAt permanecen null después de save. Se cura sin magia: añades una clase de configuración con @EnableJpaAuditing y verificas que esté en el escaneo de componentes.
Error nº 2: habilitaste la auditoría, pero no conectaste AuditingEntityListener.
Este es el segundo caso más popular. Hiciste todo «casi correctamente», pero olvidaste @EntityListeners(AuditingEntityListener.class). Como resultado, la infraestructura está habilitada, pero la entidad no participa en la mecánica de auditoría y los campos no se rellenan. Es especialmente engañoso si en una entidad conectaste el listener y en otra no: entonces parece que la auditoría «funciona a veces sí, a veces no».
Error nº 3: esperar que updatedAt cambie inmediatamente después de setXxx(...).
Si imprimes updatedAt justo en medio de una transacción y esperas que ya sea nuevo, puedes ver el valor antiguo. Esto no es un bug de la auditoría, es una consecuencia de que la auditoría vive en el momento del flush/commit. Piensa en ello como un «sello en un documento»: el sello no se pone cuando empiezas a rellenar el formulario, sino cuando realmente lo entregas.
Error nº 4: auditoría automática y setUpdatedAt(now()) manual en el servicio simultáneamente.
También se puede hacer, pero normalmente genera confusión. En primer lugar, pierdes el sentido de la automatización. En segundo lugar, obtienes dos fuentes de verdad. Y dos fuentes de verdad en programación es como dos líderes en un equipo: ambos son inteligentes, pero luego empieza la lucha por tener la razón. Elige un estilo. En nuestro proyecto, el estilo del día es la auditoría automática.
Error nº 5: no hay columnas en la BD (o se llaman de otra forma), y ya has añadido los campos en la entidad.
La auditoría no crea columnas. Si en la entidad aparece un campo createdAt y en la tabla no hay created_at, al iniciar la aplicación probablemente te encontrarás con un error de mapeo/discrepancia de DDL (dependiendo de la configuración). El enfoque correcto en nuestra trayectoria es añadir columnas a través de migraciones de Flyway y mantener el esquema como fuente de verdad. Si hasta ahora has vivido en modo dev con generación automática de esquema, esto es especialmente fácil de pasar por alto.
GO TO FULL VERSION