In almost every modern Java textbook, data persistence is introduced with a dangerous shortcut. You are told to create an interface, extend JpaRepository<Product, UUID>, and call it a day.
The result? Without even noticing, you have tightly coupled your entire application to Spring Data and Hibernate.
The moment a junior developer leaks Pageable or Specification from Spring into your clean @Service layer, or worse—directly into your REST controllers (as we discussed in Chapter 1)—your clean architecture is officially dead. Your core module now depends on the database infrastructure framework.
If tomorrow you want to switch a high-throughput read-heavy path to raw JDBC, or migrate a specific sub-domain to MongoDB, you will realize your business logic is trapped in a framework prison.
In Chapter 3 of our Evolutionary Architecture series, we are building the Dao API Module. A zero-dependency, pure Java module that strictly defines the contracts for persistence and querying, without knowing a single thing about SQL, Hibernate, or Spring.
1. Separation of Concerns: Split Creation from Updates
A common mistake in generic DAO design is introducing a single save(Entity) method that acts as both an INSERT and an UPDATE (the typical CRUD/Upsert pattern).
While frameworks love this because it hides complexity, it introduces an architectural and performance issue.
When creating a new domain object, it enters the system without an ID, allowing the infrastructure layer (like Hibernate with its modern, time-ordered UUIDv7 generator) to assign it upon persistence. However, when updating an existing entity, the ID is already present.
If you expose a unified .save() method to the outer layers, the underlying framework is forced to run a hidden, expensive SELECT query just to check whether the ID already exists in the database before deciding to execute an INSERT or an UPDATE. By explicitly splitting these actions in our core contract, we remove this infrastructure ambiguity entirely:
public interface CrudDao<E, ID> {
void create(E entity);
void update(E entity);
// Return the object or null. The business layer will decide how to proceed
E loadById(ID id);
void delete(ID id);
}
Unlike standard frameworks that blindly force you to wrap your lookups in Optional, our core interface relies on clean, raw type returns. Optional at the database level is a costly abstraction. It doesn't eliminate missing values; it just masks them behind wrapper objects, eating up your heap allocation and putting unnecessary pressure on the Garbage Collector in high-throughput enterprise systems. If an object is missing, the database layer should return null. It is up to the business context on the invocation side to decide whether that missing record warrants a NotFoundException or signals an entry point for an initialization flow.
2. Type-Safe Pagination and Sorting Without Spring
How do we support enterprise-grade searching, pagination, and sorting without importing org.springframework.data.domain.Pageable?
We define our own lightweight, framework-agnostic parameter objects. To enforce compile-time safety on sorting and prevent malicious SQL injections via arbitrary string sorting parameters, we introduce the SortBy interface:
public interface SortBy {
String getPropertyName();
}
Every domain can now declare its own concrete sorting capabilities using type-safe Java Enums:
public enum ProductSort implements SortBy {
PRICE("price"),
CREATED_AT("createdAt"),
NAME("name");
private final String propertyName;
ProductSort(String propertyName) {
this.propertyName = propertyName;
}
@Override
public String getPropertyName() {
return this.propertyName;
}
}
Using this, our base search query transport object remains incredibly clean, pure, and descriptive:
public abstract class AbstractParams<S extends SortBy> {
private int page;
private int size;
private S sortBy;
private Direction direction = Direction.DESC;
protected AbstractParams(int page, int size) {
this.page = Math.max(page, 0);
this.size = size < 1 ? 10 : size;
}
...
}
3. Defining Domain-Specific Contracts
To tie everything together, we compose these capabilities into a distinct SearchableDao contract and extend it for our specific Aggregate Roots.
public interface SearchableDao<Type, P extends AbstractParams<?>> {
ResultPage<Type> search(P params);
}
Notice how the ProductDao interface lives entirely within the API module, requires zero infrastructure annotations, and can easily be extended with specialized, high-performance business queries that aren't a good fit for standard CRUD, where ProductSearchParams simply extends our AbstractParams<ProductSort>
public interface ProductDao extends
CrudDao<Product, UUID>,
SearchableDao<Product, ProductSearchParams> {
// Explicit business-driven query contract
Product findBySku(String sku);
}
4. Continuous Architecture: The Contract Test Blueprint
The hidden superpower of this architecture is how trivial it makes testing. Because our DAO interfaces in this module are completely clean contracts, they allow us to implement bulletproof Contract Testing later on.
While the dao-api module itself contains zero code execution and thus carries no tests, it defines the absolute baseline behaviors. In the next chapter—when we build the concrete database implementation module—we won't just write random tests for our repositories. Instead, we will construct an abstract test suite containing 15 to 20 foundational scenarios: covering successful creation, updates, constraint violations, optimistic locking behaviors, and sorting correctness.
If someone breaks the data contract during a database migration or framework upgrade, the contract test will fail immediately before the code ever leaves the infrastructure module.
What's Next?
By decoupling our data contracts, our domain module remains pristine, bulletproof, and completely independent. Frameworks are put back where they belong: as external delivery mechanisms.
In the next chapter, we will finally bring in the heavy machinery. We will implement the concrete Infrastructure Database Module, plug in Hibernate and Spring Data JPA under the hood, and look into how to seamlessly map database entities back to our rich domain models without losing encapsulation.
Codebase & Architecture Blueprint
The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the baseline state established in Chapter 3, use the following link:
-
GitHub Repository (Tag:
chapter-03-dao-api): advanced-spring-multimodule
Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.
◀️ Read Chapter 2: Anatomy of the Domain Module
▶️ Read Chapter 4: The Dao Implmentation Module (Coming soon)
Join the Masterclass Journey
This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, subscribe to my Substack Newsletter here!
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.