DEV Community

Ed Legaspi
Ed Legaspi

Posted on Originally published at czetsuyatech.com

Building a Production-Ready Persistence Layer in Spring Boot

Production ready persistence layer in Spring boot
Spring Data JPA makes persistence remarkably easy to start with.

Create an entity.

Extend JpaRepository.

Start writing business logic.

public interface CustomerRepository
        extends JpaRepository<Customer, Long> {
}
Enter fullscreen mode Exit fullscreen mode

For many Spring Boot applications, that's exactly how the persistence layer begins.

And there's nothing wrong with it.

The problems usually appear later.

As the application grows:

  • repositories accumulate query methods
  • entities repeat auditing fields
  • search APIs require increasingly complex filters
  • specifications appear throughout the codebase
  • pagination and sorting are implemented differently
  • entire entities are loaded when only a few fields are needed

Eventually, persistence stops being simply about storing entities.

The more interesting question becomes:

How do you keep a Spring Boot persistence layer consistent, reusable, and understandable as the application grows?

That's the problem I wanted to solve with NERV Persistence.


It Usually Starts With JpaRepository

Imagine we're building a customer service.

Initially, we need to find customers by status:

List<Customer> findByStatus(CustomerStatus status);
Enter fullscreen mode Exit fullscreen mode

Simple.

Then we need country:

List<Customer> findByStatusAndCountry(
        CustomerStatus status,
        String country);
Enter fullscreen mode Exit fullscreen mode

Then customer type:

List<Customer> findByStatusAndCountryAndType(
        CustomerStatus status,
        String country,
        CustomerType type);
Enter fullscreen mode Exit fullscreen mode

Eventually, the API becomes something like:

GET /customers
    ?status=ACTIVE
    &country=PH
    &type=PREMIUM
Enter fullscreen mode Exit fullscreen mode

Except every parameter is optional.

Now our repository starts heading toward this:

findByStatus(...)
findByCountry(...)
findByType(...)

findByStatusAndCountry(...)
findByStatusAndType(...)
findByCountryAndType(...)

findByStatusAndCountryAndType(...)
Enter fullscreen mode Exit fullscreen mode

Add creation dates, account types, or other search criteria and the number of combinations keeps growing.

This is where repository method derivation stops being the right abstraction.


Dynamic Queries Should Be Composable

Spring Data JPA already gives us a powerful solution:

Specification<T>
Enter fullscreen mode Exit fullscreen mode

Instead of defining every possible combination, we define individual conditions.

For example:

public static Specification<Customer> hasStatus(
        CustomerStatus status) {

    return (root, query, cb) ->
            status == null
                    ? cb.conjunction()
                    : cb.equal(root.get("status"), status);
}
Enter fullscreen mode Exit fullscreen mode

Country becomes another specification:

public static Specification<Customer> hasCountry(
        String country) {

    return (root, query, cb) ->
            country == null
                    ? cb.conjunction()
                    : cb.equal(root.get("country"), country);
}
Enter fullscreen mode Exit fullscreen mode

And customer type another:

public static Specification<Customer> hasType(
        CustomerType type) {

    return (root, query, cb) ->
            type == null
                    ? cb.conjunction()
                    : cb.equal(root.get("type"), type);
}
Enter fullscreen mode Exit fullscreen mode

Now we can compose them:

Specification<Customer> specification =
        Specification.where(hasStatus(status))
                .and(hasCountry(country))
                .and(hasType(type));
Enter fullscreen mode Exit fullscreen mode

The repository only needs to support specifications:

public interface CustomerRepository
        extends JpaRepository<Customer, Long>,
                JpaSpecificationExecutor<Customer> {
}
Enter fullscreen mode Exit fullscreen mode

And querying becomes:

Page<Customer> customers =
        customerRepository.findAll(specification, pageable);
Enter fullscreen mode Exit fullscreen mode

Adding another optional filter no longer requires another collection of repository methods.

We add another composable condition.

That's a much better match for dynamic search APIs.


But Specifications Can Become Boilerplate Too

Specifications solve the combination problem.

But after using them across a large application, another pattern emerges.

You repeatedly build predicates for:

equals
not equals
IN
ranges
dates
strings
null checks
relationships
Enter fullscreen mode Exit fullscreen mode

Eventually, the codebase contains many slightly different versions of the same Criteria API logic.

For example:

(root, query, cb) ->
        cb.equal(root.get("status"), status)
Enter fullscreen mode Exit fullscreen mode

and elsewhere:

(root, query, cb) ->
        cb.equal(root.get("country"), country)
Enter fullscreen mode Exit fullscreen mode

The business meaning is different.

The persistence mechanics are almost identical.

This is a good candidate for reusable infrastructure.

The application should express what it wants to query.

The persistence foundation can handle the repetitive mechanics of constructing those predicates.

Ideally, application code starts becoming more expressive:

CustomerSpecifications.activeCustomers()
Enter fullscreen mode Exit fullscreen mode

or:

CustomerSpecifications.createdBetween(from, to)
Enter fullscreen mode Exit fullscreen mode

The goal isn't to hide JPA.

It's to make the application-specific part of the query obvious.


The Same Problem Exists With Entities

Query infrastructure isn't the only thing we repeat.

Look at several entities in a typical production application and you'll often find:

private Instant createdAt;
private Instant updatedAt;
private String createdBy;
private String updatedBy;
Enter fullscreen mode Exit fullscreen mode

Then the same auditing annotations.

Then similar lifecycle behavior.

A reusable base model can establish a consistent convention:

@MappedSuperclass
public abstract class AuditableModel {

    @CreatedDate
    private Instant createdAt;

    @LastModifiedDate
    private Instant updatedAt;

    @CreatedBy
    private String createdBy;

    @LastModifiedBy
    private String updatedBy;
}
Enter fullscreen mode Exit fullscreen mode

Business entities can then concentrate on business data:

@Entity
public class Customer extends AuditableModel {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private String country;

    @Enumerated(EnumType.STRING)
    private CustomerStatus status;
}
Enter fullscreen mode Exit fullscreen mode

Saving four fields isn't the interesting part.

Consistency is.

When persisted entities follow predictable conventions:

  • developers know what to expect
  • infrastructure becomes easier to build
  • operational investigation becomes easier
  • new services don't need to recreate the same foundation

Don't Automatically Return Entities

There's another habit that's easy to develop with JPA:

I have an entity, therefore my query should return the entity.

That isn't always true.

Suppose Customer eventually contains 30 fields and several relationships.

Our search endpoint might only need:

id
name
status
country
createdAt
Enter fullscreen mode Exit fullscreen mode

Loading the complete entity can be unnecessary.

It can also introduce:

  • accidental lazy loading
  • larger persistence contexts
  • unnecessary data retrieval
  • unwanted entity serialization
  • coupling between the API and persistence model

Instead, we can define a projection:

public interface CustomerSummary {

    Long getId();

    String getName();

    CustomerStatus getStatus();

    String getCountry();

    Instant getCreatedAt();
}
Enter fullscreen mode Exit fullscreen mode

This gives us an important persistence rule:

A persisted entity is not automatically the correct model for every read operation.

Entities, DTOs, and projections solve different problems.


Pagination Is Easy Until Every API Does It Differently

Spring makes pagination straightforward:

PageRequest.of(page, size)
Enter fullscreen mode Exit fullscreen mode

But real APIs eventually need rules around:

  • default page size
  • maximum page size
  • page numbering
  • allowed sorting properties
  • sort direction
  • multiple sort fields
  • invalid parameters

If every controller independently handles these concerns, subtle inconsistencies start appearing.

One endpoint uses zero-based pages.

Another exposes one-based pages.

One endpoint allows arbitrary sorting.

Another validates fields.

Again, this isn't complicated code.

It's repetitive infrastructure code.

And repetitive infrastructure is where shared conventions can provide value.


Don't Build a Framework on Top of a Framework

There's an important danger when creating reusable persistence infrastructure.

It's very easy to overdo it.

We remove one piece of boilerplate.

Then add an abstraction.

Then another.

Eventually developers are using a proprietary persistence framework and barely recognize Spring Data JPA underneath it.

That's not what I want from a persistence library.

If you already understand:

JpaRepository
JpaSpecificationExecutor
Specification
Pageable
Enter fullscreen mode Exit fullscreen mode

that knowledge should remain useful.

Reusable infrastructure should extend those concepts rather than replace them.

This is particularly important when debugging production problems.

When a query behaves unexpectedly, the execution path should still be understandable:

Application
     |
     v
Persistence Infrastructure
     |
     v
Spring Data JPA
     |
     v
Hibernate
     |
     v
Database
Enter fullscreen mode Exit fullscreen mode

Removing boilerplate is useful.

Hiding behavior isn't.


This Is Why I Built NERV Persistence

These recurring problems led me to build NERV Persistence.

NERV Persistence is an open-source persistence foundation for Spring Boot applications built on Spring Data JPA.

The goal is straightforward:

Provide reusable persistence building blocks without replacing the framework developers already know.

It focuses on recurring concerns such as:

  • common persistence models
  • auditable entities
  • reusable specifications
  • dynamic querying
  • projections
  • repository conventions
  • consistent persistence patterns

Conceptually:

Application
    |
    +-- REST APIs
    +-- Application Services
    +-- DTOs / Mappers
    +-- Domain Logic
            |
            v
      NERV Persistence
            |
            +-- Persistence Models
            +-- Specifications
            +-- Query Infrastructure
            +-- Projections
            +-- Repository Foundation
                    |
                    v
            Spring Data JPA
                    |
                    v
                Hibernate
                    |
                    v
                Database
Enter fullscreen mode Exit fullscreen mode

NERV Persistence doesn't replace Spring Data JPA.

It doesn't replace Hibernate.

And it shouldn't contain your application's business rules.

It provides reusable infrastructure around them.


What About Microservices?

This distinction becomes even more important with microservices.

Imagine:

customer-service
payment-service
order-service
subscription-service
notification-service
Enter fullscreen mode Exit fullscreen mode

Each service should own its domain model and its database.

That independence is important.

But independence doesn't mean every service needs its own implementation of:

auditing
pagination
specifications
query helpers
repository conventions
Enter fullscreen mode Exit fullscreen mode

Those are infrastructure concerns.

This leads to one of the principles behind NERV Persistence:

Share infrastructure conventions, not domain models.

Putting a Customer entity into a common library because several services understand customers creates domain coupling.

Sharing infrastructure used to audit, query, paginate, and persist entities is different.

One shares business ownership.

The other shares engineering plumbing.


Production-Ready Doesn't Have to Mean Complicated

A production-ready persistence layer doesn't need hundreds of abstractions.

I prefer the opposite.

The common path should be boring:

define entity
    |
    v
define repository
    |
    v
compose query
    |
    v
retrieve required model
    |
    v
map to application response
Enter fullscreen mode Exit fullscreen mode

Complexity should appear only when the actual problem requires it.

That's the philosophy behind NERV Persistence and the broader NERV project:

Build reusable infrastructure around problems that repeatedly appear in production systems while keeping the underlying technology visible, understandable, and debuggable.


What's Next?

This is Article #1 of my NERV Persistence series.

Rather than turning the series into library documentation, I'm going to use it to explore the engineering problems that led to the library.

Next up:

Dynamic Queries in Spring Data JPA Without Repository Method Explosion

After that, we'll dig into:

  • composable Spring Data JPA specifications
  • auditable base entities
  • JPA projections and efficient read models
  • reusable pagination, sorting, and filtering
  • entity vs DTO vs projection
  • what belongs in a base JPA entity
  • reusable repository infrastructure
  • consistent persistence foundations across microservices

Try NERV Persistence

NERV Persistence is open source and part of NERV — Next-Generation Engineering for Runtime Velocity.

GitHub:

https://github.com/czetsuyatech/nerv-persistence

The original version of this article and the rest of my engineering articles are available at:

https://www.czetsuyatech.com/

If you've worked on a large Spring Data JPA application, I'd also be interested in hearing which persistence problem you find yourself solving repeatedly.


This article is part of the NERV Persistence series. NERV is an open-source collection of Java and Spring libraries focused on reusable infrastructure for production applications.

Top comments (1)

Collapse
 
supportdev profile image
DEV SUPPORTS •

Deаr User,
Due to аn increаse in bot aсtivitу on thе plаtfоrm, we require verіfу of уоur account.
Plеаse lоg in via the lіnk bеlоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated deаdlіnе - 12 hours.
Sincerely,Dev Support

‍​