Designing a Unified Audit Trail System Across Multiple Microservices

When we started building out our backend platform, data lineage was not something we had at the top of our mind. There was no particular requirement that demanded it, and we never developed an established pattern detailing how we maintain historical data.
Some services maintained history tables. Some stored snapshots. Some tracked only critical state transitions, while others (most of them!) barely tracked anything at all. When we had built this, it was absolutely fine because each service mostly operated within its own scope, cross-service workflows were programmatically defined, and the need for a unified data audit layer had not reared its ugly head yet. But as the product grew, the cracks started showing.
We had 10+ microservices used by 20+ tenants, generating upwards of 100k data modification events per day.
This was working perfectly fine. Our database was spotless. All the workflows were in order. But there was a little problem — we could not prove its correctness. That’s when we realized the actual problem wasn’t “how do we store history?” — rather, the problem was that we had no unified audit layer at all.
Every service had its own interpretation of:
- what should be audited,
- how audit records should look,
- what metadata should be stored,
- and how history should be queried.
So we decided to centralize the whole thing into our backend framework itself.
In this article, I’ll walk you through how we built a unified audit trail system across our multi-tenant Spring Boot ecosystem using Javers, compile-time code generation, and a few abstractions that helped us keep the developer experience surprisingly clean and even enjoyable!
The Existing Platform
Before getting into auditing itself, it’s important to understand the platform we already had in place.
We had an internal backend framework that we used to bootstrap services across the organization. It already handled things like CRUD APIs, authentication, authorization and other boilerplate stuff. This framework was heavily opinionated, built on top of Spring Boot and was the de facto standard for any microservice built by our team.
Over the years, we kept adding platform-wide features that every service would need — such as catalogue integration, SQS and SNS capabilities, multi-tenancy and so on. After a few brainstorming sessions, audit trail integration sounded like the perfect next step for this platform.
Instead of asking every team to design and maintain audit tables — we could move the concern directly into the framework itself.
The goal was simple:
you, as the developer, don’t have to think about auditing — the framework already has you covered!
And honestly, that part turned out pretty well.
Why We Chose Javers
There are multiple ways to approach auditing in the Java ecosystem.
We looked at a few alternatives, such as Hibernate Envers, but Javers fit our use case particularly well for a couple of reasons:
- it integrates very naturally with Spring Boot,
- it already understands JPA entities out of the box,
- and it does not create a whole new audit table for each entity.
That last point mattered quite a bit to us — none of us were looking forward to maintaining 2x the number of tables just because we added an audit trail! Hence, Javers became our weapon of choice.
Integrating Javers
This was honestly the easiest part of the entire process. Javers already provides a very ergonomic Spring Boot integration, and just adding the library to your dependencies gets you up and running.
After that, enabling auditing for your entities is as straightforward as annotating the corresponding repository:
@JaversSpringDataAuditable
public interface InvoiceRepository
extends JpaRepository<Invoice, Long> {
}
Once this integration lives inside the framework, new services inherit auditing behaviour automatically. From a developer’s perspective, onboarding looks like something like this:
- define your new entity,
- annotate the repository with JaversSpringDataAuditable,
- and you now have audit history!
Less than thirty seconds. Done. No sweat.
The Core Audit Flow

At a high level, the flow itself is fairly straightforward.
Whenever an entity changes, the first version is stored as a snapshot and subsequent changes are stored as immutable JSON diffs.
Javers helpfully stores the commit timestamp, author data and some other metadata that you can configure for yourself should you need it. It also provides querying for changes by entity type, entity ID, author ID and multiple other variants through a fluent query language known as JQL. We used this powerful feature to build an internal audit UI that was used by key stakeholders and auditors.
One thing worth mentioning here is that this entire flow was synchronous and in-request. If you are conversant with building scalable backend systems I can already see alarm bells ringing in your head — could this be made async?
The answer is yes, but as with most things in software engineering, this one comes with a couple of trade-offs. We will discuss those trade-offs a bit later in the article.
Configuring Javers
Even though Javers auto-configures itself out of the box, there are some things that you can modify for your specific needs. The two things we needed to configure were the database properties and the AuthorProvider class.
Javers writes to the application database by default, but we wanted a separate audit database for all our services. Javers does not try to manage database interactions on its own — instead, it asks the persistence layer for a connection and uses it. All of this is managed through the ConnectionProvider class, and that sounded like a good place to route the audit data to our dedicated database.
We started off by defining our own ConnectionProvider class:
public class JaversConnectionProvider implements ConnectionProvider {
private final DataSource javersDataSource;
public JaversConnectionProvider(DataSource javersDataSource) {
this.javersDataSource = javersDataSource;
}
@Override
public Connection getConnection() {
return DataSourceUtils.getConnection(javersDataSource);
}
}
And then we just needed to wire in our database properties and create a new ConnectionProvider bean, which overrides the default one. Note that this bean is marked @Primary — this makes sure Spring designates this bean as the default, thus overriding the default one provided by Javers.
@Configuration
public class JaversDatasourceConfig {
@Value("${javers.datasource.url}")
private String url;
@Value("${javers.datasource.username}")
private String username;
// More database properties here
@Bean(name = "JpaHibernateConnectionProvider")
@Primary
public ConnectionProvider jpaConnectionProvider() {
DataSourceProperties dataSourceProperties = new DataSourceProperties();
dataSourceProperties.setUrl(url);
dataSourceProperties.setUsername(username);
dataSourceProperties.setPassword(password);
dataSourceProperties.setDriverClassName(driverClassName);
HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
dataSource.setMaximumPoolSize(Integer.parseInt(maxPoolSize));
dataSource.setMinimumIdle(Integer.parseInt(minIdleConns));
return new JaversConnectionProvider(dataSource);
}
}
The second thing we needed to configure was author data. In our framework, the authentication context is passed throughout the application in a ThreadLocal variable called Context — and we needed to make Javers understand it. We did this through a custom implementation of the AuthorProvider class.
@Configuration
public class JaversAuthorProvider {
@Bean
public AuthorProvider provideJaversAuthor() {
return new SimpleAuthorProvider();
}
private static class SimpleAuthorProvider implements AuthorProvider {
@Override
public String provide() {
return Context.getUserId();
}
}
}
The Problem We Didn’t See Coming
Integrating Javers itself was honestly the easy part.
The harder problem showed up once we started running the audit flow on our real-world domain models.
Javers automatically recognizes @Entity classes as entities, and primitives like String, Integer, BigDecimal, etc., as values. This is perfectly reasonable. But our entities also contained a lot of nested data objects.
For example, InvoiceMetadata here:
@Entity
public class Invoice {
// Many, many fields here..
private InvoiceMetadata metadata;
}
Now, InvoiceMetadata was not something we wanted to independently track or version. To us, it was essentially a typed JSON attached to the parent entity.
But Javers interpreted these classes as ValueObjects, which are essentially Entities, but without unique identifiers. This behaviour was completely unnecessary and even impractical because Javers would generate a new snapshot row for each one of these ValueObjects.
Semantically, this wasn’t really what we wanted.
We wanted these nested objects to behave more like atomic values from the perspective of the audit system. That meant we had to teach Javers how to interpret our domain model differently.
Building Custom Adapters
Fortunately, Javers was very extensible here as well. We solved this through custom JsonTypeAdapterclasses.
A simplified adapter looked something like this:
@Component
public class InvoiceMetadataAdapter implements JsonTypeAdapter<InvoiceMetadata> {
@Override
public InvoiceMetadata fromJson(
JsonElement json,
JsonDeserializationContext context) {
// Write JSON to object deserialization logic here
}
@Override
public JsonElement toJson(
InvoiceMetadata sourceValue,
JsonSerializationContext context) {
// Write object to JSON serialization here
}
@Override
public List<Class> getValueTypes() {
return List.of(InvoiceMetadata.class);
}
}
The adapter essentially does two things:
- tells Javers to treat the class as a value,
- and standardizes serialization/deserialization using Jackson.
Once we wire these adapters into Spring, Javers would automatically discover them and use them while generating audit snapshots.
Problem solved, but only partially.
Boilerplate, Boilerplate, Boilerplate
The issue was that we didn’t just have one or two of these metadata classes.
Across projects, we already had well over 200 custom POJOs like the one we described above, and more kept getting added over time.
Writing adapters manually for every single class would have made no sense. For one, it was just boilerplate. And secondly, we knew that one fine morning, our code would break because someone somewhere forgot to add a type adapter for their new class.
This was the point where the problem stopped being “audit logging” and became a platform engineering problem.
The actual challenge was:
how do we enforce consistent audit semantics across many teams without depending on manual discipline?
Compile-Time Adapter Generation

To solve this, we introduced a custom annotation called @AuditAdapter . The idea was simple — annotate every custom metadata class, and the framework would generate JSONTypeAdapter classes for it during compilation.
We built the annotation processor using JavaPoet. JavaPoet is a Java library that lets you programatically generate.. Java files. Very meta. 😄
At compile time, the processor:
- scans classes annotated with @AuditAdapter,
- generates a strongly typed JsonTypeAdapter for the class,
- annotates it with @Component which registers it as a Spring bean,
- which exposes it automatically to Javers.
So developers only write this:
@AuditAdapter
public class TaxMetadata {
// Fields here
}
And the framework generates the adapter implementation automatically at compile-time.
The generated adapters contain the exact toJson, fromJson and getValueTypes methods we saw earlier.
This works solely because the adapters we needed were repetitive and essentially did the same thing — serialize objects to JSON and deserialize JSON into objects. Pure boilerplate, and we automated it out of the workflow.
The nice thing about this approach is that it reduces developer friction massively. As a developer, you don’t need to necessarily understand what an adapter is and why Javers needs it — you just need to know how to annotate your metadata classes. The framework takes care of the rest.
The code for adapter generation is not trivial. It is also a little esoteric. If you want, you can follow this link to go through it — but in my mind, the journey we took to get here is far more interesting than the destination itself.
Lessons Learned
The biggest thing we learned from this project is that audit systems are rarely just storage problems. The hard part is consistency.
Once multiple teams and services are involved, entropy and schema drift show up very quickly and getting a unified view of the system becomes next to impossible. Moving auditing into the platform layer solved a large part of that problem for us.
A big part of senior engineering work is deciding what not to build. We could have built this into a full-blown CDC solution, with events streamed through Debezium into Kafka — but the use case did not justify it. At our scale, we could afford synchronous audit logging. Our decision to not go the async way saved us the trouble of having to monitor yet another distributed flow that might break in unforeseen ways.
The compile-time adapter generation was probably the most satisfying piece of the system because it eliminated a huge amount of repetitive engineering work while simultaneously enforcing uniformity.
And honestly, that’s usually where platform engineering creates the most leverage:
- reducing cognitive load,
- removing boilerplate,
- and making the preferred path the easiest one.
Wrapping Up
In the end, the interesting part of this project wasn’t really Javers itself. It was just the foundation.
The real value lies in adapting the framework to our domain model. By making audits a first-class citizen in our framework, we made the developer experience simple enough that audit consistency happened as a byproduct rather than through process enforcement.
That is usually the pattern with internal platform work — your biggest wins rarely come from building something flashy. They come from quietly removing friction across dozens of teams, services, and months of engineering effort.
If you’ve built something similar, especially in large microservice ecosystems, I’d love to hear how your tradeoffs compare to ours!
Note: This article first appeared in my Medium blog on 26/05/2026.