Spring Boot for Dummies: Part 2.1 [Persistence Layer]
In this part we will look at persistence in general, that includes database connections, how to use JPA, Data Validations, ORM working, the @Repository
pattern, Spring Profiles etc. So, let's get started. You can read the part 1.2 here.
In part now we added a data structure to store the values, but when we close the application, all data is gone, it is not a good idea to keep data in application memory, because it will eventually be a bottleneck in our app’s performance and capacity. And why do it if we have highly efficient solutions that can do this for us more efficiently. Well, no surprise, they are called databases, as you may have already guessed. In this part, we will explore the third layer of our three-tier architecture (i.e. the persistence layer)
# How Persistence works in Spring
Persistence in Spring Boot is achieved through the use of Spring Data JPA, which is a part of the larger Spring Data project. Spring Data JPA provides a high-level abstraction over the Java Persistence API (JPA) and simplifies the development of the data access layer in a Spring application.
JPA is a specification for accessing, managing, and persisting data between Java objects and relational databases. JPA provides a set of annotations and APIs that simplify the development of data access layers in Java applications.
It provided us with these key features:
- Standardization: JPA is a standard API that provides a common set of interfaces and annotations for working with data persistence. It allows you to write database-agnostic code that can be easily switched between different database vendors.
- Object-Relational Mapping (ORM): JPA eliminates the need for manual mapping between Java objects and database tables. It provides automatic mapping of Java classes to database tables, and vice versa, using annotations or XML configuration. This simplifies the development process and reduces the amount of boilerplate code.
- CRUD Operations: JPA provides high-level APIs for performing CRUD (Create, Read, Update, Delete) operations on database entities. It abstracts away the complexities of SQL queries, allowing you to work with objects instead of raw database operations.
- Querying: JPA includes an object-oriented query language called JPQL (Java Persistence Query Language) that allows you to write database queries using Java objects and their relationships. JPQL queries are database-agnostic and can be easily modified to work with different database vendors.
- Caching: JPA provides caching mechanisms that improve the performance of database operations by reducing the number of database hits. You can configure the level of caching and choose between different caching strategies based on the requirements of your application.
- Transaction Management: JPA supports transaction management, which ensures that multiple database operations are executed atomically, either all succeeding or all failing. It provides transactional semantics, allowing you to define the boundaries of a transaction and handle exceptions in a consistent manner.
Overall, JPA simplifies the development of the data access layer in Spring application by providing a standardized and object-oriented approach to database persistence. It promotes code reusability, maintainability, and portability across different database vendors.
Sounds awesome right!!
Here are some common components in the Persistence Layer:
- Entity Classes: In Spring Boot, persistence starts with defining entity classes. These classes represent the tables in the database and are annotated with
@Entity
to indicate that they are persistent entities. They also define the attributes and relationships between entities using annotations such as@Column
,@Id
,@GeneratedValue
, etc. - Repository Interfaces: Spring Data JPA provides a repository pattern implementation that allows us to perform CRUD (Create, Read, Update, Delete) operations on the database without writing any SQL queries. We define repository interfaces by extending the
JpaRepository
interface or its sub-interfaces. These interfaces provide methods for common database operations and can also define custom queries using the@Query
annotation. - Database Configuration: Spring Boot automatically configures a connection to the database based on the configuration properties specified in the
application.properties
orapplication.yml
file. The connection details such as the database URL, username, password, etc. are provided in the configuration file. Spring Boot also configures a default database connection pool using HikariCP. - JPA EntityManager: The JPA EntityManager is responsible for managing the entity instances and performing CRUD operations on the database. Spring Data JPA uses the
EntityManager
behind the scenes to execute the repository methods. It also handles transactions and provides caching support. - Transaction Management: Spring Boot manages transactions declaratively using the
@Transactional
annotation. By default, Spring Boot creates a new transaction for each repository method and commits the transaction after the method completes. If an exception occurs, the transaction is rolled back.
Overall, Spring Boot simplifies the development of the persistence layer by providing automatic configuration, high-level abstractions, and powerful features like automatic CRUD operations and transaction management. It allows developers to focus on the business logic of their applications without worrying about low-level database operations.
Note: JPA, by its nature, is more tailored toward relational databases and may not be the ideal choice for NoSQL databases due to differences in data models and querying mechanisms. However, Spring Data offers modules for various databases, including both SQL and some NoSQL databases (example Spring Data MongoDB).
Let’s now take a look at how we used to achieve Persistence in Java without JPA and ORM and Then how to add a persistence layer to our application with JPA.
# A world without JPA and ORMs (using Java Data Base Connectivity or JDBC)
Before the introduction of JPA and ORM (Object-Relational Mapping), persisting objects in databases using Java involved writing raw SQL queries and handling database operations manually. Java provided JDBC (Java Database Connectivity) as the standard API for working with databases.
With JDBC, developers had to manually create database connections, write SQL statements, execute queries, handle result sets, and manage transactions. Here’s an example of how data persistence was done using JDBC:
Step 1: Run the docker container with Postgres.
Please download the docker desktop for your OS here. I have added the docker-compose.yml
file under (Project Base Directory) > containers > PostgreSQL directory. This lets us run an instance of the PostgreSQL database in a docker container. (If you have DB on your system already) please feel free to connect to it by replacing DB_URL
. Also, make sure to replace the volume path in docker-compose.yml(git link).
To skip this changing of Paths, I would recommend copying the container’s directory to your project.
# change to PostgrSQL directory
> cd ProjectPath/containers/PostgereSQL
> docker compose up -d
If you have a docker desktop running, you should see PostgreSQL running there.
If you want to connect to DB with command line in docker.
>psql -h localhost -p 5432 -U admin -d simplytodo
Step 2: Add a DAO class (Data Access Object) to interact with the DB.
import com.simplytodo.entity.TodoTask;
import com.simplytodo.enums.TodoTaskStatus;
import com.simplytodo.errors.TodoErrorStatus;
import com.simplytodo.errors.TodoException;
import org.springframework.stereotype.Component;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
//The component annotation is to mark the class to be managed by IOC
@Component
public class TodoTaskDao{
private final String DB_URL = "jdbc:postgresql://localhost:5432/simplytodo";
private final String USERNAME = "admin";
private final String Password = "mypassword";
Connection connection = null;
public TodoTaskDao() throws SQLException {
connection = DriverManager.getConnection(DB_URL, USERNAME, Password)
}
public TodoTask getTask(int id) throws TodoException {
PreparedStatement statement = null;
ResultSet resultSet = null;
try{
statement = connection.prepareStatement("SELECT * FROM todo_task WHERE id = ?");
statement.setInt(1, id);
resultSet = statement.executeQuery();
List<TodoTask> tasks = new ArrayList<>();
while(resultSet.next()){
// map the response (manually)
TodoTask todoTask = new TodoTask();
todoTask.setId(resultSet.getInt("id"));
todoTask.setTitle(resultSet.getString("title"));
todoTask.setDescription(resultSet.getString("description"));
todoTask.setStatus(TodoTaskStatus.valueOf(resultSet.getString("status")));
todoTask.setDueDate(resultSet.getDate("due_date"));
todoTask.setCreatedAt(resultSet.getDate("created_at"));
tasks.add(todoTask);
}
return tasks.size()>0 ? tasks.get(0): null;
}catch (Exception e){
throw new TodoException(TodoErrorStatus.BAD_REQUEST, e.getMessage());
}
}
public List<TodoTask> getAllTasks() throws TodoException {
PreparedStatement statement = null;
ResultSet resultSet = null;
try{
statement = connection.prepareStatement("SELECT * FROM todo_task");
resultSet = statement.executeQuery();
List<TodoTask> tasks = new ArrayList<>();
while(resultSet.next()){
// map the response (manually)
TodoTask todoTask = new TodoTask();
todoTask.setId(resultSet.getInt("id"));
todoTask.setTitle(resultSet.getString("title"));
todoTask.setDescription(resultSet.getString("description"));
todoTask.setStatus(TodoTaskStatus.valueOf(resultSet.getString("status")));
todoTask.setDueDate(resultSet.getDate("due_date"));
todoTask.setCreatedAt(resultSet.getDate("created_at"));
tasks.add(todoTask);
}
return tasks;
}catch (Exception e){
throw new TodoException(TodoErrorStatus.BAD_REQUEST, e.getMessage());
}
}
public void delete(int id) throws TodoException{
PreparedStatement statement = null;
try{
// create the statement
statement = connection.prepareStatement("DELETE FROM todo_task WHERE id = ?");
// set vars
statement.setInt(1, id);
// update the DB
statement.executeUpdate();
}catch (Exception e){
throw new TodoException(TodoErrorStatus.BAD_REQUEST, e.getMessage());
}
}
public TodoTask createOrUpdate(TodoTask todoTask) throws TodoException {
PreparedStatement statement = null;
try{
// create the statement
statement = connection.prepareStatement("INSERT INTO todo_task (title, description, status, due_date, created_at) VALUES (?, ?, ?, ?, ?)");
// set vars
statement.setString(1, todoTask.getTitle());
statement.setString(2, todoTask.getDescription());
statement.setString(3, todoTask.getStatus().toString());
statement.setDate(4,todoTask.getDueDate() !=null? new java.sql.Date(todoTask.getDueDate().getTime()): null);
statement.setDate(5, new java.sql.Date(todoTask.getCreatedAt().getTime()));
// update the DB
statement.executeUpdate();
}catch (Exception e){
throw new TodoException(TodoErrorStatus.BAD_REQUEST, e.getMessage());
}
return todoTask;
}
Now we have a DB container running and code to interact with it. (Obviously, it’s not a good practice to hardcode connection string, username & password). Ideally, you will store it in config and read from it. Also, have some class hierarchy to reuse the code. (But this is good for the sake of demonstration).
We have to update our service class to use this instead of a local HashMap for storage.
import java.util.List;
@Service
public class TodoService {
@Autowired
TodoTaskDao todoTaskDao;
public TodoTask createOrUpdate(TodoTask todoTask) throws TodoException {
return todoTaskDao.createOrUpdate(todoTask);
}
public TodoTask getTask(int id) throws TodoException {
return todoTaskDao.getTask(id);
}
public void delete(int id) throws TodoException {
todoTaskDao.delete(id);
}
public List<TodoTask> getAllTasks() throws TodoException {
return todoTaskDao.getAllTasks();
}
}
Now if you start everything and send the request, you will get an error and that is because we have not added the table yet.
Step 3: Create a table in simplytodo
db.
There are a few ways to do this. We can write some code in Java that checks for a table and if it does not exist, the code will create it for us, or create, it manually by running the following query or using an app like DBeaver (I personally use this for connecting to DB’s for inspect and debug) It’s an excellent tool.
// SQL query to create a table when adding manually or via an app
CREATE TABLE todo_task (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
due_date DATE,
created_at TIMESTAMP NOT NULL
);
Let’s add the Java code to achieve this.
We can add a method like this in our DAO class:
private void CheckAndCreateTable() throws SQLException {
var dbMetaData = this.connection.getMetaData();
var tables = dbMetaData.getTables(null, null, "todo_task", null);
if(!tables.next()){
var createTableStatement = this.connection.prepareStatement("CREATE TABLE todo_task (id SERIAL PRIMARY KEY, title VARCHAR(255), description VARCHAR(255), status VARCHAR(255), due_date DATE, created_at DATE)");
createTableStatement.executeUpdate();
}
}
// also call this method from DAO class Constructor like
public TodoTaskDao() throws SQLException {
connection = DriverManager.getConnection(DB_URL, USERNAME, Password);
this.CheckAndCreateTable();
}
The above code will create the table for us if it does not exist.
Now let’s run the app and see the result.
Let’s verify if the task has been created in DB. (a screenshot from DBeaver).
You can also do GET, DELETE etc.
As you can see, working with JDBC involved a lot of boilerplate code and manual handling of database operations. Developers had to write SQL statements, handle connections, manage transactions, and deal with low-level JDBC APIs.
This was a little tedious and we have not done full error checking as well. Like handling null values, returning the actual object saved in DB transaction management, and much more. Also, the mapping we did from the DB result to the Java object was done by code per field. Imagine that happening for a very large object. It just adds layers of complexity to the persistence layer. You can write some code that uses reflection and inheritance to make it much more reusable and concise and also add some external libraries for Object mapping.
But why reinvent the wheel? Spring Data JPA has everything already implemented for us.
JPA and ORM frameworks like Hibernate simplify the data persistence process by providing higher-level abstractions and automating many of the database operations. They handle the mapping between Java objects and database tables, generate SQL statements, manage transactions, and provide caching and optimization mechanisms.
By using JPA and ORM frameworks, developers can focus more on the business logic of their applications and reduce the amount of boilerplate code required for data persistence.
# Spring Data JPA (Into to Repository Pattern)
We already saw how tedious it can be to add all the logic and checks for persistence in the previous example.
Now let’s use Spring Data JPA, to achieve the same.
Step 1: Add the following dependency to your pom.xml file.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Step.2: Add the connection strings to DB in application.properties
# DB connection
spring.datasource.url=jdbc:postgresql://localhost:5432/simplytodo
spring.datasource.username=admin
spring.datasource.password=mypassword
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop
Note: The last property spring.jpa.hibernate.ddl-auto controls Hibernate’s schema generation tool. When set to create-drop, Hibernate will drop and recreate the database schema every time the application starts up. This can be useful during development and testing, but it is generally not recommended for production use. Read more about it here.
Step 3: Marking the class to store with @Entity
We need to mark the class that we want to store in DB with @Entity annotation. When you mark a class with @Entity
, you are telling JPA that the class is an entity and that its instances should be persisted in the database.
import com.simplytodo.enums.TodoTaskStatus;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Date;
@Data
@Entity
public class TodoTask {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@NotNull(message = "Title cannot be null")
private String title;
private String description;
private TodoTaskStatus status = TodoTaskStatus.NOT_STARTED;
private Date dueDate;
@NotNull(message = "Created date cannot be null")
private Date createdAt = Date.from(java.time.Instant.now());
}
Observe:
@Id
marks that this field is marked as Primary Key.@GeneratedValue
means that its is auto generated and generation type is of type identity.
@NotNull
is part of validation annotations, you can read about them here.
Step 4: Setting up access with @Repository
pattern.
To access the data now we create an Interface let’s name it. TodoTaskRepository
, an indication that it is used for data operations on TodoTask.
import com.simplytodo.entity.TodoTask;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TodoTaskRepository extends JpaRepository<TodoTask, Integer> {
public TodoTask findById(int id);
public void deleteById(int id);
public TodoTask save(TodoTask todoTask);
public List<TodoTask> findAll();
}
Step 5: Using the Repository
We can now update our service class TodoService
to use the repository.
import com.simplytodo.entity.TodoTask;
import com.simplytodo.errors.TodoException;
import com.simplytodo.repository.TodoTaskRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TodoService {
@Autowired
private TodoTaskRepository todoTaskRepository;
public TodoTask createOrUpdate(TodoTask todoTask) throws TodoException {
return todoTaskRepository.save(todoTask);
}
public TodoTask getTask(int id) throws TodoException {
return todoTaskRepository.findById(id);
}
public void delete(int id) throws TodoException {
todoTaskRepository.deleteById(id);
}
public List<TodoTask> getAllTasks() throws TodoException {
return todoTaskRepository.findAll();
}
}
You are probably wondering, where are the queries, we did not even implement methods defined in the TodoTaskRepository interface! Well, the spring does all the magic for us.
Spring Data JPA automatically generates the implementation of the repository for us, including the methods for performing CRUD operations on TodoTask.
Some common methods are:
- findAll() — get a List of all available entities in the database
- findAll(…) — get a List of all available entities and sort them using the provided condition.
- save(…) — save an Iterable of entities. Here, we can pass multiple objects to save them in a batch
- flush() — flush all pending tasks to the database
- saveAndFlush(…) — save the entity and flush changes immediately
- deleteInBatch(…) — delete an Iterable of entities. Here, we can pass multiple objects to delete them in a batch
Furthermore, In addition to the CRUD methods, Spring Data JPA also provides a mechanism for generating query methods based on method names. This means that you can define a method in your repository interface with a specific naming convention, and Spring Data JPA will automatically generate the corresponding query method for you.
For example, if you have an entity with two fields key1 and key2, you can define a method in your repository interface with the following naming convention:
List<MyEntity> findAllByKey1AndKey2(String key1, String key2);
You can also use other keywords such as Like
, NotLike
, GreaterThan
, LessThan
, Between
, etc., to create more complex query methods.
Here is a link to an article covering this in detail.
In Part 2.2 we will look at Data validation, Custom Queries and Spring JDBC. Happy Coding :)