Home | Send Feedback | Share on Bluesky |

Entity auditing with JaVers in jOOQ/Spring Boot application

Published: 21. July 2025  •  java

In a previous blog post, I showed how to use Hibernate Envers for auditing entity objects. This approach works great if you are using Hibernate in your application. The problem is, what if you are not using Hibernate? What if you are using a different database technology like MongoDB? In all these cases, you can't use Envers, because it is so tightly coupled with Hibernate. Fortunately, there is an alternative solution called JaVers that can be used in any Java application, regardless of the database technology or ORM framework you are using.

JaVers is an open-source Java library for tracking changes. It provides an object diff engine that compares object graphs and returns detailed atomic changes. The library provides different repository implementations to store these changes into various data stores, not just relational databases. JaVers also brings a powerful query language (JQL) to retrieve historical data, making it easy to track changes over time.

JaVers Diff Engine

The core of JaVers is the diff engine, which is responsible for comparing two objects and returning the differences. The diff engine can handle complex object graphs, including collections and nested objects. It provides annotations to customize the diff behavior, such as ignoring certain fields.

The diff engine can be added to a Maven managed project by adding the following dependency:

<dependency>
    <groupId>org.javers</groupId>
    <artifactId>javers-core</artifactId>
    <version>7.8.0</version>
</dependency>

Here a simple example of the diff engine in action:

The example code first defines three records, User and Todo are denoted as entities with the @Id annotation, while Address is a value object, it has no unique identity.

JaVers differentiates between entity (has a unique identity), value object (complex objects without identity), and value (single value, e.g. String, BigDecimal, etc.).

To denote an object as entity, you add an @Id annotation to a field that uniquely identifies the object. JaVers not only recognizes its own @Id annotation, but also annotations from other libraries like JPA (@jakarta.persistence.Id) and Morphia (@org.mongodb.morphia.annotations.Id). This allows you to use JaVers in applications that already use these libraries without having to change your domain model.

  record User(@Id String id, String name, int age, List<String> roles, Address address,
      Todo todo) {
  }

  record Address(String street, String city) {
  }

  record Todo(@Id String id, String title, boolean completed) {
  }

DiffExample.java

The application then instantiates a Javers object using the JaversBuilder. This is the main entry point to the JaVers diff engine.

    Javers javers = JaversBuilder.javers().build();

DiffExample.java

In the first example, the code compares two value objects. Value objects are compared field by field. Address only contains single value fields, so the only change that can occur is a value change represented by the ValueChange instance.

    Address address = new Address("123 Main St", "Anytown");
    Address newAddress = new Address("1234 Main St", "Anytown");
    Diff diff = javers.compare(address, newAddress);
    for (Change change : diff.getChanges()) {
      ValueChange valueChange = (ValueChange) change;
      System.out.println("Property '" + valueChange.getPropertyName() + "' changed from '"
          + valueChange.getLeft() + "' to '" + valueChange.getRight() + "'");
    }

DiffExample.java

To compare two objects, you call the compare method on the Javers instance, passing in the two objects you want to compare. The result is a Diff object that contains all the changes between the two objects. The getChanges returns a list of Change objects. Change is an abstract class that sits on top of the type hierarchy for all change types in JaVers. In the example above, we know the change can only be a ValueChange, so we cast it to ValueChange to access the specific properties of the change. The concrete type ValueChange represents a change in a value field, and it provides methods to access the property name and the old and new values. JaVers does not use the words "old" and "new", but rather "left" and "right". These two directions refer to the two arguments passed to the compare method, where the first argument is considered the "left" side and the second argument is the "right" side.

Output of the above code is

Property 'street' changed from '123 Main St' to '1234 Main St'

You can also compare collections of objects with the compareCollections method. This method returns a Diff object that contains all the changes between the two collections. The changes are represented as ListChange objects, which contain a list of ContainerElementChange objects. These represent the changes to the individual elements in the collection. Note that getChanges only returns one ListChange object, because there is only one collection that has changed.

    List<String> h1 = List.of("admin", "editor", "viewer", "reporter");
    List<String> h2 = List.of("admin", "viewer", "reporter");
    diff = javers.compareCollections(h1, h2, String.class);
    for (Change change : diff.getChanges()) {
      ListChange listChange = (ListChange) change;

      for (ContainerElementChange c : listChange.getChanges()) {
        switch (c) {
        case ElementValueChange evc -> System.out
            .println("Value changed: " + evc.getIndex() + " from '" + evc.getLeftValue()
                + "' to '" + evc.getRightValue() + "'");
        case ValueAdded va -> System.out
            .println("Added: " + va.getValue() + " at index " + va.getIndex());
        case ValueRemoved vr -> System.out
            .println("Removed: " + vr.getValue() + " at index " + vr.getIndex());
        default -> throw new IllegalArgumentException("Unexpected value: " + c);
        }
      }
    }

DiffExample.java

The compareCollections method compares the two collections index by index and results in this output:

Value changed: 1 from 'editor' to 'viewer'
Value changed: 2 from 'viewer' to 'reporter'
Removed: reporter at index 3

Next example compares two entities. The name and age of the user have changed.

    User user = new User("U1", "Alice", 30, List.of("admin", "editor"), address, null);
    User userAfterValueChange = new User("U1", "Alicia", 31, user.roles(), user.address(),
        null);

    diff = javers.compare(user, userAfterValueChange);
    System.out.println("Has Changes: " + diff.hasChanges());
    System.out.println("Diff (Value Changes): \n" + diff.prettyPrint());

    for (Change change : diff.getChanges()) {
      ValueChange vc = (ValueChange) change;
      System.out.println("  ValueChange: Property '" + vc.getPropertyName()
          + "' changed from '" + vc.getLeft() + "' to '" + vc.getRight() + "'");
    }

DiffExample.java

hasChanges returns true if there are any changes between the two objects. The prettyPrint method returns a human-readable representation of the diff, which includes all the changes. The output of the prettyPrint method is:

Diff:
* changes on ch.rasc.javersdemo.DiffExample$User/U1 :
  - 'age' changed: '30' -> '31'
  - 'name' changed: 'Alice' -> 'Alicia'

If you want to access the changes programmatically, get all the changes with the getChanges method and then process them accordingly. In this example, we only changed single value fields and therefore the diff engine only created ValueChange objects.

  ValueChange: Property 'name' changed from 'Alice' to 'Alicia'
  ValueChange: Property 'age' changed from '30' to '31'

Note that in practice it only makes sense to compare entity objects that have the same identity. You could compare entities with different ids, JaVers allows that, but this comparison results in a NewObject and ObjectRemoved change, which is not very useful.


Let's look at an example with more change types. This example instantiates a new Todo object, which is an entity object and sets it as a property in the User object. The code also changes the address of the user.

    newAddress = new Address("456 Oak Ave", "Newville");
    Todo todo1 = new Todo("T1", "Buy groceries", false);
    User userAfterAddressAndTodoChange = new User("U1", userAfterValueChange.name(),
        userAfterValueChange.age(), user.roles(), newAddress, todo1);

    diff = javers.compare(userAfterValueChange, userAfterAddressAndTodoChange);
    for (Change change : diff.getChanges()) {
      switch (change) {
      case NewObject noc -> System.out.println("  New Object: " + noc);
      case ReferenceChange rc -> System.out
          .println("  ReferenceChange: Property '" + rc.getPropertyName()
              + "' changed from " + rc.getLeft() + " to " + rc.getRight());
      case ValueChange vc -> System.out
          .println("  ValueChange: Property '" + vc.getPropertyName() + "' changed from '"
              + vc.getLeft() + "' to '" + vc.getRight() + "'");
      default -> System.out.println("  Other Change: " + change);
      }
    }

DiffExample.java

In the output, we see that entity and value object are treated differently. Because Todo is an entity, we get a NewObject change and we get a ReferenceChange for the property user.todo. If you compare this to the value object Address, we only get two ValueChange objects for the properties street and city. Because an Address object does not have an identity, JaVers cannot determine if this is a new object or an existing object that has changed. It only knows that the properties of the Address object have changed.

  New Object: NewObject{ new object: ch.rasc.javersdemo.DiffExample$Todo/T1 }
  ValueChange: Property 'id' changed from 'null' to 'T1'
  ValueChange: Property 'title' changed from 'null' to 'Buy groceries'
  ReferenceChange: Property 'todo' changed from null to ...DiffExample$Todo/T1
  ValueChange: Property 'street' changed from '123 Main St' to '456 Oak Ave'
  ValueChange: Property 'city' changed from 'Anytown' to 'Newville'

Note that JaVers by default also creates ValueChange objects for the initial instantiation of an entity. In this example, two ValueChange objects are created for the properties id and title of the Todo object. These initial changes are not always useful and can lead to a lot of noise if you instantiate many entity objects with a lot of fields. Therefore, JaVers provides a way to disable these initial changes when you create the Javers instance.

Javers javers = JaversBuilder.javers().withInitialChanges(false).build();

Annotations

In the examples above, we used the @Id annotation to denote an entity. Additionally, JaVers provides a set of annotations to customize the diff behavior:

@DiffIgnore
Can be either used on a field, getter method or type. If used on a field or method, it tells JaVers to ignore this field when comparing objects. If used on a type, it tells JaVers to ignore all fields of this type. In a JPA application, you can also use the @jakarta.persistence.Transient annotation to achieve the same effect.

@DiffInclude
Can be used on a field or getter method to tell JaVers to include this field in the diff. All other properties in this class and all properties in its subclasses will be ignored by JaVers. This annotation could be useful if you only want to track a few properties of a class, instead of adding @DiffIgnore to all other properties. Note that you can't mix @DiffInclude and @DiffIgnore in the same class.

@DiffIgnoreProperties This is a class annotation that allows you to specify a list of properties or methods to be ignored by JaVers. This is useful if you want to ignore multiple properties without annotating each property individually.

    @DiffIgnoreProperties({"roles", "address"})
    record User(@Id String id, String name, int age, List<String> roles, Address address,
        Todo todo) {
    }

@TypeName
JaVers internally creates a global ID for entity objects, which by default is a combination of the fully qualified class name and the id of the object (e.g. ch.rasc.javersdemo.DiffExample$User/U1). The problem is that you can't rename a class or move a class into a different package without breaking any existing audit records. To avoid this problem, you can use the @TypeName annotation to specify a custom name for the class. This name is then used as the global ID instead of the fully qualified class name.

    @TypeName("User")
    record User(@Id String id, String name, int age, List<String> roles, Address address,
        Todo todo) {
    }

@PropertyName
Similar to the @TypeName annotation, the @PropertyName annotation allows you to specify a custom name for a property. This is useful if you need to rename a property without breaking existing audit records.

@ShallowReference
If you annotate an entity with this annotation, JaVers only compares the id and ignores all other properties. This is useful if you only want to track changes to a reference, but not the changes to the referenced object itself.

    record User(@Id String id, String name, int age, List<String> roles, 
        Address address, @ShallowReference Todo todo) {
    }

This concludes our tour of the JaVers diff engine. Check out the JaVers documentation for more details.

On top of the core diff engine, JaVers provides a set of libraries that store the changes in various data stores (Repository), and a query language (JQL) to retrieve the changes. In the next section, we take a look at how to integrate JaVers into a Spring Boot application using jOOQ, PostgreSQL and Spring Security.

Setup JaVers

First, include the JaVers dependencies in your pom.xml file.

        <dependency>
            <groupId>org.javers</groupId>
            <artifactId>javers-persistence-sql</artifactId>
            <version>7.8.0</version>
        </dependency>
        <dependency>
            <groupId>org.javers</groupId>
            <artifactId>javers-spring-jpa</artifactId>
            <version>7.8.0</version>
        </dependency>               

pom.xml

The javers-persistence-sql has a transitive dependency on javers-core, so you don't need to add it explicitly. This demo application does not use JPA, but we will leverage one utility class from the javers-spring-jpa.


The next step is to configure JaVers. This application stores the entities in a PostgreSQL database and I wanted to store the audit records in the same database, so I added the javers-persistence-sql dependency. This is not a requirement, you can store the audit records separately in a different data store. JaVers currently supports these data stores: MongoDB, H2, PostgreSQL, MySQL, MariaDB, Oracle, Microsoft SQL Server and Redis.

Using the same database has the advantage that storing the entity object and storing the audit records can be done in the same transaction. This way you can be sure that the audit records are always in sync with the entity objects.

This is also what the following configuration does. It configures JaVers so that it takes part in the Spring transaction management.

@Configuration
public class JaversConfig {

  private final DataSource dataSource;
  private final PlatformTransactionManager transactionManager;

  JaversConfig(DataSource dataSource, PlatformTransactionManager transactionManager) {
    this.dataSource = dataSource;
    this.transactionManager = transactionManager;
  }

  @Bean
  Javers javers() {
    JaversSqlRepository sqlRepository = SqlRepositoryBuilder.sqlRepository()
        .withConnectionProvider(() -> DataSourceUtils.getConnection(this.dataSource))
        .withDialect(DialectName.POSTGRES).build();

    return TransactionalJpaJaversBuilder.javers().withTxManager(this.transactionManager)
        .registerEntities(InventoryItem.class).registerJaversRepository(sqlRepository)
        .build();

  }

}

JaversConfig.java

When you start the application the first time, JaversSqlRepository automatically creates the necessary tables in the database. The current version of JaVers (7.8.0) creates these tables: jv_commit, jv_commit_property, jv_global_id and jv_snapshot.

Track changes

In this demo application, only one entity will be tracked, the InventoryItem entity. Note the @Id annotation on the id field, which tells JaVers that this is an Entity. The @DiffIgnore annotation on the createdAt and updatedAt fields tells JaVers to ignore these fields when comparing objects.

public class InventoryItem {
  @Id
  private Long id;

  @NotBlank
  private String name;

  private String description;

  @NotNull
  @Min(0)
  private Integer quantity = 0;

  @NotNull
  @DecimalMin("0.00")
  private BigDecimal price = BigDecimal.ZERO;

  private String category;

  private String sku;

  @DiffIgnore
  private LocalDateTime createdAt;

  @DiffIgnore
  private LocalDateTime updatedAt;

InventoryItem.java

The application uses the following AuditService to track changes to the entity object. The application calls these methods everytime something in the object changes.

Unlike the compare method we saw earlier, the commit method only expects a single argument, the updated entity object. JaVers internally automatically retrieves the previous state of the object, compares it and stores the changes in the database.

For deleting an entity object, the application calls the commitShallowDelete method. This method marks the given object as deleted. Unlike commit, this method is shallow and only affects the given object. It does not delete anything from the audit tables, it only persists a 'terminal snapshot' of the given object. This means that the object is marked as deleted, but the previous states of the object are still stored in the audit tables. This allows you to retrieve old states of an object even after it has been deleted.

@Service
public class AuditService {

  private final Javers javers;

  public AuditService(Javers javers) {
    this.javers = javers;
  }

  private static String getCurrentUser() {
    Authentication authentication = SecurityContextHolder.getContext()
        .getAuthentication();
    if (authentication != null && authentication.isAuthenticated()
        && !"anonymousUser".equals(authentication.getName())) {
      return authentication.getName();
    }
    return "system";
  }

  public Commit commitEntity(Object entity) {
    return this.javers.commit(getCurrentUser(), entity);
  }

  public Commit commitEntityDeletion(Object entity) {
    return this.javers.commitShallowDelete(getCurrentUser(), entity);
  }

}

AuditService.java

Because this demo application uses Spring Security, it can easily access the currently logged-in user. The getCurrentUser method retrieves the username of this user. The username is passed to the commit call. JaVers stores this information together with the changes in the audit log. This allows us to see who made the change.


In the InventoryService, the application uses jOOQ to interact with the database. The InventoryService class provides methods to create, update, delete and retrieve InventoryItem objects. In this class the AuditService is injected and used to track changes to the InventoryItem objects. Each time an InventoryItem is modified, the AuditService is called to commit the changes to the audit tables.

@Service
@Transactional
public class InventoryService {

  private final DSLContext dsl;
  private final AuditService auditService;

  public InventoryService(DSLContext dsl, AuditService auditService) {
    this.dsl = dsl;
    this.auditService = auditService;
  }

  public List<InventoryItem> getAllItems() {
    return this.dsl.selectFrom(INVENTORY_ITEMS).fetch().into(InventoryItem.class);
  }

  public InventoryItem getItemById(Long id) {
    return this.dsl.selectFrom(INVENTORY_ITEMS).where(INVENTORY_ITEMS.ID.eq(id))
        .fetchOne().into(InventoryItem.class);
  }

  public InventoryItem createItem(InventoryItem item) {
    InventoryItem savedItem = save(item);
    this.auditService.commitEntity(savedItem);
    return savedItem;
  }

  public InventoryItem updateItem(InventoryItem item) {
    InventoryItem savedItem = save(item);
    this.auditService.commitEntity(savedItem);
    return savedItem;
  }

  public void deleteItem(Long id) {
    InventoryItem item = getItemById(id);
    if (item != null) {
      deleteById(id);
      this.auditService.commitEntityDeletion(item);
    }
  }

  public InventoryItem updateQuantity(Long id, Integer newQuantity) {
    InventoryItem item = getItemById(id);
    if (item == null) {
      throw new RuntimeException("Item not found");
    }
    item.setQuantity(newQuantity);
    InventoryItem savedItem = save(item);
    this.auditService.commitEntity(savedItem);
    return savedItem;
  }

InventoryService.java

Query changes

Just storing the changes is by itself not very useful. You also need a way to query the changes and retrieve the history of an object. JaVers provides a query language called JQL (JaVers Query Language) to retrieve historical data. JQL is a simple, fluent API to query the stored audit records for changes of a given class, object or property.

JaVers provides three views on an object's history: Changes, Shadows and Snapshots.

Changes are the atomic changes that have been made to an object. They represent the differences between two states of an object. These are the changes from the core diff engine we saw earlier.

Snapshots represent the state of an object at a specific commit, including all properties and relationships, represented as a property:value map.

Shadows are historical versions of a domain object restored from a snapshot. These are instances of your domain object that are created from a snapshot.

To access these views, JaVers provides find* methods on the Javers instance. Here are some examples:

  @GetMapping("/inventory-items/{id}/snapshots")
  public String getInventoryItemSnapshots(@PathVariable Long id,
      @RequestParam(defaultValue = "10") int limit) {
    List<CdoSnapshot> changes = this.javers.findSnapshots(
        QueryBuilder.byInstanceId(id, InventoryItem.class).limit(limit).build());
    return this.javers.getJsonConverter().toJson(changes);
  }

  @GetMapping("/inventory-items/{id}/changes")
  public String getInventoryItemChanges(@PathVariable Long id) {
    var changes = this.javers
        .findChanges(QueryBuilder.byInstanceId(id, InventoryItem.class).build());
    return this.javers.getJsonConverter().toJson(changes);
  }
  
  @GetMapping("/inventory-items/{id}/shadows")
  public List<InventoryItem> getInventoryItemShadows(@PathVariable Long id) {
    return this.javers
        .findShadows(QueryBuilder.byInstanceId(id, InventoryItem.class).build()).stream()
        .map(shadow -> (InventoryItem)shadow.get()).toList();
  }

AuditController.java

This example only scratches the surface of what you can do with JQL. Check out the official documentation for more examples and detailed information.

Demo

Let's see this in action. I created a simple client that allows me to send requests to the Spring Boot application.

The client first logs in with user1 and creates a new InventoryItem object. Then it logs in with user2 and updates the quantity of the item. Finally, it logs in again with user1 and deletes the item.

You find the complete source code of the client in the GitHub repository.

Now let's see how this looks when calling the three endpoints defined above.


Changes

The changes endpoint returns a list of Change objects that represents the changes made to the InventoryItem object.

[
  {
    "changeType": "ObjectRemoved",
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "commitMetadata": {
      "author": "user1",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.815",
      "commitDateInstant": "2025-07-21T09:57:15.815144400Z",
      "id": 6.00
    }
  },
  {
    "changeType": "TerminalValueChange",
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "commitMetadata": {
      "author": "user1",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.815",
      "commitDateInstant": "2025-07-21T09:57:15.815144400Z",
      "id": 6.00
    },
    "property": "id",
    "propertyChangeType": "PROPERTY_VALUE_CHANGED",
    "left": 59,
    "right": null
  },
  ...
  {
    "changeType": "ValueChange",
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "commitMetadata": {
      "author": "user2",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.551",
      "commitDateInstant": "2025-07-21T09:57:15.551236400Z",
      "id": 5.00
    },
    "property": "quantity",
    "propertyChangeType": "PROPERTY_VALUE_CHANGED",
    "left": 50,
    "right": 65
  },
  {
    "changeType": "NewObject",
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "commitMetadata": {
      "author": "user1",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.275",
      "commitDateInstant": "2025-07-21T09:57:15.275780400Z",
      "id": 4.00
    }
  },
  {
    "changeType": "InitialValueChange",
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "commitMetadata": {
      "author": "user1",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.275",
      "commitDateInstant": "2025-07-21T09:57:15.275780400Z",
      "id": 4.00
    },
    "property": "id",
    "propertyChangeType": "PROPERTY_VALUE_CHANGED",
    "left": null,
    "right": 59
  }
  ...
]

Snapshots

A snapshot is a complete state of an object. Because our entity object went through three changes: initial creation, update and deletion, we see three snapshots. They are by default sorted in reverse chronological order. So the first snapshot is the terminal snapshot and represents that the object has been deleted.

The second snapshot is the state of the object after updating the quantity property, and the third snapshot is the initial state of the object when it was created. The author tells you who made the change and the commitDate tells you when the change was made.

[
  {
    "commitMetadata": {
      "author": "user1",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.815",
      "commitDateInstant": "2025-07-21T09:57:15.815144400Z",
      "id": 6.00
    },
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "state": {},
    "changedProperties": [],
    "type": "TERMINAL",
    "version": 3
  },
  {
    "commitMetadata": {
      "author": "user2",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.551",
      "commitDateInstant": "2025-07-21T09:57:15.551236400Z",
      "id": 5.00
    },
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "state": {
      "quantity": 65,
      "price": 899.99,
      "name": "Laptop Computer",
      "description": "High-performance laptop for business use",
      "id": 59,
      "category": "Electronics",
      "sku": "LAP-001"
    },
    "changedProperties": [
      "quantity"
    ],
    "type": "UPDATE",
    "version": 2
  },
  {
    "commitMetadata": {
      "author": "user1",
      "properties": [],
      "commitDate": "2025-07-21T11:57:15.275",
      "commitDateInstant": "2025-07-21T09:57:15.275780400Z",
      "id": 4.00
    },
    "globalId": {
      "entity": "ch.rasc.javersdemo.entity.InventoryItem",
      "cdoId": 59
    },
    "state": {
      "quantity": 50,
      "price": 899.99,
      "name": "Laptop Computer",
      "description": "High-performance laptop for business use",
      "id": 59,
      "category": "Electronics",
      "sku": "LAP-001"
    },
    "changedProperties": [
      "quantity",
      "price",
      "name",
      "description",
      "id",
      "category",
      "sku"
    ],
    "type": "INITIAL",
    "version": 1
  }
]

Shadows

Because shadows are created from snapshots, we also get three shadows back. You see here that this is a JSON of the InventoryItem object. Because these are instances of the entity object, we don't get any metadata like who or when the change was made. Shadows are just the historical versions of the entity object and could be useful if you want to revert to a previous state.

[
  {
    "id": null,
    "name": null,
    "description": null,
    "quantity": 0,
    "price": 0,
    "category": null,
    "sku": null,
    "createdAt": "2025-07-21T11:57:16.1135129",
    "updatedAt": "2025-07-21T11:57:16.1135129"
  },
  {
    "id": 59,
    "name": "Laptop Computer",
    "description": "High-performance laptop for business use",
    "quantity": 65,
    "price": 899.99,
    "category": "Electronics",
    "sku": "LAP-001",
    "createdAt": "2025-07-21T11:57:16.1155138",
    "updatedAt": "2025-07-21T11:57:16.1155138"
  },
  {
    "id": 59,
    "name": "Laptop Computer",
    "description": "High-performance laptop for business use",
    "quantity": 50,
    "price": 899.99,
    "category": "Electronics",
    "sku": "LAP-001",
    "createdAt": "2025-07-21T11:57:16.1155138",
    "updatedAt": "2025-07-21T11:57:16.1155138"
  }
]

Conclusion

You have reached the end of this blog post. I showed you some of the core features of JaVers, how to integrate it into a jOOQ/Spring application and how to track changes. JaVers provides a powerful diff engine that can handle complex object graphs and collections, and it allows you to track changes over time and query them with the JQL query language.

The complete source code of the demo application is available on GitHub.