Auditoría con Hibernate Envers

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-hibernate-envers

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
}
  • @Audited sobre la clase activa la auditoría de todos los campos.
  • @NotAudited excluye campos específicos (datos volátiles, blobs grandes).
  • Para asociaciones, targetAuditMode = NOT_AUDITED indica 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 _AUD crecen 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 _AUD y revinfo).

Mitigaciones:

  • Particionar tablas _AUD por 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. Si last_login cambia 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

  • @Audited solo en entidades que importan: empleados, contratos, facturas. No en logs internos o catálogos volátiles.
  • RevisionEntity custom 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 _AUD y mejora performance.
  • Particionar _AUD cuando crece: PostgreSQL declarativo con PARTITION 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 _AUD enormes con datos poco útiles. La regla es auditar lo que un auditor querrá ver, no todo por si acaso.

Alan Sastre - Autor del tutorial

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.