Bluetape4k JaVers Part 4: Audit Logs Are Not Free

Part 3 followed a business object through persistence, a JaVers commit, and domain event publication. Once that flow works, the next question arrives quickly:
Should every change become audit history?
“Keep as much as possible” sounds safe at first. As the audit scope grows, however, every change writes snapshots and commit metadata. More history also means more data to query. Adding indexes as soon as a query slows down moves cost back into writes and storage. Audit logs are not free.
This post reads two benchmark results committed to bluetape4k-javers. The numbers are not a
product leaderboard. We first separate the work included in each measured path, then connect the
evidence to audit-scope and commit-metadata-index decisions.
Auditing Every Change Alters the Cost Model
Section titled “Auditing Every Change Alters the Cost Model”Without audit history, the primary cost is persisting and reading current state. With audit history, one business change can include current-state persistence, JaVers commit creation, snapshot encoding, and snapshot storage. A delete may also need a terminal snapshot instead of ending at a row delete.
Reads split into two paths. Current-state queries read the business table. History queries read snapshots together with commit metadata. Questions such as “who changed this?” and “what changed in this period?” can add a join, predicates, and ordering across commit and snapshot tables.
The cost is therefore more than one extra row.
| Cost point | Question to answer |
|---|---|
| Write | How much do business persistence and the JaVers commit add to request latency? |
| Storage | How long will full object state, changed properties, and commit metadata be retained? |
| Query | How much can global-id, author, and date predicates narrow the SQL result? |
| Operations | Who owns retention, deletion requests, schema changes, and codec compatibility? |
What One Change Stores
Section titled “What One Change Stores”ExposedCdoSnapshotRepository splits JaVers data across two tables. The commit table stores the
author, commit time, properties, and sequence. The snapshot table stores the global id, version,
snapshot type, encoded full state, changed properties, and managed type.

The current implementation encodes the full snapshot state as text. Larger objects and frequent changes increase retained volume. On the read path, audit queries join the two tables by commit id and order results by sequence descending. The base schema already indexes fields such as commit id, snapshot type, and managed type. It does not force secondary indexes on author or commit time.
That is deliberate. Not every service queries history by author or time range. A default index that does not serve a real query still adds write and storage cost.
Separate the Measured Paths Before Comparing Them
Section titled “Separate the Measured Paths Before Comparing Them”A benchmark that names Envers, JaVers, and Exposed invites a library ranking, but the current
entries do different work.
| Measured path | Work included |
|---|---|
| Hibernate Envers | JPA entity persistence, revision recording, and revision query |
| JaVers in-memory | JaVers core commit and snapshot query without a database |
| JaVers + Exposed repository | JaVers snapshot and commit metadata persistence in PostgreSQL |
| JaVers + Exposed DDD path | Business-table persistence, JaVers audit, and repository orchestration |
The in-memory result is closest to JaVers core overhead. The Exposed repository path includes SQL
persistence. The DDD path handles current state and audit history together. Even when all three use
the name insert, they do not complete the same work. Ratios without those boundaries are
misleading.
Read the Work Before the Number
Section titled “Read the Work Before the Number”The following result is a committed measurement from JDK 21.0.11 on macOS aarch64 with PostgreSQL
18 Testcontainers. Each value is mean ms/op after 5 warmup and 40 measured iterations; lower is
better. It is not a production p95 or p99 latency result, and it does not establish concurrent-user
capacity.

| Measured path | insert | update | audit query |
|---|---|---|---|
| Hibernate Envers | 4.486 ms | 6.917 ms | 12.483 ms |
| JaVers in-memory | 0.510 ms | 0.978 ms | 12.559 ms |
| JaVers + Exposed repository | 8.499 ms | 5.945 ms | 0.763 ms |
| JaVers + Exposed DDD path | 6.397 ms | 7.257 ms | 0.704 ms |
The result supports a limited but useful reading. Persisting snapshots in PostgreSQL adds visible write work. Exposed audit queries were fast for this corpus. That is evidence that the current pushdown and indexes fit this data shape; it is not a promise that a table with hundreds of millions of snapshots will keep the same latency.
A production decision needs another run with representative object size, change frequency, retention period, and query predicates. Mean time is not enough. Measure p95 and p99 latency, throughput, database CPU and I/O, plus table and index size.
Why the JaVers Path Can Cost More When It Writes to the Database
Section titled “Why the JaVers Path Can Cost More When It Writes to the Database”The slower JaVers + Exposed repository insert result does not mean that JaVers is always slower
than Envers. Both paths audit changes, but they persist different models and complete different work.
This scenario writes JaVers commits and snapshots to PostgreSQL; it does not persist the source-of-
truth OrdersTable. Source-state persistence belongs to the separate JaVers + Exposed DDD path.
The Hibernate Envers example persists a JPA entity, then its flush detects the change and records a revision plus entity audit rows.
Hibernate Envers
begin transaction persist current entity row flush detects the change write revision and entity audit rowscommit transactionIn the JaVers + Exposed repository path, JaVers first creates a commit and snapshot from the aggregate, then the repository persists that snapshot.
JaVers + Exposed repository
build JaVers commit from the aggregateencode the full snapshot state
begin transaction check whether commit metadata exists insert commit metadata when it is absent insert snapshot row with state and changed propertiescommit transactionExposedCdoSnapshotRepository.saveSnapshot() encodes the complete snapshot as text, checks whether
the commit metadata already exists, inserts its commit row when needed, and then inserts a snapshot
row containing the global id, version, type, full state, and changed properties. Snapshot creation,
JSON/text payload size, metadata lookup, and additional JDBC/SQL work can therefore all contribute
to one request’s latency. That explanation applies only to the repository-only path. The DDD path
also includes source aggregate persistence and repository orchestration.
| Cost-increasing condition | Extra work on the database write path |
|---|---|
| Larger aggregate state | More full-snapshot serialization and text payload |
| Frequent changes | Commit and snapshot rows, plus index maintenance, accumulate faster |
| Richer commit metadata | The metadata payload and index/query considerations widen |
| Synchronous audit boundary | The business write waits for the audit write to finish |
The benchmark is therefore a starting point for choosing how much audit history to write and how synchronously to write it, not a verdict that JaVers is slower. Use representative object size, change frequency, and read requirements before choosing a persistence path.
Do Not Choose an Audit Strategy from Performance Alone
Section titled “Do Not Choose an Audit Strategy from Performance Alone”JaVers is not useful just because it can create another history table. It earns its cost when a team needs to read object-level diffs for an aggregate, keep commit metadata about who made a change and which command or event caused it, or make the order of current-state persistence, JaVers commit, and domain-event publication explicit in the command flow.
A more direct alternative may be better. Envers fits when JPA entity revisions are enough. CDC starts closer to the requirement when the main goal is to deliver database changes reliably to other systems. Write-cost measurements do not remove those requirements. Decide which history needs an explanation and where it will be queried before measuring the cost of the path.
| Path | Work handled in the command path | Kafka and query role | Good fit |
|---|---|---|---|
| Source state + JaVers audit | Handles current-state persistence and a queryable audit in the same command flow | Kafka is an optional downstream event path | Both immediate audit queries and object diffs are needed |
| Source state + JaVers audit + Kafka projection | Writes the audit, then carries a downstream event to update a read model | Kafka separates UI, search, and external consumers | A faster read model or delivery to other systems is needed |
| Kafka snapshot stream + projection store | Confirms Kafka publish acknowledgement | The Kafka repository is write-only; the projector target owns queries | A replayable event flow can be operated separately |
In the first two paths, the JaVers audit store remains where change history is queried. Adding Kafka
does not change that responsibility. A Kafka consumer can update a Redis or RDBMS read model
asynchronously, but a Kafka repository that only holds snapshot events cannot answer an audit query.
The snapshots must be projected into a separate store, such as with KafkaCdoSnapshotProjector, before
they become queryable history.
Kafka also does not make the command path asynchronous by itself. The current
KafkaCdoSnapshotRepository waits for broker acknowledgement for up to 30 seconds by default and
propagates timeout or publish failure as a RuntimeException. Consumers and their projections can
run asynchronously, but the current publish is not fire-and-forget. Truly separating the response
path requires a delivery design for loss, duplicates, and replay between the DB transaction and
Kafka publish. An outbox, retry queue, and fail-fast or best-effort policy are follow-up choices for
that design, not features this repository already implements.
Do Not Add an Index Before Reproducing the Query
Section titled “Do Not Add an Index Before Reproducing the Query”The second benchmark treats indexes on commit metadata author and commit_date as candidates. It
compares a baseline, an author index, a commit-date index, and both indexes in temporary tables.

| Index configuration | Author query | Date-range query | insert |
|---|---|---|---|
| None | 917.5 ops/s | 916.5 ops/s | 481.4 ops/s |
| author | 907.1 ops/s | 904.7 ops/s | 488.6 ops/s |
| commit date | 931.2 ops/s | 923.2 ops/s | 499.3 ops/s |
| author + commit date | 945.9 ops/s | 873.8 ops/s | 518.6 ops/s |
Both indexes produced the highest author-query throughput but lower date-range throughput than the baseline. The indexed variants also showed unexpectedly higher insert throughput. This run has only one warmup and one measured iteration, so the result cannot be generalized. It is an exploratory smoke benchmark, not a production DDL recommendation.
Use this order before adding an index:
- Capture the slow query and its call frequency.
- Inspect its execution plan against a production-like data distribution.
- Measure write latency and index size along with query latency.
- Apply the index through a migration only where it is needed, then compare before and after.
This is why bluetape4k-javers does not put author and commit-date indexes into the default schema.
The service should choose them from a reproduced bottleneck instead of inheriting one query pattern
from the library.
Three Ways to Set Audit Scope
Section titled “Three Ways to Set Audit Scope”An index cannot fix an audit scope that is too broad. I review the scope in three levels.
| Choice | Good fit | Trade-off |
|---|---|---|
| Audit core aggregates | Payments, contracts, permissions, and other decisions that need an explanation | Evidence for excluded objects must come from another log |
| Audit important transitions | Approval, cancellation, or another decision matters more than intermediate calculations | Commit points must be explicit and covered by tests |
| Audit every change | Regulation or internal control requires complete tracking | Plan capacity, retention, deletion, and sensitive-data masking first |
More history is not always safer. If snapshots include personal data or secrets, access control and deletion policy become harder to design. Too little history creates the opposite failure: there may be no evidence when an incident or dispute must be explained.
The useful question is “who will need to explain which decision later?” Select the business objects and state transitions needed for that explanation, measure write and retention volume for that scope, then choose indexes for the query predicates people actually use.
Before Production
Section titled “Before Production”| Area | Check |
|---|---|
| Audit target | Inclusion and exclusion rules for aggregates and transitions are documented |
| Transaction | A partial failure cannot make current state and audit history report different facts |
| Retention | Retention, archive, deletion, and personal-data rules are defined |
| Query path | The service knows whether it really queries by global id, author, or time range |
| Benchmark | p95 and p99 are measured with a representative database, data distribution, and object size |
| Observability | Write latency, query latency, table/index size, and failures are visible |
| Compatibility | Tests prove old snapshots remain readable after codec and schema changes |
Benchmarks and production telemetry belong together. A benchmark compares candidate designs; production telemetry checks whether those assumptions survive real traffic. Either one by itself leaves part of the cost model untested.
Resources
Section titled “Resources”- Benchmark module:
benchmark/javers-exposed-benchmark - Persistence path benchmark:
EnversComparisonBenchmark.kt - Commit metadata index benchmark:
ExposedCommitMetadataIndexBenchmark.kt - Exposed snapshot repository:
ExposedCdoSnapshotRepository.kt - DDD audit boundary:
AggregateRepository.kt - Kafka snapshot repository:
KafkaCdoSnapshotRepository.kt - Kafka snapshot projector:
KafkaCdoSnapshotProjector.kt
Closing
Section titled “Closing”| Step | Decision | Evidence to check |
|---|---|---|
| 1 | Choose the aggregates and transitions that carry an explanation obligation | Who must explain which decision during an incident, dispute, or regulatory review? |
| 2 | Separate audit queries from screen queries | Is an object diff required, and is a separate read model required? |
| 3 | Set the delivery boundary | Synchronous audit, Kafka projection, acknowledgement wait, and outbox/retry design |
| 4 | Measure again under operating conditions | p95/p99, storage volume, retention, query predicates, and index size |
Validate these choices against the real workload. The benchmark is not an answer sheet, and adding Kafka does not automatically settle audit-query or delivery-guarantee responsibilities.
Comments
Leave a note or reaction with your GitHub account.