JaVers From First Principles: How Automatic Audit Logging Actually Works
Table of Contents
Somebody asks who changed this invoice’s status last Tuesday, and from what. Your application has to be able to answer, and nobody wants to hand-write that logging on every setter.
That’s what JaVers does, and this is a walkthrough of how it works for somebody meeting it for the first time.
JaVers 7.11.4, Spring Boot 3.5, MongoDB. The scale figures come from a production cluster holding around 102 million snapshots.
1. What is JaVers?
A Java library that records the history of your domain objects and lets you query it.
Worth separating from things it isn’t. It’s not a logging framework: you don’t get lines of text, you get structured versions you can query. It’s not event sourcing: your objects stay the source of truth and the history sits beside them, rather than the events being the truth and the objects being derived.
It does two things, and most introductions only cover the first.
It records. Hand it an object and it stores the state of that object, plus everything reachable from it that changed:
javers.commit("alice@example.com", invoice);
One line. That’s the entire write API. You give it an author and an object, it works out what changed since last time and stores it.
It answers questions. This is the half that makes the recording worth doing:
// everything that ever happened to this invoice
Changes changes = javers.findChanges(
QueryBuilder.byInstanceId(invoiceId, Invoice.class).build());
// every change to the status field, on any invoice
Changes statusChanges = javers.findChanges(
QueryBuilder.byClass(Invoice.class).withChangedProperty("status").build());
// the full stored state of this invoice at each point in its life
List<CdoSnapshot> history = javers.findSnapshots(
QueryBuilder.byInstanceId(invoiceId, Invoice.class).build());
Changes is a list of typed change objects, each one naming the property, the old value and the new value. That’s what a “who changed what” screen is built from, and it’s why the history is worth storing as structured data rather than as log lines.
How is this different from Hibernate Envers?
If you’re on a Java stack, Envers is the alternative you’ll have heard of, and the distinction is clean:
| Hibernate Envers | JaVers | |
|---|---|---|
| Works at | the SQL row level | the Java object level |
| Requires | JPA and Hibernate | nothing in particular |
| Stores history in | an _AUD table per entity | one collection or table of snapshots |
| Tracks | what your schema knows about | what your object graph reaches |
| Storage backends | your relational database | SQL or MongoDB |
Envers is the tighter fit if you’re all-in on Hibernate and your audit needs mirror your tables. JaVers is the better fit when the thing you want to audit is a Java object graph that doesn’t map one-to-one onto rows, or when your history lives somewhere other than your main database.
2. Why not just write the logging yourself?
Audit logging is a cross-cutting concern: something every part of the application needs, that belongs to none of them.
You could do it by hand:
public void updateStatus(Invoice invoice, String newStatus) {
String before = invoice.getStatus();
invoice.setStatus(newStatus);
invoiceRepository.save(invoice);
auditLog.record("Invoice", invoice.getId(), "status", before, newStatus, currentUser());
}
That works, and it fails within a month, because:
- Somebody forgets. One code path skips the log line and that change is invisible forever.
- It’s noisy. Every method doubles in length and the business logic gets buried.
- It drifts. Add a field, forget to audit it, and nobody notices until someone asks about that field.
- It’s shallow. You logged one field on one object. The bill line that changed underneath it went unrecorded.
JaVers inverts it. Don’t record changes at the point you make them, observe them at the point they’re saved, and let the library work out what actually differs.
Which brings us to the part that decides everything else about how it behaves.
3. Where does it hook in?
You’ve seen javers.commit(...). In most Spring applications you never write that line, because the Spring integration calls it for you. There are two ways to use the library and it’s worth knowing which one you’re in:
- Explicit. You call
javers.commit(author, object)yourself, wherever you decide history matters. Full control, and you own every call site. - Automatic. You annotate a repository with
@JaversSpringDataAuditableand the library commits on every save that goes through it. Almost no code, and one large consequence.
The rest of this article is about the automatic mode, because that’s what most teams pick and because its consequences aren’t obvious.
It hooks in at the persistence layer, through Spring AOP. Not the controller, not the service.
If you’re new to AOP: it lets you wrap extra behaviour around method calls without changing the methods. Spring creates a proxy that sits in front of your repository, and calls pass through it on their way in. JaVers ships this as a Spring integration, which is where the annotations come from.
service calls repository.save(invoice)
|
v
Spring Data repository, annotated @JaversSpringDataAuditable
|
v
the JaVers aspect intercepts the call
|
v
the entity is serialised and handed off for recording
That single placement decision explains almost everything else about the library, including its most surprising behaviour, which is that it sometimes records nothing at all.
One detail worth internalising early: what gets handed off is the entity itself, not a diff and not a snapshot. Those don’t exist yet. Computing what changed happens later, away from your request.
String entityJson = converter.toJson(currentVersion);
publisher.publish(command(COMMIT, author, properties, entityJson, entityClass));
That’s why interception is cheap. Your save() pays for a serialisation and a message publish, not for a diff against history.
4. When does the write actually happen?
One thing to separate before this section makes sense: JaVers itself can commit synchronously. Call javers.commit(...) and it computes the diff and writes there and then. What follows is a common way to wire it rather than something the library forces on you, and it’s worth knowing which is which.
In the system these numbers come from, the aspect publishes to a queue and the recording happens elsewhere. So your save() returns and at that point nothing has been recorded anywhere.
[1] a consumer reads the queue, in batches
|
v
computes the diff against history, WRITES the raw snapshots
|
v
publishes the new commit id to a second queue
|
v
[2] a second consumer reads that
|
v
resolves labels and before/after values, drops meaningless changes
|
v
WRITES the readable audit entry
|
v
[3] the API serves that to the frontend
Two practical consequences, and juniors hit both.
Audit becomes eventually consistent. Save something, look at its history immediately, and it may not be there yet. That’s not a bug, it’s the two queue hops. Any test asserting on audit right after a save has to wait for it. Commit synchronously instead and this cost goes away, at the price of doing diff computation inside your request.
Audit failure never breaks the business action. The interceptor deliberately swallows its own exceptions, with a comment saying it’s so the transaction can still commit. If recording fails, your invoice still saves. That’s almost certainly the right trade, and it’s worth knowing it’s the trade being made: audit is best-effort by design.
5. What actually gets written?
Two collections, and the difference between them matters more than it first appears.
[1] JaVers writes -> jv_snapshots raw, immutable, source of truth
[2] a worker reads <- jv_snapshots
and writes -> audit_commit_entry processed, human-readable
[3] the API serves <- audit_commit_entry what users actually see
jv_snapshotsis never shown to a user. It’s the raw record.audit_commit_entryis derived from it, and the derivation is idempotent, so it can be thrown away and rebuilt.
That asymmetry has a practical consequence people miss: you can be aggressive about deleting the derived collection, because you can regenerate it. You cannot be aggressive about the source.
6. The vocabulary, and the two words people get wrong
Five terms. Two of them mean something much narrower than they sound, and those two are where the confusion lives.
Commit
A commit is one intercepted repository.save(...) call. It gets a numeric id.
It is not a business action, and this trips up everybody. One user clicking “approve” usually calls several repositories, so it produces several commits. They’re stitched back together by a shared correlation id, not by commit id.
1 save -> 1 commit
1 business action -> 1..n commits (linked by a correlation id)
Snapshot
A snapshot is the complete state of one object at one moment. Not a diff. Every field is copied, plus a small changedProperties array naming what differs from the previous snapshot.
That word “complete” is the expensive part. Change one field on an invoice and you store the whole invoice again. On the cluster I measured, 102 million snapshots average 1.61 kB each, and that’s why.
One snapshot per object, never per field. If status and amount both change on the same invoice, that’s one snapshot listing two changed properties. The per-field breakdown appears later, in the derived collection:
snapshot = per object
atomicEntry = per field
The graph
When JaVers snapshots something, it doesn’t stop at the object you handed it. It follows Java references and snapshots everything reachable that changed.
The word “graph” here means the object graph in memory. It has nothing to do with your SQL schema or your foreign keys. If invoice.getBillLines() is populated in memory, those bill lines are part of the graph. If it’s lazy and never loaded, they aren’t.
JaVers treats two kinds of thing differently:
| has its own id? | gets its own snapshot? | |
|---|---|---|
| Entity | yes | yes |
| ValueObject | no, it belongs to an owner | recorded under its owner |
Snapshot type
Every snapshot is one of three kinds, and the split on a real cluster looks like this:
| Type | Share | Means |
|---|---|---|
INITIAL | 47% | first time this object was ever seen |
UPDATE | 46% | a field changed |
TERMINAL | 7% | the object was deleted |
The near even split between INITIAL and UPDATE surprises people. It says roughly half of everything recorded is an object being seen for the first time, which is what you’d expect from a system creating a lot of new records rather than editing old ones.
globalId
The address of a snapshot. It answers “which object is this?”: the type name, the object’s id, and a combined string key like "Invoice/abc-123".
7. One example that covers all of it
This is the example worth memorising, because every confusing thing about JaVers shows up in it.
invoice.setStatus("PAID"); // field 1 \ same object
invoice.setAmount(100); // field 2 /
invoice.getBillLines().get(0).setAmount(50); // a DIFFERENT object
invoiceRepository.save(invoice); // ONE save call
One save. What gets recorded?
commit 1001
snapshot Invoice changedProperties: ["status", "amount"] <- 2 fields, 1 snapshot
snapshot BillLine changedProperties: ["amount"] <- other object, other snapshot
One commit. Two snapshots. Three separate rules produced that, and mixing them up is what makes this confusing:
- Per object, never per field. Two fields changed on the invoice, so one snapshot for the invoice. Always true.
- JaVers followed the reference. You only called
saveon the invoice. The bill line got snapshotted because it hangs off the invoice in memory. That’s the graph. - A second
savewould be a second commit. If the service then callspoRepository.save(po), that’s commit 1002, linked to 1001 by the correlation id, not part of it.
Worth stating the two mechanisms side by side, because they produce different things:
several save() calls you wrote -> several COMMITS
one save(), JaVers follows children -> several SNAPSHOTS in one commit
8. The four ways JaVers records nothing
Here’s the consequence of hooking in at the repository, and it’s the single most useful thing on this page:
JaVers only sees what goes through an intercepted repository method.
Anything that reaches the database another way is invisible. Four of those, and none of them produce an error:
- Hibernate dirty checking with no explicit save. Load an entity in a transaction, change a field, let the transaction commit. Hibernate writes the update. No
save()was called, so no aspect fired, so no audit. @Modifyingqueries. Raw SQL or JPQL goes straight to the database and bypasses the proxy entirely.- Column transformers. JaVers sees the Java-side value, not whatever the database layer transformed it into.
- Derived delete methods.
deleteByStatus(...)isn’t one of the delete methods JaVers tracks by default. You have to opt in explicitly.
None of these throw. None of these log a warning. The audit trail just quietly has a hole in it, and you find out when somebody asks a question it can’t answer.
If you take one habit from this article: when you add a code path that writes to the database, ask whether it goes through a repository method. If it doesn’t, it isn’t audited.
There’s a related subtlety on timing. The aspect intercepts the repository method call, so JaVers fires when save() is called. Not when Hibernate flushes, and not at database commit. If you want the mental model for why those three moments are different, save, saveAndFlush and REQUIRES_NEW covers exactly that.
9. The fan-out, and why it decides everything downstream
Put the pieces together and you get a shape that explains most of the surprises in an audit database:
1 save -> 1 commit -> ~27 snapshots -> 1 readable audit entry
holding N field-level entries
Twenty-seven is the average measured on that production cluster. It’s not a constant, it’s whatever your object graph happens to be, and a rich aggregate produces a lot.
Read that line left to right and it’s a fan-out: one call becomes twenty-seven documents. Read it right to left and it’s a fan-in: those twenty-seven collapse back into one thing a human reads.
Three consequences follow, and they’re worth knowing before you design anything on top of this:
- Snapshot count grows with graph size, not with change size. Changing one field on a large aggregate is not a small write.
- Storage grows fast, because snapshots are full copies rather than diffs.
- There is no “commits” collection. Commits aren’t stored as their own documents anywhere. A commit exists only as the id repeated across the snapshots that share it.
That last one sounds like trivia. It isn’t, and it’s where part two of this pair begins: if you ever need a list of commit ids, you have no choice but to walk snapshots and deduplicate, and one commit means roughly twenty-seven identical values in a row.
10. The indexes it creates behind your back
Worth knowing before you go index hunting: JaVers manages its own schema, and it recreates its indexes on every application boot. On the snapshot collection that’s seven of them, covering the globalId fields, the changed-properties array, and a compound index over commit metadata.
Two things follow.
Dropping one is temporary. You drop it, the next deploy puts it back. If you’ve ever removed an index and watched it reappear, this is why.
The off switch is all or nothing. There’s a flag to stop JaVers managing the schema, and it defaults to on. Turning it off doesn’t let you keep six indexes and drop the seventh. It stops all of them being created, including the ones everything depends on. That’s rarely what you want.
11. What to take away
- It’s two halves, and the query half is the point.
commitwrites history,findChangesandfindSnapshotsread it back as structured data you can render. - In automatic mode, JaVers observes saves; it doesn’t observe changes. Everything else follows from where it hooks in.
- A commit is one save, not one user action. Correlation ids exist to put actions back together.
- A snapshot is one object, fully copied, not a diff and not a field.
- The graph is Java references in memory, not your database schema.
- Silence is the failure mode. Four common code paths produce no audit and no error.
That last point is the one worth carrying around. Most libraries fail loudly, and you learn their limits by hitting an exception. This one fails by writing nothing, so its limits have to be learned deliberately, in advance.
Part two takes that fan-out and follows it into a production database, where a single query walking those repeated commit ids was reading two billion documents a week. That’s over here.



