Skip to content
3D workbench illustration of robotic developers wiring a DDD command flow to JaVers audit storage
Audit history belongs next to the command flow, not in someone’s memory.

Part 1 introduced the JaVers audit model. Part 2 split the persistence roles. Part 3 maps that model onto two examples: the workshop javers-audit module and the javers-exposed-ddd command side example.

ExampleWhat to inspect
bluetape4k-workshop/exposed/javers-auditProduct row persistence with JaVers history and diff queries
examples/javers-exposed-dddOrder aggregate, Exposed source of truth, JaVers audit, domain events, Redis read model

This post is an application map rather than an exhaustive API guide. It starts with the small workshop example, then reads the DDD example as command side plus read side. Neither example claims to provide distributed transactions or production recovery by itself.

Command handler flow through order aggregate, Exposed store, JaVers audit, event publisher, and read model
The command side persists aggregate state, commits to JaVers, then publishes the domain event. Each step has a separate failure boundary.

Manual audit tables are fine until every entity needs its own copy logic. The workshop example keeps the current product row in Exposed and lets JaVers own history and diffs.

Comparison between manual audit table code and JaVers snapshot flow
Exposed keeps the current row. JaVers owns change history and object diff semantics.
fun save(author: String, product: Product) {
author.requireNotBlank("author")
javers.commit(author, product)
transaction {
ProductTable.upsert {
it[id] = product.id
it[name] = product.name
it[price] = product.price
it[category] = product.category
}
}
}

History reads come from JaVers snapshots:

fun getHistory(productId: Long): List<CdoSnapshot> {
val query = QueryBuilder.byInstanceId(productId, Product::class.java).build()
return javers.findSnapshots(query)
.sortedBy { it.commitMetadata.commitDate }
}

The tests verify initial, update, terminal snapshots, latest snapshot lookup, and value changes.

The small example does not make the two writes atomic. save commits to JaVers before a separate Exposed transaction upserts the row. delete likewise records the terminal snapshot before deleting the row. The module proves responsibility separation and happy-path ordering, not recovery when only one write succeeds.

The DDD example starts with an OrderCommandHandler. It loads or creates the aggregate, applies one state transition, and saves through the repository.

The scenario is intentionally small. A user places an order, and later payment marks it as paid. The command side stores the current order in an Exposed table, records the same change as a JaVers snapshot, and publishes an OrderPlaced or OrderMarkedPaid event. The query side consumes the event and updates a Redis OrderSummary. Redis is not the audit log; it is the read model for queries and screens.

Sequence diagram from the javers-exposed-ddd README showing command handling, audit commit, event publishing, and Redis projection
Command handling, audit commit, event publishing, and Redis projection run in order, but not as one atomic operation.
fun handle(command: MarkOrderPaidCommand): Order {
val current = requireNotNull(repository.load(command.orderId)) {
"Order not found: ${command.orderId.value}"
}
val now = clock.instant()
val paid = current.markPaid(now)
return repository.save(
aggregate = paid,
author = command.author,
event = OrderMarkedPaid(
aggregateId = paid.id,
occurredOn = now,
),
)
}

OrderRepository extends AggregateRepository. The source of truth is still OrdersTable. OrderRepository.saveAuditBoundary runs source persistence and the Exposed-backed JaVers commit inside the database transaction, while event publication happens after that boundary.

fun save(aggregate: T, author: String, events: Collection<DomainEvent>): T {
val saved = saveAuditBoundary {
val persisted = persist(aggregate)
javers.commit(author, persisted, events.toJaversProperties())
persisted
}
eventPublisher.publishAll(events)
return saved
}

The ordering matters: persist the aggregate first, commit the saved state to JaVers, then publish the domain event. It is not an outbox. If Kafka publication fails, the current order and audit history may remain without a projection event.

The example also has a Redis-backed OrderSummary projection. That is a query-side read model, not the audit log itself. When that line gets blurry, teams are likely to try using JaVers snapshots as a replacement for every read model. JaVers answers “how did this object change?” The read model answers “what does the screen need now?” They can work together, but they should not be confused.

CQRS flow diagram from the javers-exposed-ddd README
The source-of-truth store, JaVers audit store, Kafka event stream, and Redis read model have separate responsibilities.

The current projection assumes that OrderPlaced has already created the summary. OrderMarkedPaid fails when the summary is missing. The example also does not implement duplicate handling, out-of-order repair, production offset management, or restart recovery.

CDC, Hibernate Envers, and JaVers all sit near audit history, but they start from different places.

OptionStarts fromBest fit
CDCDatabase logStream database changes to external systems
Hibernate EnversJPA entity revisionFast entity-level revision tables in a JPA application
JaVersDomain object commitObject diffs, commit metadata, and DDD command/event audit flow

An early documentation benchmark reported a large JaVers + Exposed audit-query outlier. A later run did not reproduce it, so the old number should not be treated as a stable comparison. Part 4 documents the current benchmark paths, completion boundaries, environments, and audit-scope choices.

Verified by the exampleStill required for production
Happy-path command state and JaVers snapshotsSchema, locking, and isolation checks on the actual PostgreSQL deployment
Kafka publication keyed by order ID and Redis projectionTransactional outbox or equivalent durable recovery record
Normal OrderPlaced then OrderMarkedPaid orderingEvent IDs, idempotency, retries, quarantine, and offset policy
Latest OrderSummary reads from RedisProjection rebuild and consistency-check procedures

The tests use H2 in PostgreSQL compatibility mode plus Kafka and Redis Testcontainers. They do not prove actual PostgreSQL behavior, process restart recovery, broker failure handling, concurrent commands, duplicate or reordered events, or projection rebuilds.

StepCheck
IdentityStable aggregate id and clear JaVers @Id
Source of truthWhich table or repository owns current state
Commit pointWhere the command flow commits the saved aggregate
MetadataAuthor, event type, aggregate id, and event attributes
PersistenceExposed, Redis, and Kafka roles split by history/query/stream
Failure contractRecovery when database save, audit commit, or event publication fails
TestsINITIAL/UPDATE/TERMINAL, diff, latest snapshot, projection flow, and failure boundaries

The point is not to delete every audit table. The point is to separate responsibilities. Exposed stores current aggregate state, JaVers stores object history and diffs, and event publishers plus read models serve query-side consumers.

That separation explains the happy path. Production use must also verify transaction participation for the database and audit store, durable recovery for event publication, and idempotent, rebuildable projections.

Comments

Leave a note or reaction with your GitHub account.