Building a Production-Ready Persistence Layer in Spring Boot
DEV Community

Building a 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 { } 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 findByStatus(CustomerStatus status); Simple. Then we need country: List findByStatusAndCountry( CustomerStatus status, String country); Then customer type: List findByStatusAndCountryAndType( CustomerStatus status, String country, CustomerType type); Eventually, the API becomes something like: GET /customers ?status=ACTIVE &country=PH &type=PREMIUM Except every parameter is optional. Now our repository starts heading toward this: findByStatus(...) findByCountry(...) findByType(...) findByStatusAndCountry(...) findByStatusAndType(...) findByCountryAndType(...) findByStatusAndCountryAndType(...) 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 Instead of defining every possible combination, we define individual conditions. For example: public static Specification hasStatus( CustomerStatus status) { return (root, query, cb) -> status == null ? cb.conjunction() : cb.equal(root.get("status"), status); } Country becomes another specification: public static Specification hasCountry( String country) { return (root, query, cb) -> country == null ? cb.conjunction() : cb.equal(root.get("country"), country); } And customer type another: public static Specification hasType( CustomerType type) { return (root, query, cb) -> type == null ? cb.conjunction() : cb.equal(root.get("type"), type); } Now we can compose them: Specification specification = Specification.where(hasStatus(status)) .and(hasCountry(country)) .and(hasType(type)); The repository only needs to support specifications: public interface CustomerRepository extends JpaRepository , JpaSpecificationExecutor { } And querying becomes: Page customers = customerRepository.findAll(specification, pageable); 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 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) and elsewhere: (root, query, cb) -> cb.equal(root.get("country"), country) 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() or: CustomerSpecifications.createdBetween(from, to) 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; 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; } 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; } 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 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(); } 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) 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 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 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 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 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 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

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.