Replacing Audit Trail Workarounds with a Mapping-Level Model
How Hibernate 7.4 moves temporal reads and audit history into the ORM layer — what that changes for compliance queries, data retention planning, and the upgrade path from Envers.
If an auditor asks what account VL-1000 looked like at 17:00 on quarter close, a last_modified column does not help much. If compliance asks who changed KYC from PENDING to APPROVED, a normal CRUD stack runs out of memory very fast.
A lot of Java teams patch that with Envers, hand-written shadow tables, entity listeners, and a few extra columns that felt useful at the time. That can work for a while. Then somebody asks for one consistent story across the model, and the shortcuts start arguing with each other.
Hibernate 7.4 got my attention because it moves both problems into mappings. @Temporal gives an entity history over time. @Audited gives audit logs and changesets without going back to the old just add one more listener pattern. The Hibernate 7.4 introduction is good background. I wanted to see the feature inside a normal Quarkus service, with real endpoints and tests.
There is one caveat up front. The latest stable Quarkus release is 3.36.2, and it still ships Hibernate ORM 7.3.7.Final. The first Quarkus line I found with Hibernate ORM 7.4.0.Final is 3.37.0.CR1. So this article uses a preview line on purpose. If your team only wants stable platform bits today, stay with Envers for now. If you want to learn the new model today, keep going.
We build a small VaultLedger service end to end. One entity tracks balance history over time. Another keeps an audit trail for KYC changes. We put both behind HTTP, verify them with curl, and add one Quarkus test that checks the behavior from the outside.
Prerequisites
We use Quarkus 3.37.0.CR1, Hibernate ORM 7.4.0.Final, Java 25, and PostgreSQL through Dev Services. You should already be comfortable with JPA basics, Quarkus dev mode, and JSON HTTP requests from a terminal.
JDK 25 installed
Quarkus CLI 3.36+ in your
PATHPodman or Docker for Dev Services
Basic familiarity with JPA and Quarkus REST
About ☕️☕️☕️☕️ (it’s a rainy day and I am slower today)
Project setup
Create the application and follow along or grab the full source from my Github repository:
quarkus create app -P io.quarkus.platform:quarkus-bom:3.37.0.CR1 \
com.themainthread:quarkus-time-traveler \
--extension='rest-jackson,hibernate-orm,jdbc-postgresql' \
--java=25 \
--no-code
cd quarkus-time-travelerThe -P syntax comes from the Quarkus CLI guide. I pin the platform BOM explicitly.
We use three extensions:
quarkus-rest-jacksongives us JSON HTTP endpointsquarkus-hibernate-ormbrings in Hibernate ORM and the new@Temporaland@AuditedAPIs from Hibernate7.4quarkus-jdbc-postgresqlgives us the PostgreSQL driver and lets Dev Services boot a database automatically in dev and test
Create src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.unsupported-properties."hibernate.temporal.table_strategy"=HISTORY_TABLE
quarkus.hibernate-orm.unsupported-properties."hibernate.temporal.changeset_id_supplier"=com.themainthread.timetraveler.LedgerRevisionSupplierThose last two properties are the weird part for now. Quarkus does not expose first-class config for Hibernate’s new temporal table strategy or the changelog-backed changeset supplier yet, so we pass both through with unsupported-properties. A rather awkward config but at least we can do it even with a not fully integrated preview status. HISTORY_TABLE keeps current rows in the main table and old rows in a companion table. That is easier to inspect and easier to explain.
One more shim is needed on this preview line. Quarkus 3.37.0.CR1 brings Hibernate 7.4.0.Final, but its build-time bootstrap still misses the ChangesetCoordinator service. If we do nothing, the app compiles and then falls over during startup when Hibernate binds temporal metadata.
Add src/main/java/com/themainthread/timetraveler/ChangesetCoordinatorContributor.java:
package com.themainthread.timetraveler;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.service.internal.ChangesetCoordinatorInitiator;
import org.hibernate.service.spi.ServiceContributor;
public class ChangesetCoordinatorContributor implements ServiceContributor {
@Override
public void contribute(StandardServiceRegistryBuilder builder) {
builder.addInitiator(ChangesetCoordinatorInitiator.INSTANCE);
}
}Then register it in src/main/resources/META-INF/services/org.hibernate.service.spi.ServiceContributor:
com.themainthread.timetraveler.ChangesetCoordinatorContributorWe will add LedgerRevisionSupplier after the changelog entity because it depends on that class.
Model the domain
We start with two small enums:
package com.themainthread.timetraveler;
public enum AccountStatus {
ACTIVE,
FROZEN,
SUSPENDED
}package com.themainthread.timetraveler;
public enum KycStatus {
PENDING,
APPROVED,
REJECTED
}Temporal account state
Create src/main/java/com/themainthread/timetraveler/Account.java:
package com.themainthread.timetraveler;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.Temporal;
@Entity
@Table(name = "accounts")
@Temporal
@Temporal.HistoryTable(name = "account_history")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false, length = 32)
private String accountNumber;
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal balance;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private AccountStatus status;
protected Account() {
}
public Account(String accountNumber, BigDecimal balance, AccountStatus status) {
this.accountNumber = accountNumber;
this.balance = balance;
this.status = status;
}
public Long getId() {
return id;
}
public String getAccountNumber() {
return accountNumber;
}
public BigDecimal getBalance() {
return balance;
}
public AccountStatus getStatus() {
return status;
}
public void changeBalance(BigDecimal balance, AccountStatus status) {
this.balance = balance;
this.status = status;
}
}What matters here is what we did not write. There is no mirror entity for history rows. There is no listener copying state on update. @Temporal tells Hibernate to version the entity over time, and @Temporal.HistoryTable tells it where the old rows go.
Hibernate’s 7.4 documentation recommends a version attribute for temporal entities. On Quarkus 3.37.0.CR1, a numeric @Version still breaks when Hibernate writes the separate history table. I leave it out here because I want runnable code.
I also keep accountNumber non-null but not JPA-unique. On this preview line, a column-level unique constraint is copied to the history table too. The second revision of the same business key then fails for no useful reason.
Audited holder state
Create src/main/java/com/themainthread/timetraveler/AccountHolder.java:
package com.themainthread.timetraveler;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.Audited;
@Entity
@Table(name = "account_holders")
@Audited
@Audited.Table(name = "account_holder_aud")
public class AccountHolder {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false, unique = true, length = 32)
private String externalId;
@Column(nullable = false, length = 120)
private String fullName;
@Column(nullable = false, length = 160)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private KycStatus kycStatus;
protected AccountHolder() {
}
public AccountHolder(String externalId, String fullName, String email, KycStatus kycStatus) {
this.externalId = externalId;
this.fullName = fullName;
this.email = email;
this.kycStatus = kycStatus;
}
public Long getId() {
return id;
}
public String getExternalId() {
return externalId;
}
public String getFullName() {
return fullName;
}
public String getEmail() {
return email;
}
public KycStatus getKycStatus() {
return kycStatus;
}
public void update(String fullName, String email, KycStatus kycStatus) {
this.fullName = fullName;
this.email = email;
this.kycStatus = kycStatus;
}
}Now add the changelog entity in src/main/java/com/themainthread/timetraveler/LedgerRevision.java:
package com.themainthread.timetraveler;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import org.hibernate.annotations.Changelog;
import org.hibernate.audit.TrackingModifiedEntitiesChangelogMapping;
@Entity
@Changelog
@Table(name = "ledger_revision")
public class LedgerRevision extends TrackingModifiedEntitiesChangelogMapping {
}This is a boring class as I like it. It already gives us a revision id, a timestamp, and the set of entity types touched in each changeset. That is enough for a useful audit endpoint. If you need actor data such as changedBy or ipAddress, extend this class. Hibernate 7.4 gives you that hook through @Changelog and ChangesetListener.
Now add src/main/java/com/themainthread/timetraveler/LedgerRevisionSupplier.java:
package com.themainthread.timetraveler;
import org.hibernate.audit.spi.ChangelogSupplier;
public class LedgerRevisionSupplier extends ChangelogSupplier<Long> {
public LedgerRevisionSupplier() {
super(LedgerRevision.class, "id", "timestamp", "modifiedEntityNames", null);
}
}This keeps the temporal side and the audit side on the same changeset id type during bootstrap. Without it, this preview line builds temporal columns as timestamp-based and then later tries to write changelog-backed Long ids into them. That is a bad use of a morning and my precious coffee.
Read past state through a session
Temporal reads are session-scoped. That is the first thing to keep in your head. We do not call a special find() overload. On this preview line, we first resolve the requested instant to a changelog changeset id. After that, we open a session with atChangeset(changesetId) and do a normal entity lookup.
Create src/main/java/com/themainthread/timetraveler/AccountService.java:
package com.themainthread.timetraveler;
import java.math.BigDecimal;
import java.time.Instant;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.audit.AuditLog;
import org.hibernate.audit.AuditLogFactory;
@ApplicationScoped
public class AccountService {
private final Session session;
private final SessionFactory sessionFactory;
public AccountService(Session session, SessionFactory sessionFactory) {
this.session = session;
this.sessionFactory = sessionFactory;
}
@Transactional
public Account create(String accountNumber, BigDecimal openingBalance) {
Account account = new Account(accountNumber, openingBalance, AccountStatus.ACTIVE);
session.persist(account);
return account;
}
@Transactional
public Account getCurrent(Long id) {
Account account = session.find(Account.class, id);
if (account == null) {
throw new NotFoundException("No account with id " + id);
}
return account;
}
@Transactional
public Account getSnapshot(Long id, Instant asOf) {
try (AuditLog auditLog = AuditLogFactory.create(session)) {
Object changesetId = auditLog.getChangesetId(asOf);
try (Session historicalSession = sessionFactory.withOptions().atChangeset(changesetId).openSession()) {
Account account = historicalSession.find(Account.class, id);
if (account == null) {
throw new NotFoundException("No account with id " + id + " at " + asOf);
}
return account;
}
}
}
@Transactional
public Account changeBalance(Long id, BigDecimal balance, AccountStatus status) {
Account account = session.find(Account.class, id);
if (account == null) {
throw new NotFoundException("No account with id " + id);
}
account.changeBalance(balance, status);
return account;
}
}Now expose it in src/main/java/com/themainthread/timetraveler/AccountResource.java:
package com.themainthread.timetraveler;
import java.math.BigDecimal;
import java.time.Instant;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/accounts")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AccountResource {
private final AccountService accountService;
public AccountResource(AccountService accountService) {
this.accountService = accountService;
}
@POST
public Response create(CreateAccountRequest request) {
Account account = accountService.create(request.accountNumber(), request.openingBalance());
return Response.status(Response.Status.CREATED).entity(toView(account)).build();
}
@GET
@Path("/{id}")
public AccountView getCurrent(@PathParam("id") Long id) {
return toView(accountService.getCurrent(id));
}
@GET
@Path("/{id}/snapshot")
public AccountView getSnapshot(@PathParam("id") Long id, @QueryParam("asOf") String asOf) {
return toView(accountService.getSnapshot(id, Instant.parse(asOf)));
}
@PUT
@Path("/{id}/balance")
public AccountView changeBalance(@PathParam("id") Long id, BalanceUpdateRequest request) {
return toView(accountService.changeBalance(id, request.balance(), request.status()));
}
private static AccountView toView(Account account) {
return new AccountView(
account.getId(),
account.getAccountNumber(),
account.getBalance(),
account.getStatus());
}
}
record CreateAccountRequest(String accountNumber, BigDecimal openingBalance) {
}
record BalanceUpdateRequest(BigDecimal balance, AccountStatus status) {
}
record AccountView(Long id, String accountNumber, BigDecimal balance, AccountStatus status) {
}This is the behavior I wanted to verify in code. The current read is plain session.find(). The historical read is also a normal entity lookup, just inside a session pinned to a changeset. It is a little more ceremony than the pure timestamp path, but it keeps temporal state and audit history on the same revision model.
Expose audit history over HTTP
Audit history is a different query shape from temporal reads. For AccountHolder, we care about the sequence of changes, not only the row that was valid at one point in time.
Create src/main/java/com/themainthread/timetraveler/AccountHolderService.java:
package com.themainthread.timetraveler;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import org.hibernate.Session;
import org.hibernate.audit.AuditEntry;
import org.hibernate.audit.AuditLog;
import org.hibernate.audit.AuditLogFactory;
@ApplicationScoped
public class AccountHolderService {
private final Session session;
public AccountHolderService(Session session) {
this.session = session;
}
@Transactional
public AccountHolder create(String externalId, String fullName, String email, KycStatus kycStatus) {
AccountHolder holder = new AccountHolder(externalId, fullName, email, kycStatus);
session.persist(holder);
return holder;
}
@Transactional
public AccountHolder update(Long id, String fullName, String email, KycStatus kycStatus) {
AccountHolder holder = session.find(AccountHolder.class, id);
if (holder == null) {
throw new NotFoundException("No account holder with id " + id);
}
holder.update(fullName, email, kycStatus);
return holder;
}
public List<AuditEntry<AccountHolder>> getHistory(Long id) {
try (AuditLog auditLog = AuditLogFactory.create(session)) {
return auditLog.getHistory(AccountHolder.class, id);
}
}
}Now create src/main/java/com/themainthread/timetraveler/AccountHolderResource.java:
package com.themainthread.timetraveler;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.hibernate.audit.AuditEntry;
import org.hibernate.audit.ModificationType;
@Path("/holders")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AccountHolderResource {
private final AccountHolderService accountHolderService;
public AccountHolderResource(AccountHolderService accountHolderService) {
this.accountHolderService = accountHolderService;
}
@POST
public Response create(CreateAccountHolderRequest request) {
AccountHolder holder = accountHolderService.create(
request.externalId(),
request.fullName(),
request.email(),
request.kycStatus());
return Response.status(Response.Status.CREATED).entity(toView(holder)).build();
}
@PUT
@Path("/{id}")
public AccountHolderView update(@PathParam("id") Long id, UpdateAccountHolderRequest request) {
return toView(accountHolderService.update(id, request.fullName(), request.email(), request.kycStatus()));
}
@GET
@Path("/{id}/audit")
public List<AccountHolderAuditItem> audit(@PathParam("id") Long id) {
return accountHolderService.getHistory(id).stream()
.map(AccountHolderResource::toAuditItem)
.toList();
}
private static AccountHolderView toView(AccountHolder holder) {
return new AccountHolderView(
holder.getId(),
holder.getExternalId(),
holder.getFullName(),
holder.getEmail(),
holder.getKycStatus());
}
private static AccountHolderAuditItem toAuditItem(AuditEntry<AccountHolder> entry) {
LedgerRevision revision = (LedgerRevision) entry.changeset();
long changesetId = revision.getId();
Instant changedAt = revision.getRevisionInstant();
Set<String> modifiedEntities =
revision.getModifiedEntityNames() == null ? Set.of() : revision.getModifiedEntityNames();
return new AccountHolderAuditItem(
changesetId,
changedAt,
entry.modificationType(),
modifiedEntities,
toView(entry.entity()));
}
}
record CreateAccountHolderRequest(String externalId, String fullName, String email, KycStatus kycStatus) {
}
record UpdateAccountHolderRequest(String fullName, String email, KycStatus kycStatus) {
}
record AccountHolderView(Long id, String externalId, String fullName, String email, KycStatus kycStatus) {
}
record AccountHolderAuditItem(
long changesetId,
Instant changedAt,
ModificationType modificationType,
Set<String> modifiedEntities,
AccountHolderView holder) {
}AuditLogFactory lets us ask Hibernate for history instead of reverse-engineering the audit tables ourselves. We ask for holder history, read the changelog data once, and return something an API client can use.
LedgerRevision does the rest. The audit entry already has the entity snapshot and the modification type. The changelog adds the revision timestamp and the set of modified entity names for the same changeset. That is enough context for most operational audit screens.
Test it from the HTTP boundary
I want the test at the HTTP boundary. Temporal reads and audit history are user-visible behavior. The question is not whether some internal callback fired. The question is whether the API answers history queries correctly.
Create src/test/java/com/themainthread/timetraveler/VaultLedgerResourceTest.java:
package com.themainthread.timetraveler;
import java.time.Instant;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.is;
@QuarkusTest
class VaultLedgerResourceTest {
@Test
void canReadAnAccountSnapshotAtAnEarlierInstant() throws InterruptedException {
int accountId = given()
.contentType(ContentType.JSON)
.body("""
{
"accountNumber": "VL-1000",
"openingBalance": 1250.00
}
""")
.when()
.post("/accounts")
.then()
.statusCode(201)
.body("balance", is(1250.0f))
.extract()
.path("id");
Instant beforeChange = Instant.now();
Thread.sleep(100);
given()
.contentType(ContentType.JSON)
.body("""
{
"balance": 950.00,
"status": "SUSPENDED"
}
""")
.when()
.put("/accounts/{id}/balance", accountId)
.then()
.statusCode(200)
.body("balance", is(950.0f))
.body("status", is("SUSPENDED"));
given()
.queryParam("asOf", beforeChange.toString())
.when()
.get("/accounts/{id}/snapshot", accountId)
.then()
.statusCode(200)
.body("balance", is(1250.0f))
.body("status", is("ACTIVE"));
}
@Test
void exposesAuditHistoryForAccountHolderChanges() {
int holderId = given()
.contentType(ContentType.JSON)
.body("""
{
"externalId": "CUST-001",
"fullName": "Elena Fischer",
"email": "elena@vaultledger.dev",
"kycStatus": "PENDING"
}
""")
.when()
.post("/holders")
.then()
.statusCode(201)
.extract()
.path("id");
given()
.contentType(ContentType.JSON)
.body("""
{
"fullName": "Elena Fischer",
"email": "elena.fischer@vaultledger.dev",
"kycStatus": "APPROVED"
}
""")
.when()
.put("/holders/{id}", holderId)
.then()
.statusCode(200)
.body("kycStatus", is("APPROVED"));
given()
.when()
.get("/holders/{id}/audit", holderId)
.then()
.statusCode(200)
.body("size()", is(2))
.body("modificationType", hasItems("ADD", "MOD"));
}
}The Thread.sleep(100) is not ideal. But we need the first instant to be clearly before the update. Haven’t found a better way to do that.
Run it
Start the application in dev mode from the project directory:
./mvnw quarkus:devCreate one holder and one account:
holder_id=$(
curl -s -X POST http://localhost:8080/holders \
-H 'Content-Type: application/json' \
-d '{"externalId":"CUST-001","fullName":"Elena Fischer","email":"elena@vaultledger.dev","kycStatus":"PENDING"}' \
| jq -r '.id'
)
account_id=$(
curl -s -X POST http://localhost:8080/accounts \
-H 'Content-Type: application/json' \
-d '{"accountNumber":"VL-1000","openingBalance":1250.00}' \
| jq -r '.id'
)Now capture a point in time, change the balance, and read the older snapshot:
as_of=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
sleep 1
curl -s -X PUT "http://localhost:8080/accounts/${account_id}/balance" \
-H 'Content-Type: application/json' \
-d '{"balance":950.00,"status":"SUSPENDED"}' > /dev/null
curl -s "http://localhost:8080/accounts/${account_id}/snapshot?asOf=${as_of}" | jqExpected output:
{
"accountNumber": "VL-1000",
"balance": 1250.00,
"id": 1,
"status": "ACTIVE"
}If that comes back with 950.00, your captured instant was too late. Run the three commands again with a slightly longer sleep. Time is doing time things.
Update the holder and inspect the audit trail:
curl -s -X PUT "http://localhost:8080/holders/${holder_id}" \
-H 'Content-Type: application/json' \
-d '{"fullName":"Elena Fischer","email":"elena.fischer@vaultledger.dev","kycStatus":"APPROVED"}' > /dev/null
curl -s "http://localhost:8080/holders/${holder_id}/audit" | jqExpected output:
[
{
"changedAt": "2026-06-12T08:49:18.087Z",
"changesetId": 1,
"holder": {
"email": "elena@vaultledger.dev",
"externalId": "CUST-001",
"fullName": "Elena Fischer",
"id": 1,
"kycStatus": "PENDING"
},
"modificationType": "ADD",
"modifiedEntities": [
"com.themainthread.timetraveler.AccountHolder"
]
},
{
"changedAt": "2026-06-12T08:50:14.264Z",
"changesetId": 4,
"holder": {
"email": "elena.fischer@vaultledger.dev",
"externalId": "CUST-001",
"fullName": "Elena Fischer",
"id": 1,
"kycStatus": "APPROVED"
},
"modificationType": "MOD",
"modifiedEntities": [
"com.themainthread.timetraveler.AccountHolder"
]
}
]Your changesetId and changedAt values will differ. The two modification types should not.
That is the feature in one screen. We can ask for the old state of one entity and the audited change sequence of another without building our own side-channel persistence model first.
Production notes
History tables need indexes and retention
Temporal and audit features remove code. They do not remove database work. account_history and account_holder_aud keep growing until you give them retention, archival, or partitions.
Index the history you create. For temporal entities, index the entity identifier together with the effectivity columns. Their default names are effective and superseded, though the stored values may be timestamps or changeset ids depending on configuration. For audited entities, index the primary key plus revision column. Otherwise the compliance story ends in a table scan.
Temporal relationships and actor metadata need a real design
I kept the model flat for demo purposes. Once temporal entities point at other temporal entities, historical referential integrity stops being a free bonus from normal foreign keys. You need a policy: follow the association at the same instant, at the same changeset, or only from current rows. Hibernate will not invent that policy for you, which I consider a feature.
The same story applies to actor metadata. LedgerRevision is the right place to add changedBy, ipAddress, or a business comment. But use a real context model, not a static helper you promise to clean up next sprint. Hibernate supports the extension. Your application still owns where the truth comes from.
Conclusion
We built a Quarkus service that can answer two questions most CRUD apps delay for as long as possible: what did this row look like then, and what exactly changed later. The shift is where that logic lives. In Hibernate 7.4, temporal reads and audit history move into the mapping model instead of living in listeners, side tables, and team folklore. Can’t wait for it to be included into mainline Quarkus.


