Skip to content

Bluetape4k JaVers Part 4: Audit Logs Are Not Free

3D workbench illustration of small robotic workers sorting JaVers commits and snapshots while watching a cost gauge
Audit history improves accountability, but its write, storage, and query cost grows with its scope.

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 pointQuestion to answer
WriteHow much do business persistence and the JaVers commit add to request latency?
StorageHow long will full object state, changed properties, and commit metadata be retained?
QueryHow much can global-id, author, and date predicates narrow the SQL result?
OperationsWho owns retention, deletion requests, schema changes, and codec compatibility?

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.

Cost map from a business change through current-state persistence, JaVers commit metadata and snapshot storage, and audit history queries
Audit cost spans the business write, snapshot encoding and retention, metadata joins, and snapshot reconstruction.

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 pathWork included
Hibernate EnversJPA entity persistence, revision recording, and revision query
JaVers in-memoryJaVers core commit and snapshot query without a database
JaVers + Exposed repositoryJaVers snapshot and commit metadata persistence in PostgreSQL
JaVers + Exposed DDD pathBusiness-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.

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.

Chart comparing mean insert, update, and audit query time across Hibernate Envers, JaVers in-memory, Exposed repository, and Exposed DDD paths
The four paths complete different work. Read the path boundary before comparing bar lengths.
Measured pathinsertupdateaudit query
Hibernate Envers4.486 ms6.917 ms12.483 ms
JaVers in-memory0.510 ms0.978 ms12.559 ms
JaVers + Exposed repository8.499 ms5.945 ms0.763 ms
JaVers + Exposed DDD path6.397 ms7.257 ms0.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 rows
commit transaction

In 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 aggregate
encode 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 properties
commit transaction

ExposedCdoSnapshotRepository.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 conditionExtra work on the database write path
Larger aggregate stateMore full-snapshot serialization and text payload
Frequent changesCommit and snapshot rows, plus index maintenance, accumulate faster
Richer commit metadataThe metadata payload and index/query considerations widen
Synchronous audit boundaryThe 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.

PathWork handled in the command pathKafka and query roleGood fit
Source state + JaVers auditHandles current-state persistence and a queryable audit in the same command flowKafka is an optional downstream event pathBoth immediate audit queries and object diffs are needed
Source state + JaVers audit + Kafka projectionWrites the audit, then carries a downstream event to update a read modelKafka separates UI, search, and external consumersA faster read model or delivery to other systems is needed
Kafka snapshot stream + projection storeConfirms Kafka publish acknowledgementThe Kafka repository is write-only; the projector target owns queriesA 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.

Chart comparing author-query, date-range-query, and insert throughput for four commit metadata index configurations
Index effects vary by query shape. This smoke benchmark is not evidence for production DDL.
Index configurationAuthor queryDate-range queryinsert
None917.5 ops/s916.5 ops/s481.4 ops/s
author907.1 ops/s904.7 ops/s488.6 ops/s
commit date931.2 ops/s923.2 ops/s499.3 ops/s
author + commit date945.9 ops/s873.8 ops/s518.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:

  1. Capture the slow query and its call frequency.
  2. Inspect its execution plan against a production-like data distribution.
  3. Measure write latency and index size along with query latency.
  4. 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.

An index cannot fix an audit scope that is too broad. I review the scope in three levels.

ChoiceGood fitTrade-off
Audit core aggregatesPayments, contracts, permissions, and other decisions that need an explanationEvidence for excluded objects must come from another log
Audit important transitionsApproval, cancellation, or another decision matters more than intermediate calculationsCommit points must be explicit and covered by tests
Audit every changeRegulation or internal control requires complete trackingPlan 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.

AreaCheck
Audit targetInclusion and exclusion rules for aggregates and transitions are documented
TransactionA partial failure cannot make current state and audit history report different facts
RetentionRetention, archive, deletion, and personal-data rules are defined
Query pathThe service knows whether it really queries by global id, author, or time range
Benchmarkp95 and p99 are measured with a representative database, data distribution, and object size
ObservabilityWrite latency, query latency, table/index size, and failures are visible
CompatibilityTests 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.

StepDecisionEvidence to check
1Choose the aggregates and transitions that carry an explanation obligationWho must explain which decision during an incident, dispute, or regulatory review?
2Separate audit queries from screen queriesIs an object diff required, and is a separate read model required?
3Set the delivery boundarySynchronous audit, Kafka projection, acknowledgement wait, and outbox/retry design
4Measure again under operating conditionsp95/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.