Best Practices For Handling Database in Java

Best Practices for handling Data Access
Introduction

Hello Java developers, accessing the Database is crucial but what are the best practices? In this article, we will dive into the Java database best practices that are essential for every developer aiming to master database interactions and ORM (Object-Relational Mapping) in Java. By adopting these best practices, you’ll enhance not only the performance of your applications but also their maintainability. We’ll cover the key techniques and practices such as abstraction, transaction management, lazy loading, and null handling, which will help you effectively manage common challenges in database access and ORM (Object-Relational Mapping).

1. Using Repository Abstraction

When using frameworks such as Spring, Micronaut, or Quarkus it is recommended to use the Data repository interfaces, such as JpaRepository, which allows for a more abstract approach to database access, leading to cleaner and more maintainable application code.

✅ Good: Interface-based repositories enable a cleaner separation of concerns
public interface UserRepository extends JpaRepository<User, Long> {
  Optional <User> findByEmail(String email);
}
@Service
public class UserService {
    private final UserRepository userRepository;

    @Autowired
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUserByEmail(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new UserNotFoundException("User not found"));
    }
}
Explanation: This is good because it uses the framework Data’s repository abstraction to create a query method that is clear and concise. The findByEmail method follows the framework Data JPA conventions and automatically provides the implementation. The use of Optional is a good practice to avoid null checks and to handle the absence of a result in a clean, expressive way.
⛔ Avoid: Direct use of EntityManager results in error-prone and less manageable code
@Service
public class UserService {

    @Autowired
    private EntityManager entityManager;

    public User getUserByEmail(String email) {
        // Bad: Using EntityManager directly for something repositories could do
        Query query = entityManager.createQuery("SELECT u FROM User u WHERE u.email = :email");

        query.setParameter("email", email);
        return (User) query.getSingleResult();
    }

}
Explanation: This is bad because it bypasses the simplicity and safety provided by repository abstractions. Direct use of EntityManager for this type of query is unnecessary and verbose. It also opens up the potential for JPQL injection if not handled properly and the method does not handle the case when the result is not found, potentially throwing a NoResultException.
2. Transaction Management
✅ Good: Proper use of @Transactional at the service layer
@Service
@Transactional
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(User user) {
        // The transaction is automatically managed by Spring
        return userRepository.save(user);
    }
}
Explanation: This is good because it uses the @Transactional annotation at the service level, which is the recommended way to handle transactions in a Spring application. This ensures that the entire method execution is wrapped in a transactional context, which provides consistency and integrity in the database operations.
⛔ Avoid: Annotating individual repository methods with @Transactional is unnecessary
public interface UserRepository extends JpaRepository<User, Long> {
  @Transactional 
  <extends User S> save(S entity);
}
Explanation: This is bad because adding @Transactional to a method in a repository interface is not necessary since Spring Data repositories are already transactional by nature. Moreover, this can lead to confusion, as transaction management should typically be handled at the service layer, not the repository layer. The service layer often represents business transactions that can span multiple repository calls, requiring a broader transactional context.
3. Handling Lazy Initialization
✅ Good: Load lazy associations within a transaction using Hibernate.initialize()
@Service
public class UserService {
    private final UserRepository userRepository;

    @Transactional(readOnly = true)
    public User getUserWithOrders(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() - new UserNotFoundException("User not found"));
        // Initialize lazy collection
        Hibernate.initialize(user.getOrders());
        return user;
    }
}

Explanation: This is good because it handles the lazy loading within a transactional context, ensuring that the Hibernate.initialize() method can load the lazy collection before the session is closed. The use of  readOnly = true is also good practice for read operations as it can optimize performance.

⛔ Avoid: Accessing a lazily loaded collection outside a transaction can lead to exceptions
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    public User getUserWithOrders(Long userId) {
        User user = userRepository.findById(userId).get();

        // May throw LazyInitializationException
        int orderCount = user.getOrders().size();

        return user;
    }
}
Explanation: This is bad because it attempts to access a lazily loaded collection outside of an open session, which can result in a LazyInitializationException. There’s no @Transactional annotation, meaning that the session may be closed before the lazy collection is accessed.
4. Using Pagination
✅ Good: Implementing pagination with Spring Data’s Pageable
Pagination helps in fetching data in manageable chunks, thus saving resources.
public interface UserRepository extends JpaRepository<User, Long> {
    PageUser findAll(Pageable pageable);
}

@Service
public class UserService {
    private final UserRepository userRepository;

    public Page<User> getUsersWithPagination(int page, int size) {
        Pageable pageable = PageRequest.of(page, size, Sort.by("lastName").ascending());
        return userRepository.findAll(pageable);
    }
}
Explanation: This is good practice because it takes advantage of Spring Data’s built-in support for pagination, which is important for performance and usability when dealing with large datasets. The Pageable parameter encapsulates pagination information and sorting criteria, which the repository infrastructure uses to generate the correct query.
⛔ Avoid: Retrieving all entries can lead to memory and performance issues
@RestController
public class UserController {
    private final UserRepository userRepository;

    @GetMapping("/users")
    public <List> User getAllUsers() {
    }
}    
5. Handling Null Values with Optional

Check out here for more details dealing with null values.

✅ Good: Using orElseThrow() to handle absent values elegantly
@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException("User with id " + id + " not found"));
    }
}
Explanation: This is good because it makes use of Optional, a feature introduced in Java 8, which is designed to provide a better alternative to null. It forces the developer to think about the case when the User might not be found and handle it accordingly, possibly throwing a custom exception.
⛔ Avoid: Using get() without checking if the value is present can cause exceptions
@Service
public class UserService {
    private final UserRepository userRepository;

    public User getUserById(Long id) {
        return userRepository.findById(id).get();
    }
}
Explanation: This is bad because it assumes that the findById method will always return a non-null value, which is not guaranteed. Using get() directly on the Optional returned by findById may throw a NoSuchElementException if the Optional is empty (i.e. if the user is not found). This approach fails to handle the potential absence of a User with the given ID in a clean, safe manner.
Summary
The “Good” examples follow the best practices of Java framework Data JPA and Java, promoting code readability, maintainability, and proper error handling. In contrast, the “Avoid” examples show common pitfalls that can lead to bugs, inefficient database operations, and code that is harder to maintain and understand.

Share This Article

Reddit
LinkedIn
Twitter
Facebook
Telegram
Mezo Code

Mezo Code

Welcome to my technical blog, where I strive to simplify the complexities of technology and provide practical insights. Join me on this knowledge-sharing adventure as we unravel the mysteries of the digital realm together.

All Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Latest Post
Kubernetes Developer Cheat Sheet

Kubernetes Developer Cheat Sheet

This cheat sheet covers the most frequently used kubectl commands that every developer working with Kubernetes should know. 1. Cluster Information kubectl version Displays the

Read More »