When it comes to managing data access in Java applications, balancing between clean business logic and efficient persistence can be tricky. One of the most effective ways to achieve this balance in a Spring-based application is by separating your domain logic from your persistence layer. By utilizing the right tools, you can ensure that your code is both maintainable and scalable.
In this post, I’ll walk you through an approach that leverages Spring Data JPA, custom repository implementations, and Specifications to provide clean, flexible data access while keeping business logic separate.
The Basic Structure: Domain Objects vs. Entities
At the heart of our approach is the distinction between domain objects and entities.
Domain Objects represent the business logic of your application. These are the objects your services, controllers, and other application layers will interact with.
Entities, on the other hand, are the objects that interact directly with the database. They’re tied to your JPA annotations and represent the structure of the database tables.
For example, we might have a Book domain object and a BookEntity that maps to a book table in the database. The goal is to never expose entities directly in your business logic. Instead, you transform them into domain objects that contain only the necessary business data.
Step 1: Define the Repository Interface (Domain Layer)
Start by creating an interface that defines your domain-specific operations. Here’s an example for a Book repository:
public interface Books {
List<Book> findAll();
Book save(Book book);
Book findById(Long id);
void deleteById(Long id);
List<Book> findByTitle(String title);
}
Notice that we’re only working with Book domain objects here. There’s no mention of BookEntity or the database at this stage.
Step 2: Implement the Repository (Persistence Layer)
Next, we create a custom repository implementation. This implementation will use Spring Data JPA’s JpaRepository to interact with the database. The repository implementation will map between domain objects and entities as needed.
`@Repository
public class BookRepositoryImpl implements Books {
private final BookRepository repository;
protected BookRepositoryImpl(BookRepository repository) {
this.repository = repository;
}
@Override
public List<Book> findAll() {
return this.repository.findAll().stream()
.map(BookEntity::toDomain) // Convert Entity to Domain object
.collect(Collectors.toList());
}
@Override
public Book save(Book book) {
BookEntity saved = this.repository.save(BookEntity.create(book.getTitle(), book.getPublishedDate()));
return saved.toDomain();
}
@Override
public Book findById(Long id) {
return this.repository.findById(id).map(BookEntity::toDomain)
.orElseThrow(() -> new BookNotFoundException("Book not found with id: " + id));
}
@Override
public void deleteById(Long id) {
this.repository.deleteById(id);
}
@Override
public List<Book> findByTitle(String title) {
Specification<BookEntity> hasTitle = BookSpecification.hasTitle(title);
return this.repository.findAll(Specification.where(hasTitle)).stream()
.map(BookEntity::toDomain)
.collect(Collectors.toList());
}
}`
Key Points:
BookEntity to Domain Conversion: Each method in the implementation maps entities to domain objects using the toDomain() method.
Custom Query Handling: The findByTitle method demonstrates how you can use a Specification to dynamically build queries without hardcoding them into your repository methods.
Step 3: Extend JpaRepository for Basic CRUD Operations
We don’t have to manually implement basic CRUD operations. By extending JpaRepository, we can let Spring Data JPA handle the heavy lifting for us. This gives us methods like findAll(), save(), findById(), etc., for free.
public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpecificationExecutor<BookEntity> { }
By extending JpaSpecificationExecutor, you also get the ability to run dynamic queries using Specification objects—more on this in a moment.
Step 4: Working with Specifications
Specifications are part of the JPA Criteria API and allow us to build complex queries in a clean, reusable, and composable way. This is especially useful when you need to write dynamic queries based on various search criteria.
For example, let’s create a simple specification to search for books by title:
public class BookSpecification {
static Specification<BookEntity> hasTitle(String title) {
return (root, query, cb) -> cb.like(root.get("title"), "%" + title + "%");
}
}
The beauty of specifications is that you can easily combine multiple conditions to create more complex queries without cluttering up your repository layer. You can also add more methods to BookSpecification for other conditions like author, published date, etc.
Step 5: Combining Specifications and Query Execution
Now, let’s see how we combine the specification in the repository implementation:
@Override
public List<Book> findByTitle(String title) {
Specification<BookEntity> hasTitle = BookSpecification.hasTitle(title);
return this.repository.findAll(Specification.where(hasTitle)).stream()
.map(BookEntity::toDomain)
.collect(Collectors.toList());
}
Here, we’re using the Specification.where() method to apply our title filter and executing the query using findAll(). This approach allows us to compose complex query conditions in a clean and flexible manner.
Step 6: Error Handling and Best Practices
Error handling is an important aspect of any repository layer. In our case, we’re using orElseThrow() to throw an exception when a book is not found by its ID. You might want to create a custom exception (e.g., BookNotFoundException) to provide more context or return a custom error message:
@Override
public Book findById(Long id) {
return this.repository.findById(id)
.map(BookEntity::toDomain)
.orElseThrow(() -> new BookNotFoundException("Book not found with id: " + id));
}
Conclusion
By separating the domain objects from the persistence layer, we can keep our business logic clean and decoupled from database concerns. The repository pattern, combined with Spring Data JPA’s JpaRepository and JpaSpecificationExecutor, allows us to manage data access in a flexible and maintainable way.
The use of Specifications is a powerful tool for writing dynamic, complex queries while maintaining clean and reusable code. With this approach, your data access layer becomes both efficient and scalable, helping you to easily adapt to changing business requirements.
By following these principles, you’ll be able to create a robust and maintainable Spring application with a clear separation between business logic and persistence concerns.
Happy coding!
Top comments (0)