DEV Community

Ed Legaspi
Ed Legaspi

Posted on Originally published at czetsuyatech.com

The Database Tells You What Is. An Audit Trail Tells You What Happened


Something is wrong with a customer's account.

You open the database and run:

SELECT *
FROM customer_account
WHERE id = 12345;
Enter fullscreen mode Exit fullscreen mode

The row looks perfectly valid.

id       = 12345
status   = ACTIVE
plan     = PREMIUM
limit    = 10000
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Before the update:

status = SUSPENDED
Enter fullscreen mode Exit fullscreen mode

After the update:

status = ACTIVE
Enter fullscreen mode Exit fullscreen mode

Query the table afterward and you see:

ACTIVE
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

only gives us:

someone changed something at this time
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or, if we're lucky:

Changing customer 12345 status from SUSPENDED to ACTIVE
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now the production investigation becomes much easier.

The database still gives us:

ACTIVE
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

When the entity changes, Envers stores revisions in audit tables.

Conceptually, instead of only having:

customer_account
Enter fullscreen mode Exit fullscreen mode

you also get historical data representing:

customer_account_AUD
Enter fullscreen mode Exit fullscreen mode

Each revision captures the entity state associated with a particular change.

Now we have something much more powerful than:

updatedAt = ...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Result:

ACTIVE
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Then generated audit timestamps can use:

Instant.now(clock);
Enter fullscreen mode Exit fullscreen mode

This also makes time deterministic during testing:

Clock fixedClock =
    Clock.fixed(
        Instant.parse("2026-10-01T10:42:15Z"),
        ZoneOffset.UTC);
Enter fullscreen mode Exit fullscreen mode

Now tests don't depend on:

whatever time the machine happens to think it is
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or:

creditLimit

5,000
    ↓
7,500
    ↓
10,000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

https://www.czetsuyatech.com/

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)