Spring Boot: Versioning entities

Kai
soobr
Published in
8 min readJun 26, 2024

We constantly strive to improve the user experience by delivering features that empower self-service. This time, we wanted to equip users with the ability to create copies (non-live) of their data environment. This allows them to test changes through simulations before applying them to the live environment.

This article dives into our implementation of versioned entities within a Spring Boot application. We’ll explore how we optimized code modifications while prioritizing a maintainable and well-understood solution. We’ll explore the implementation details, covering the database layer all the way to the frontend.

The concept

In this chapter, we’ll explore the initial approach we took for implementing entity versioning in our Spring Boot application. We aimed to minimize code changes while ensuring a maintainable solution.

Identifying Versionable Data

Our first step was to determine which data would benefit most from versioning for simulations. We analyzed our data model and identified approximately 6 out of 150 tables as relevant for this feature. We named this feature “ConfigVersion.”

Why not Hibernate Envers?

While Hibernate Envers is a powerful tool for auditing individual entity changes, it wasn’t the ideal fit for our specific needs. Envers excels at tracking field-level modifications and creates separate audit tables for each audited entity.

However, our requirement was to manage entire configuration versions as a whole, including relationships between entities. These configurations could be built and modified by users over time, requiring consistent snapshots at specific points. The user is then also able to compare two config version against each other.

Adding a Config Version ID

We opted to add a new column named config_version_id to the 6 chosen tables. This new column, along with the existing primary key identifier, would form a composite primary key for these tables.

Sample Database Schema

A quick cutout of our database schema looks like this:

Database schema with new config_version_id column

The blue boxes highlight the newly added config_version_id column.

Foreign Keys and Cascade Deletes

The foreign key from the CalendarEntry table to the CleaningTour table is now a composite foreign key, referencing both cleaning_tour_id and config_version_id. However, the foreign key from the MobileDevice table to CleaningTour remains a simple foreign key using only cleaning_tour_id.We’ll discuss the handling of cascade deletes in a later chapter.

Runtime Data View

With this approach, our database can hold multiple entities with the same original ID but differentiated by their unique config_version_id values.

runtime view of the adjusted tables

For instance, we can maintain the same cleaning_tour_id value (e.g., 10) in the MobileDevice table even when referencing different configuration versions. When fetching the associated CleaningTour based on the cleaning_tour_id from the MobileDevice entity, we can choose to return the CleaningTour from either the active configuration version or another version based on the user’s context.

This allows users to interact with the MobileDevice defined in the active ConfigVersion while viewing a different configuration version. Consequently, the UI for the MobileDevice view will need to be read-only. The technical details of implementing this filter for CleaningTour queries will be covered in the next chapter.

Filter implementation with Hibernate Filters

We aimed to avoid manually modifying existing queries with custom filter logic for each config-versioned entity. This approach would become cumbersome as the number of queries grows. Therefore, we opted to leverage Hibernate Filters for a more centralized and maintainable solution. The following code snippet demonstrates how we implemented a filter for the CleaningTour entity:

Architecture for Setting Config Version

The value for the filter is set via a Spring Aspect once a repository of a config-versioned entity is called. The following diagram shows the architecture of setting the config version:

We created two custom annotations:

  • @ConfigVersionRelatedEntity: This annotation serves as a reminder that the entity is config-versioned but has no direct impact on the code.
  • @ConfigVersionRelatedRepository: This annotation is applied to the repository. The aspect will intercept all calls to a repository method where the repository has this annotation.
Aspect to intersect calls to an config-versioned entity

This aspect intercepts calls to repositories managing config-versioned entities. This aspect sets the relevant configVersionId for the corresponding filter value on the current Hibernate session. This ensures that all queries operating on config-versioned entities within the transaction consider the appropriate config version.

To be able to use the session object, an active transaction is required. Therefore, we annotate our repositories with @Transactional. This ensures the aspect can intercept calls and set the filter on the Hibernate session within a transactional context.

If you now call cleaningTourRepository.findByEconomicEntityId(71L);, the following query will be executed:

Hibernate: select ... from cleaning_tour cleaningto0_ where cleaningto0_.config_version_id = ? and economicen1_.id=?

As expected, Hibernate automatically adds the cleaningto0_.config_version_id = ? clause to the query based on the active filter. This filtering approach also applies to custom JPQL queries you might write.

Handling Many-to-Many Relationships with Versioning

Our data model included several @ManyToMany relationships between entities. One such example is the relationship between CleaningTourUpdateGroup and CleaningTour. However, directly mapping the CleaningTour entity within the @ManyToMany relationship becomes incompatible with our versioning approach.

Challenge and Solution

Since we need to track the specific config version of each related CleaningTour, directly mapping the entity object is no longer suitable. To address this, we opted to store only the IDs of the related CleaningTour entities within the join table.

Code Before:

@ManyToMany
@JoinTable(name = "cleaning_tour_update_group_member",
joinColumns = @JoinColumn(name = "cleaning_tour_update_group_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "cleaning_tour_id_id", referencedColumnName = "id"))
private Set<CleaningTour> cleaningTourUpdateGroupMembers = new HashSet<>();

Code After:

@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name="cleaning_tour_update_group_member", joinColumns=@JoinColumn(name="cleaning_tour_update_group_id"))
@Column(name = "cleaning_tour_id")
private Set<Long> cleaningTourUpdateGroupMembers = new HashSet<>();

Explanation of the Solution

By using @ElementCollection and @CollectionTable, we store a collection of Long values representing the IDs of the related CleaningTour entities. This approach allows us to maintain versioning information for each related entity through the config version ID within the main entity.

Fetching Additional Information

When we need to retrieve the complete CleaningTour object for a specific ID, we can leverage the injected CleaningTourRepository and filter the results based on the desired config version (which is automatically handled by the previously described filter implementation).

Obtaining the Config Version ID

The config version ID originates from the frontend application. Whenever a user wants to view a specific configuration version, the frontend dynamically transmits the desired config version ID as a header parameter with each subsequent request.

Backend Implementation

On the backend, we implemented a Spring Filter to intercept incoming requests. This filter retrieves the config version ID from the request header and stores it within a request-scoped bean named ConfigVersionContext.

Request-Scoped Bean

The @Bean annotation with scope = RequestScoped in the code snippet configures ConfigVersionContext to have a lifespan limited to the current (Web) request. This ensures the retrieved config version ID is accessible within the scope of that particular request.

The bean configuration looks like this:

Aspect Accessing ConfigVersionContext

The aspect responsible for setting the configVersionId in filters retrieves the ID from the ConfigVersionContext bean. This retrieved ID is then used to dynamically filter queries based on the desired configuration version.

Full Flow

A simplified view of the data retrieval process for a config-versioned entity is depicted in the following diagram: https://www.mermaidchart.com/raw/9a22117f-55fb-4c00-bf7f-2a4a1f1e5b97?theme=light&version=v0.1&format=svg

Pitfalls to Avoid

As you implement versioning for your entities, it’s crucial to consider potential pitfalls. Here’s one key point to remember:

Foreign Keys for Relationships Between Config-Versioned Entities

When you have a relationship between two config-versioned entities, remember to adjust the foreign key constraints. This is because a foreign key referencing only the entity ID wouldn’t be able to uniquely identify the intended related entity within a specific configuration version.

To ensure proper foreign key references, modify the foreign key to include both the entity ID and the config_version_id as a composite key. This way, the foreign key can accurately reference the specific version of the related entity within the context of a particular configuration.

Challenges

Implementing our versioning approach involved overcoming a few challenges. Here, we’ll discuss two key aspects that required careful consideration:

1. Request-Scoped Beans and Web Context

One initial hurdle we encountered involved using request-scoped beans outside of a web context. Since our ConfigVersionContext bean was scoped to the request, attempting to access it in non-web contexts resulted in exceptions. To address this, we devised a custom solution that's described in this blog post.

2. Cascade Deletes and Data Integrity

Another challenge we faced was managing cascade deletes at the database level. Cascade deletes typically function as expected when the foreign key includes the config_version_id in the referenced columns.

Scenario: MobileDevice and CleaningTour

However, consider the scenario described earlier with MobileDevice and CleaningTour. If the foreign key from MobileDevice only references cleaning_tour_id, deleting a CleaningTour while viewing a non-active config version could unintentionally impact the active config version by deleting the associated MobileDevice.

Our Approach

To mitigate this issue, we opted to drop all foreign keys on tables referencing config-versioned entities that lacked the config_version_id column. We then implemented custom logic within our code to handle the database cascades. This approach ensures data integrity by managing deletes programmatically. The delete itself is handled by using another Aspect that intersects on the delete call to a repository.

We needed to have a similar logic if the user wants to set a config version to the new active version. There we need to check if the references are satisfied on the new active version. If not, we delete the referenced entity.

Conclusion

Implementing entity versioning was a significant undertaking, requiring several months of development effort. While we encountered various challenges, particularly related to removing entity mapping, we created creative workarounds to overcome them.

Unique Feature and Benefits

As a result, we’ve established a unique feature within our industry, and early customer feedback has expressed positive reactions. From a developer perspective, we’re particularly pleased with maintaining original IDs and avoiding modifications to existing queries for config-versioned entities. This approach simplifies development and maintenance.

Phased Rollout and Future

We decided to go for a phased rollout strategy, introducing the feature on a per-entity basis. This allowed us to identify and address any issues effectively during the initial stages. We’re happy with the final outcome and excited for our customers to leverage the benefits of this feature. We’ll continue to monitor and improve the feature based on user feedback and evolving needs.

--

--