Skip to content

Bluetape4k JaVers Part 3: DDD and Workshop Audit Example

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

Most Java/Kotlin developers have heard of DDD, but fewer have run aggregate roots, command handlers, domain events, and projections together in production. JaVers in that flow is rarer still. This post therefore starts with the small workshop example and then reads the DDD example as command side plus read side, instead of assuming the reader already lives inside a DDD framework.

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.

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 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
The example README sequence shows command handling, audit commit, event publishing, and Redis projection in one flow.
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; the base repository commits the saved aggregate to JaVers and publishes events afterward.

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

The ordering matters: persist the aggregate first, commit the saved state to JaVers, then publish the domain event. If those steps are reversed, current state, history, and projections can tell different stories.

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.

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

The current example benchmark should also be stated plainly. In the documented H2 benchmark for examples/javers-exposed-ddd, Hibernate Envers is faster:

ScenarioHibernate Envers ms/opJaVers + Exposed ms/op
insert1.0493.627
update1.3832.848
audit-query8.010105.339

This is a narrow persistence benchmark, not a reason to dismiss JaVers. The JaVers + Exposed example is valuable when explicit aggregate commits, commit metadata, domain events, and CQRS projection are part of the design. But the latency gap is real. The audit-query path especially needs follow-up work around repository queries, snapshot materialization, codec overhead, and index design.

bluetape4k-javers is not finished. The examples in this post make the next improvement areas clear, and I plan to evolve the library in these directions.

PlanExpected benefit
Analyze JaVers + Exposed latency and move benchmarks into a benchmark moduleMake the Envers gap explicit and identify query, codec, and index work
Push common Exposed JQL filters down to SQLReduce audit-query cost when many snapshots exist
Add an Exposed + Kafka composite repositoryKeep queryable history in SQL while streaming the same snapshot events to Kafka
Add Kafka snapshot publishing and pluggable event pipeline adaptersMake Spring events, NATS, and other event paths easier to attach
Design a read-capable Kafka audit projection pathExtend Kafka from a write-only stream into a projection-backed read flow
Clarify Redis repository parity and Redisson near-cache strategyMake Lettuce/Redisson selection clearer and lower hot snapshot read latency
Evaluate R2DBC persistenceSupport coroutine/R2DBC services without blocking JDBC as the only SQL path
Add Spring Boot auto-configurationStart JaVers repository wiring with less application setup
Make Exposed schema mapping configurableFit production naming rules, schema separation, and multi-tenant layouts
Strengthen codec compatibility and security checksManage snapshot format compatibility and reduce JDK serialization risk
Add Spring transaction coverage for DDD event publishingMake aggregate persistence, JaVers commit, and event publishing boundaries safer
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
TestsINITIAL/UPDATE/TERMINAL, diff, latest snapshot, projection flow

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.

Comments

Leave a note or reaction with your GitHub account.