
Por qué auditar cambios
En aplicaciones reguladas (banca, salud, sector público) o críticas para el negocio (gestión de empleados, contratos, facturación), saber quién cambió qué y cuándo es un requisito no negociable. Las preguntas típicas:
- ¿Cuál era el salario del empleado X el 1 de enero?
- ¿Quién modificó el email del cliente Y la semana pasada?
- ¿Qué filas se eliminaron en el último mes y por qué?
Resolver esto con código manual es propenso a errores y a omisiones (alguien escribe un UPDATE directo en la BD y se pierde el rastro). Hibernate Envers lo hace automáticamente: cada INSERT, UPDATE y DELETE sobre entidades anotadas se registra en tablas paralelas, sin tocar el código de negocio.
Configuración
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-envers</artifactId>
</dependency>
spring:
jpa:
properties:
org.hibernate.envers:
audit_table_suffix: "_AUD"
revision_field_name: REV
revision_type_field_name: REVTYPE
store_data_at_delete: true # guardar el estado completo al borrar
Sin más configuración, Envers detecta entidades @Audited y crea las tablas necesarias.
Anotar entidades
@Entity
@Audited
public class Empleado {
@Id
@GeneratedValue
private Long id;
private String nombre;
private String email;
private BigDecimal salario;
@ManyToOne
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
private Departamento departamento;
@NotAudited
private LocalDateTime ultimaSesion; // cambia muy frecuentemente, no auditar
}
@Auditedsobre la clase activa la auditoría de todos los campos.@NotAuditedexcluye campos específicos (datos volátiles, blobs grandes).- Para asociaciones,
targetAuditMode = NOT_AUDITEDindica que la entidad relacionada no necesita ser auditada (Envers solo guarda la FK, no replica todo el histórico de la otra entidad).
Tablas que crea Envers
Para Empleado con @Audited, Envers crea:
-- Tabla original (sin cambios)
empleado (id, nombre, email, salario, departamento_id, ultima_sesion)
-- Tabla de auditoría (paralela)
empleado_aud (id, REV, REVTYPE, nombre, email, salario, departamento_id)
-- Tabla de revisiones (única para toda la aplicación)
revinfo (REV, REVTSTMP)
empleado_aud guarda un registro por cada cambio:
| id | REV | REVTYPE | nombre | email | salario | |----|-----|---------|--------|-------|---------| | 42 | 100 | 0 (insert) | Ana | ana@ex.com | 50000 | | 42 | 105 | 1 (update) | Ana | ana.lopez@ex.com | 50000 | | 42 | 120 | 1 (update) | Ana | ana.lopez@ex.com | 55000 | | 42 | 145 | 2 (delete) | Ana | ana.lopez@ex.com | 55000 |
REVTYPE: 0=INSERT, 1=UPDATE, 2=DELETE.
revinfo registra el timestamp de cada revisión:
| REV | REVTSTMP | |-----|----------| | 100 | 2026-01-15 09:00:00 | | 105 | 2026-02-20 14:30:00 | | 120 | 2026-03-10 11:15:00 | | 145 | 2026-04-05 17:45:00 |
RevisionEntity personalizada
Por defecto Envers solo guarda (REV, REVTSTMP) en revinfo. Para uso real necesitas más metadata: usuario, IP, correlation ID. Se hace con una RevisionEntity custom:
@Entity
@Table(name = "revinfo")
@RevisionEntity(AuditRevisionListener.class)
@Getter @Setter
public class CustomRevisionEntity {
@Id
@GeneratedValue
@RevisionNumber
private long id;
@RevisionTimestamp
private long timestamp;
private String userId;
private String userEmail;
private String ipAddress;
private String correlationId;
}
Listener que rellena los campos custom:
public class AuditRevisionListener implements RevisionListener {
@Override
public void newRevision(Object revisionEntity) {
var rev = (CustomRevisionEntity) revisionEntity;
rev.setUserId(currentUserId());
rev.setUserEmail(currentUserEmail());
rev.setIpAddress(currentRequestIp());
rev.setCorrelationId(MDC.get("correlationId"));
}
private String currentUserId() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getPrincipal)
.map(Object::toString)
.orElse("system");
}
private String currentUserEmail() {
// similar
}
private String currentRequestIp() {
return Optional.ofNullable(RequestContextHolder.getRequestAttributes())
.map(ServletRequestAttributes.class::cast)
.map(ServletRequestAttributes::getRequest)
.map(HttpServletRequest::getRemoteAddr)
.orElse(null);
}
}
Ahora cada revisión queda firmada con quién, desde dónde y con qué correlation ID:
SELECT id, timestamp, userId, ipAddress, correlationId FROM revinfo;
Consultar el historial con AuditReader
@Service
@RequiredArgsConstructor
public class EmpleadoAuditService {
@PersistenceContext
private EntityManager em;
@Transactional(readOnly = true)
public List<EmpleadoVersionDto> historial(Long empleadoId) {
var auditReader = AuditReaderFactory.get(em);
var revisions = auditReader.getRevisions(Empleado.class, empleadoId);
return revisions.stream()
.map(rev -> {
var empleado = auditReader.find(Empleado.class, empleadoId, rev);
var revInfo = auditReader.findRevision(CustomRevisionEntity.class, rev);
return new EmpleadoVersionDto(rev, revInfo.getTimestamp(),
revInfo.getUserId(), empleado.getNombre(),
empleado.getEmail(), empleado.getSalario());
})
.toList();
}
@Transactional(readOnly = true)
public Empleado obtenerEnFecha(Long empleadoId, LocalDateTime fecha) {
var auditReader = AuditReaderFactory.get(em);
var timestamp = fecha.toInstant(ZoneOffset.UTC).toEpochMilli();
var revision = auditReader.getRevisionNumberForDate(new Date(timestamp));
return auditReader.find(Empleado.class, empleadoId, revision);
}
}
AuditReader.find(EmpleadoClass, id, revision) devuelve la entidad tal como estaba en esa revisión. Es exactamente lo que se necesita para responder "¿cuál era el salario el 1 de enero?".
Queries con criterios
AuditQueryCreator permite consultas más complejas:
@Transactional(readOnly = true)
public List<Empleado> empleadosCambiadosEntre(LocalDateTime desde, LocalDateTime hasta) {
var auditReader = AuditReaderFactory.get(em);
return auditReader.createQuery()
.forRevisionsOfEntity(Empleado.class, true, true)
.add(AuditEntity.revisionProperty("timestamp").between(
desde.toInstant(ZoneOffset.UTC).toEpochMilli(),
hasta.toInstant(ZoneOffset.UTC).toEpochMilli()))
.add(AuditEntity.property("salario").gt(BigDecimal.valueOf(50000)))
.getResultList();
}
Esto recupera todos los empleados con salario superior a 50.000 que sufrieron cambios en un rango de fechas.
Excluir campos sensibles
Para campos sensibles (passwords, tokens) no se quiere mantener histórico:
@Entity
@Audited
public class Usuario {
@Id @GeneratedValue
private Long id;
private String email;
@NotAudited
private String password; // nunca aparece en usuario_aud
@NotAudited
private String mfaSecret;
}
Cuando se actualiza solo el password, Envers crea una revisión con los demás campos pero el password no queda registrado.
Auditoría a nivel de aplicación vs Envers
Envers es ideal para datos persistidos. Pero hay auditoría que no encaja bien:
- Llamadas a APIs externas: no hay entidad JPA. Auditar con AOP (lección anterior).
- Operaciones de negocio sin cambio de datos: "el usuario consultó el listado". No hay INSERT/UPDATE; auditar con eventos de aplicación o AOP.
- Auditoría con efectos en runtime (alertas, escalados): los eventos son mejor herramienta que las tablas
_AUD.
| Caso | Herramienta |
|------|-------------|
| Cambios en datos persistidos | Hibernate Envers |
| Lecturas auditadas (compliance) | AOP + audit_log |
| Operaciones de servicio (transferencias, autorizaciones) | AOP + ApplicationEvents |
| Cambios de configuración | Eventos de Spring Cloud Config |
| Login/logout | Spring Security AuthenticationEventListener |
Performance
Envers tiene coste:
- Cada INSERT/UPDATE/DELETE en una entidad auditada genera un INSERT adicional en la tabla
_AUD. - Las tablas
_AUDcrecen con el tiempo: una entidad con 100 actualizaciones tiene 100 filas en_AUD. - Las consultas de histórico son más lentas que las normales (pueden requerir joins entre
_AUDyrevinfo).
Mitigaciones:
- Particionar tablas
_AUDpor fecha si crecen mucho. PostgreSQL lo hace nativamente. - Archivar revisiones antiguas: mover revisiones de más de 5 años a un cold storage si la regulación lo permite.
- Excluir campos volátiles con
@NotAudited. Silast_logincambia 1000 veces al día, no auditarlo. - Excluir entidades lookup: tablas de catálogos pequeños que no cambian no necesitan
@Audited.
Coexistencia con Flyway
Envers crea las tablas _AUD automáticamente cuando ddl-auto = create-drop o update. En producción con ddl-auto = validate, hay que crearlas explícitamente con Flyway:
-- V010__create_envers_tables.sql
CREATE TABLE revinfo (
rev INTEGER NOT NULL,
revtstmp BIGINT,
user_id VARCHAR(100),
ip_address VARCHAR(45),
correlation_id VARCHAR(100),
PRIMARY KEY (rev)
);
CREATE SEQUENCE revinfo_seq START 1 INCREMENT 50;
CREATE TABLE empleado_aud (
id BIGINT NOT NULL,
rev INTEGER NOT NULL,
revtype SMALLINT,
nombre VARCHAR(100),
email VARCHAR(255),
salario NUMERIC(10, 2),
departamento_id BIGINT,
PRIMARY KEY (id, rev),
FOREIGN KEY (rev) REFERENCES revinfo(rev)
);
CREATE INDEX idx_empleado_aud_rev ON empleado_aud(rev);
CREATE INDEX idx_empleado_aud_id ON empleado_aud(id);
Spring Data JPA + Hibernate Envers también permite escribir hibernate.envers.audit_strategy = org.hibernate.envers.strategy.internal.ValidityAuditStrategy para usar columnas revend adicionales que aceleran consultas históricas, a cambio de mayor coste de escritura.
Tests
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class EmpleadoEnversTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18-alpine");
@DynamicPropertySource
static void config(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
// ...
}
@Autowired EmpleadoRepository repository;
@PersistenceContext EntityManager em;
@Test
@Transactional
void cambiosQuedanRegistradosEnAud() {
var empleado = repository.save(Empleado.builder()
.nombre("Ana").email("ana@ex.com").salario(BigDecimal.valueOf(50000))
.build());
em.flush();
var revInicial = AuditReaderFactory.get(em)
.getRevisions(Empleado.class, empleado.getId());
empleado.setSalario(BigDecimal.valueOf(55000));
repository.save(empleado);
em.flush();
var revisiones = AuditReaderFactory.get(em)
.getRevisions(Empleado.class, empleado.getId());
assertThat(revisiones).hasSize(2);
var versionInicial = AuditReaderFactory.get(em)
.find(Empleado.class, empleado.getId(), revisiones.get(0));
assertThat(versionInicial.getSalario()).isEqualByComparingTo("50000");
}
}
Buenas prácticas
@Auditedsolo en entidades que importan: empleados, contratos, facturas. No en logs internos o catálogos volátiles.RevisionEntitycustom desde el primer día: el coste de añadirla luego es alto si ya hay millones de revisiones.- Excluir campos volátiles con
@NotAudited. Reduce el tamaño de las tablas_AUDy mejora performance. - Particionar
_AUDcuando crece: PostgreSQL declarativo conPARTITION BY RANGE (rev). - Tests con Testcontainers: H2 no comporta igual que PostgreSQL en triggers y secuencias usadas internamente.
- No abusar: Envers para datos. AOP para operaciones. Eventos para flujos. Cada herramienta tiene su lugar.
Hibernate Envers es la respuesta canónica a la pregunta "¿quién cambió qué y cuándo?" en datos JPA. Bien aplicado, da trazabilidad completa con coste mínimo de código. Mal aplicado (auditando todo, sin RevisionEntity custom, sin excluir volátiles), genera tablas
_AUDenormes con datos poco útiles. La regla es auditar lo que un auditor querrá ver, no todo por si acaso.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Activar Envers con @Audited y migrar el schema con tablas _AUD. Personalizar RevisionEntity para añadir metadata (usuario, IP, correlation ID). Consultar el historial con AuditReader y AuditQuery. Excluir campos sensibles de auditoría. Diferenciar Envers de auditoría custom con AOP.