Bluetape4k JaVers Part 3: DDD and Workshop Audit Example

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.
| Example | What to inspect |
|---|---|
bluetape4k-workshop/exposed/javers-audit | Product row persistence with JaVers history and diff queries |
examples/javers-exposed-ddd | Order 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.

From Manual Audit to JaVers
Section titled “From Manual Audit to JaVers”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.

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.
DDD Command Flow
Section titled “DDD Command Flow”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.

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.
Read Model Is Not Audit History
Section titled “Read Model Is Not Audit History”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.

CDC, Envers, and JaVers
Section titled “CDC, Envers, and JaVers”CDC, Hibernate Envers, and JaVers all sit near audit history, but they start from different places.
| Option | Starts from | Best fit |
|---|---|---|
| CDC | Database log | Stream database changes to external systems |
| Hibernate Envers | JPA entity revision | Fast entity-level revision tables in a JPA application |
| JaVers | Domain object commit | Object 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:
| Scenario | Hibernate Envers ms/op | JaVers + Exposed ms/op |
|---|---|---|
| insert | 1.049 | 3.627 |
| update | 1.383 | 2.848 |
| audit-query | 8.010 | 105.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.
Next Steps
Section titled “Next Steps”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.
| Plan | Expected benefit |
|---|---|
| Analyze JaVers + Exposed latency and move benchmarks into a benchmark module | Make the Envers gap explicit and identify query, codec, and index work |
| Push common Exposed JQL filters down to SQL | Reduce audit-query cost when many snapshots exist |
| Add an Exposed + Kafka composite repository | Keep queryable history in SQL while streaming the same snapshot events to Kafka |
| Add Kafka snapshot publishing and pluggable event pipeline adapters | Make Spring events, NATS, and other event paths easier to attach |
| Design a read-capable Kafka audit projection path | Extend Kafka from a write-only stream into a projection-backed read flow |
| Clarify Redis repository parity and Redisson near-cache strategy | Make Lettuce/Redisson selection clearer and lower hot snapshot read latency |
| Evaluate R2DBC persistence | Support coroutine/R2DBC services without blocking JDBC as the only SQL path |
| Add Spring Boot auto-configuration | Start JaVers repository wiring with less application setup |
| Make Exposed schema mapping configurable | Fit production naming rules, schema separation, and multi-tenant layouts |
| Strengthen codec compatibility and security checks | Manage snapshot format compatibility and reduce JDK serialization risk |
| Add Spring transaction coverage for DDD event publishing | Make aggregate persistence, JaVers commit, and event publishing boundaries safer |
Adoption Checklist
Section titled “Adoption Checklist”| Step | Check |
|---|---|
| Identity | Stable aggregate id and clear JaVers @Id |
| Source of truth | Which table or repository owns current state |
| Commit point | Where the command flow commits the saved aggregate |
| Metadata | Author, event type, aggregate id, and event attributes |
| Persistence | Exposed, Redis, and Kafka roles split by history/query/stream |
| Tests | INITIAL/UPDATE/TERMINAL, diff, latest snapshot, projection flow |
Source Links
Section titled “Source Links”- Workshop audit example: exposed/javers-audit
- Product audit service:
ProductAuditService.kt - DDD example: examples/javers-exposed-ddd
- Command handler:
OrderCommandHandler.kt - Aggregate repository:
AggregateRepository.kt
Closing
Section titled “Closing”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.