
Something is wrong with a customer's account.
You open the database and run:
SELECT *
FROM customer_account
WHERE id = 12345;
The row looks perfectly valid.
id = 12345
status = ACTIVE
plan = PREMIUM
limit = 10000
The database has answered your question:
What does the account look like right now?
Unfortunately, that wasn't really the question you needed answered.
The support ticket says:
"My account was disabled yesterday. Why?"
Now you need completely different information.
- Was the account actually disabled?
- When did it happen?
- What was the previous status?
- Who changed it?
- Was it a user action?
- Was it an automated process?
- Did another update happen afterward?
- What else changed in the same operation?
The current row can't tell you.
Because:
The database tells you what is. An audit trail tells you what happened.
That distinction becomes increasingly important once an application reaches production.
Current State Is Not History
Most relational databases are designed around current state.
Consider:
UPDATE customer_account
SET status = 'ACTIVE'
WHERE id = 12345;
Before the update:
status = SUSPENDED
After the update:
status = ACTIVE
Query the table afterward and you see:
ACTIVE
That's correct.
But the previous state has disappeared.
Unless something else recorded it, you no longer know that the account was ever SUSPENDED.
The database has faithfully persisted the new truth.
It hasn't preserved the story that produced it.
updated_at Is Not an Audit Trail
A common first step is adding timestamps:
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
These fields are useful.
They tell us:
Something changed at 10:42.
They don't tell us:
What changed at 10:42?
Suppose we have:
status = ACTIVE
creditLimit = 10000
country = PH
updatedAt = 2026-10-01T10:42:15Z
What happened at 10:42:15Z?
Did the status change?
Did the credit limit change?
Did someone correct the country?
Did all three change?
What were their previous values?
We can't know from updatedAt.
Even adding:
private String updatedBy;
only gives us:
someone changed something at this time
That's valuable metadata.
But it still isn't history.
Logs Aren't the Same Thing Either
The next place we often look is application logs.
Perhaps somewhere we have:
Updating customer 12345
Or, if we're lucky:
Changing customer 12345 status from SUSPENDED to ACTIVE
Logs are extremely useful for observability.
But using them as the authoritative history of entity changes creates problems.
Logs may be:
- rotated
- sampled
- retained for limited periods
- distributed across services
- formatted differently between code paths
- missing for bulk/database operations
- difficult to correlate with entity revisions
More importantly, logs describe application execution.
An audit trail describes business data evolution.
Those concerns overlap, but they aren't identical.
What We Actually Want
For an audited entity, we want to reconstruct something like:
Revision 1
2026-09-01 08:30 UTC
status = PENDING
plan = BASIC
↓
Revision 2
2026-09-03 14:12 UTC
status = ACTIVE
plan = BASIC
↓
Revision 3
2026-09-20 09:41 UTC
status = ACTIVE
plan = PREMIUM
↓
Revision 4
2026-10-01 10:42 UTC
status = SUSPENDED
plan = PREMIUM
↓
Revision 5
2026-10-01 11:07 UTC
status = ACTIVE
plan = PREMIUM
Now the production investigation becomes much easier.
The database still gives us:
ACTIVE
But the audit trail explains how we got there.
Hibernate Envers Gives Us Revision History
If you're already using Hibernate, one option for entity auditing is Hibernate Envers.
At its simplest, an entity can be audited with:
@Entity
@Audited
public class CustomerAccount {
@Id
private Long id;
private String status;
private String plan;
private BigDecimal creditLimit;
}
When the entity changes, Envers stores revisions in audit tables.
Conceptually, instead of only having:
customer_account
you also get historical data representing:
customer_account_AUD
Each revision captures the entity state associated with a particular change.
Now we have something much more powerful than:
updatedAt = ...
We have actual historical versions.
Revision History Changes How You Debug Production
Consider the original support ticket:
"My account was disabled yesterday. Why?"
Without history, you might inspect the current database:
SELECT status
FROM customer_account
WHERE id = 12345;
Result:
ACTIVE
Now you're searching logs and trying to reconstruct what happened.
With revision history, you can inspect the entity's evolution:
Revision 41
status = ACTIVE
Revision 42
status = SUSPENDED
Revision 43
status = ACTIVE
Now you know the customer was correct.
The account really was suspended.
And you know exactly which revision introduced the change.
That gives you a much better starting point for investigating why.
But Production Auditing Needs More Than @Audited
This is where things become more interesting.
Adding @Audited is easy.
Building a useful production audit system around it is harder.
Soon you need answers to questions such as:
- Who performed the change?
- Which application or service produced it?
- When exactly did it happen?
- Which fields changed?
- What were the old and new values?
- Can the history be queried efficiently?
- Can it be exposed through an API?
- How should timestamps behave across time zones?
- How should audit schemas evolve?
- How do we validate the audit schema during deployments?
These are the kinds of concerns that tend to appear after the first implementation works.
Time Is More Subtle Than It Looks
Audit timestamps are particularly important.
If an audit trail is supposed to explain exactly when something happened, time needs a clear semantic meaning.
For distributed systems, that usually means UTC.
Application servers may run in different time zones.
Developers may run applications locally in different regions.
Databases may have different timezone configurations.
Containers may inherit different environment settings.
An audit record shouldn't change meaning because one JVM happened to run in Manila and another in Frankfurt.
A useful pattern is to make the clock explicit:
@Bean
public Clock nervAuditClock() {
return Clock.systemUTC();
}
Then generated audit timestamps can use:
Instant.now(clock);
This also makes time deterministic during testing:
Clock fixedClock =
Clock.fixed(
Instant.parse("2026-10-01T10:42:15Z"),
ZoneOffset.UTC);
Now tests don't depend on:
whatever time the machine happens to think it is
That sounds like a small implementation detail.
For an audit system, it isn't.
Time is part of the evidence.
Audit Data Is Part of Your Database Schema
There's another mistake that's easy to make.
We often think of audit tables as an implementation detail created by the ORM.
But once audit history matters operationally, those tables are part of your production data model.
That means schema changes matter.
Imagine adding:
private String riskCategory;
to an audited entity.
Your primary table changes.
The corresponding audit representation may need to change too.
If your migration updates one but not the other, application startup might succeed while audit behavior breaks later.
Production audit infrastructure therefore needs the same discipline as normal persistence infrastructure:
schema migration
+
audit schema migration
+
schema validation
+
integration testing
Audit history shouldn't be treated as an accidental side effect of Hibernate.
It's production data.
Audit History Should Be Queryable
Recording history is only useful if developers and applications can actually retrieve it.
A useful audit API might expose something conceptually like:
GET /customers/12345/audit
and return:
[
{
"revision": 41,
"timestamp": "2026-10-01T09:00:00Z",
"status": "ACTIVE"
},
{
"revision": 42,
"timestamp": "2026-10-01T10:42:15Z",
"status": "SUSPENDED"
},
{
"revision": 43,
"timestamp": "2026-10-01T11:07:33Z",
"status": "ACTIVE"
}
]
But we can go further.
For many support and operational use cases, developers don't really want complete snapshots.
They want the difference.
Something closer to:
Revision 42
status:
ACTIVE → SUSPENDED
changedBy:
account-service
changedAt:
2026-10-01T10:42:15Z
That turns raw revision storage into something humans can actually use.
Vertical History Can Be More Useful Than Full Revisions
Sometimes the question isn't:
What did this entity look like at revision 42?
The question is:
How has this particular field changed over time?
For example:
CustomerAccount.status
PENDING
↓
ACTIVE
↓
SUSPENDED
↓
ACTIVE
Or:
creditLimit
5,000
↓
7,500
↓
10,000
This kind of vertical audit history is particularly useful for:
- support investigations
- administrative interfaces
- compliance reviews
- debugging state transitions
- explaining automated decisions
The database row tells you the current value.
Vertical history tells you the life of that value.
This Is Why I Built NERV Audit
These production concerns are what led me to build NERV Audit.
NERV Audit is an open-source auditing foundation built around Hibernate Envers for Spring applications.
The goal isn't to replace Envers.
Envers already solves the difficult problem of storing entity revisions.
The goal is to build reusable infrastructure around the things production systems repeatedly need on top of it:
Application
|
v
NERV Audit
|
+-- Audit Querying
+-- Revision Metadata
+-- Change Detection
+-- Vertical History
+-- UTC Time Handling
+-- Schema Support
|
v
Hibernate Envers
|
v
Database
The same principle I use across the NERV libraries applies here:
Build on the framework. Don't hide it.
If you already understand Hibernate and Envers, that knowledge should remain useful.
NERV Audit provides infrastructure around them rather than inventing an entirely different auditing model.
Auditing Is About Explainability
It's easy to think of auditing as a compliance feature.
Sometimes it is.
But one of the most practical reasons for maintaining audit history is much simpler:
Production systems need to explain themselves.
A user says:
"My account changed."
Support needs to know whether they're correct.
An engineer sees an unexpected state.
They need to know how it got there.
A business rule produces an unexpected result.
Someone needs to reconstruct the sequence of changes that led to it.
Without history, you're often left combining:
current database state
+
application logs
+
distributed traces
+
educated guesses
With a proper audit trail, the entity itself has a history.
And that can dramatically change how production incidents are investigated.
The Database Is Still Doing Its Job
None of this means the database is missing something.
The database is doing exactly what we asked it to do.
When we execute:
UPDATE customer_account
SET status = 'ACTIVE'
WHERE id = 12345;
we are explicitly asking it to make ACTIVE the current state.
The old value disappearing is expected.
If historical state matters to the application, preserving that history is an application architecture decision.
That's why I like the distinction:
The database tells you what is.
An audit trail tells you what happened.
For many production systems, you eventually need both.
What's Next?
This article introduces the problem behind NERV Audit.
The broader series will go deeper into the engineering challenges involved in building production audit infrastructure around Hibernate Envers, including:
- revision metadata
- identifying changed properties
- vertical audit history
- UTC timestamp semantics
- deterministic audit testing
- PostgreSQL integration
- audit schema migrations
- schema validation
- exposing audit history safely through APIs
- designing audit infrastructure that remains reusable across services
The interesting part isn't adding @Audited.
It's everything that happens after that.
Try NERV Audit
NERV Audit is open source and part of NERV — Next-Generation Engineering for Runtime Velocity.
GitHub:
https://github.com/czetsuyatech/nerv-audit
The canonical version of this article and my other engineering articles are available at:
If you've had to investigate a production issue where the database showed the correct current state but couldn't explain how it got there, you've already encountered the problem an audit trail is designed to solve.
This article is part of the NERV Audit series. NERV is an open-source collection of Java and Spring libraries focused on reusable infrastructure for production applications.
Top comments (0)