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 |
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.

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.
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.
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.
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.
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.

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, 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 |
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.
What the Example Proves
Section titled “What the Example Proves”| Verified by the example | Still required for production |
|---|---|
| Happy-path command state and JaVers snapshots | Schema, locking, and isolation checks on the actual PostgreSQL deployment |
| Kafka publication keyed by order ID and Redis projection | Transactional outbox or equivalent durable recovery record |
Normal OrderPlaced then OrderMarkedPaid ordering | Event IDs, idempotency, retries, quarantine, and offset policy |
Latest OrderSummary reads from Redis | Projection 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.
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 |
| Failure contract | Recovery when database save, audit commit, or event publication fails |
| Tests | INITIAL/UPDATE/TERMINAL, diff, latest snapshot, projection flow, and failure boundaries |
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 - Exposed audit boundary: Audit history with JaVers
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.
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.