Spring Boot and Hibernate CRUD Example

Learn to build a robust Spring Boot application with Hibernate, covering CRUD operations, paging, filtering, caching, and best practices.

Spring Boot

In this Spring Boot CRUD application with Hibernate, we will explore the essential steps for creating REST APIs in a Spring Boot project, integrating Hibernate for data retrieval and persistence, and implementing CRUD operations to manage data seamlessly. Additionally, we will dive into related concepts such as implementing caching, transactions, logging, and unit testing the web and DAO layers.

1. What will we learn?

Here are the things we will cover in this tutorial:

  • Initial configuration and dependencies for a Spring Boot application.
  • Writing REST APIs to handle client requests and sending responses.
  • Configuring Hibernate for ORM and database interactions.
  • Defining JPA entities and mapping them to database tables.
  • Using Spring Data JPA repositories for CRUD operations.
  • Implement Caching in database operations.
  • Building service layers to handle business logic and data manipulation.
  • Adding bean validation to enforce data integrity rules.
  • Writing and executing unit and integration tests for your CRUD functionality.

This demo’s purpose is to showcase the nuts and bolts that make this interaction possible, not to cover the complexity of business logic involved in real-world applications.

2. Maven

In this example, we use Maven to add runtime jars to the project. If you are using Gradle, please use related dependencies.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.3.3</version>
  <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
  <java.version>21</java.version>
</properties>

<dependencies>
  
  <!-- Web support for REST APIs -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <!-- Spring Data support for persistence -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>

  <!-- Data Caching Support -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
  </dependency>
  <dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <classifier>jakarta</classifier>
  </dependency>

  <!-- Connection Pool Support -->
  <dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
  </dependency>

  <!-- H2 database in development environment -->
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
  </dependency>

  <!-- Bean Validation -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>

  <!-- Add Lombok, DevTools and other dependencies as necessary -->
</dependencies>

3. Configuring Persistence with Hibernate

We will start by adding the data model and CRUD operations to the database using Hibernate and Spring Data repositories. The first step of working with data is to model its structure in the JPA entity class and create a repository interface that provides the CRUD methods.

3.1. Entity

In the entity class, remember to include only JPA API annotations (jakarta.persistence.*) to de-couple hibernate from the application code.

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "persons")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PersonEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false)
  private String firstName;

  @Column(nullable = false)
  private String lastName;

  @Column(nullable = false, unique = true)
  private String email;
}

Whenever possible, we should extend the repository interface from the JpaRepository interface to allow the creation of repository implementations automatically, at runtime, for any given entity class.

3.2. Repository Interface

The JpaRepository interface extends all important interfaces, such as the ListCrudRepository, ListPagingAndSortingRepository, and QueryByExampleExecutor, so we can use any method these interfaces provide.

For example, in the following code, PersonRepository has a custom method to find all the Person records by applying filters on the firstName, lastName, and email fields.

import com.howtodoinjava.demo.data.entity.PersonEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

@Repository
public interface PersonRepository extends JpaRepository<PersonEntity, Long> {

  @Query("SELECT p FROM PersonEntity p WHERE "
      + "(:firstName IS NULL OR LOWER(p.firstName) LIKE LOWER(CONCAT('%', :firstName, '%'))) "
      + "AND (:lastName IS NULL OR LOWER(p.lastName) LIKE LOWER(CONCAT('%', :lastName, '%'))) "
      + "AND (:email IS NULL OR LOWER(p.email) LIKE LOWER(CONCAT('%', :email, '%')))")
  Page<PersonEntity> findByFilters(String firstName, String lastName, String email, Pageable pageable);
}

3.3. DataSource Configuration

To connect to the database, we must configure the datasource. We are using H2 database so respective properties are used. Please feel free to connect to another database and configure the specific behavior.

Also, we have used a couple of more properties to enable H2 console and extensive logging.

spring.application.name=spring-boot-crud-with-hibernate

# H2 Database configuration
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Connection Pool
spring.datasource.hikari.maximum-pool-size=10

# Hibernate settings
spring.jpa.hibernate.ddl-auto=update

# Enable SQL logging
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

# Caching
spring.cache.jcache.config=classpath:ehcache.xml

3.4. Connecting Pool, Transaction and Caching Configurations

At this step, we should be very clear on the transactions and caching-related requirements for the specified entities and CRUD operations. We have added a bare minimum configuration for connection pooling (in the application.properties file), caching (using ehcache), and transaction management.

For example, connection pooling is configured as:

# Connection Pool
spring.datasource.hikari.maximum-pool-size=10

Transaction management is configured as:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
public class TransactionConfig {

  @Bean
  public DataSourceTransactionManager transactionManager(DataSource dataSource) {
    return new DataSourceTransactionManager(dataSource);
  }
}

The caching is enabled as follows. The complete configuration for caching can be read from the linked article.

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.jcache.JCacheCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.cache.Caching;

@Configuration
@EnableCaching
public class CacheConfig {

  @Bean
  public JCacheCacheManager cacheManager() {

    JCacheCacheManager cacheManager = new JCacheCacheManager();
    cacheManager.setCacheManager(Caching.getCachingProvider().getCacheManager());
    cacheManager.setTransactionAware(true); // Enable transaction awareness
    return cacheManager;
  }
}

3.5. Unit Testing

Finally, we can write a few unit tests to verify that the data access setup has been done correctly.

import com.howtodoinjava.demo.data.entity.PersonEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@Transactional
class PersonRepositoryTest {

  @Autowired
  private PersonRepository personRepository;

  private PersonEntity personEntity;

  @BeforeEach
  void setUp() {
    PersonEntity person = new PersonEntity(null, "John", "Doe", "[email protected]");
    personEntity = personRepository.save(person);
  }

  @Test
  void testFindById() {
    Optional<PersonEntity> foundPerson = personRepository.findById(personEntity.getId());
    assertThat(foundPerson).isPresent();
    assertThat(foundPerson.get().getFirstName()).isEqualTo("John");
  }

  @Test
  void testSave() {
    PersonEntity newPerson = new PersonEntity(null, "Jane", "Doe", "[email protected]");
    personRepository.save(newPerson);
    assertThat(personRepository.findAll()).hasSize(2);
  }
}

4. Writing the Service Class

The service layer is optional, but it is still recommended to perform additional business logic, such as copying data between PersonVO and PersonEntity classes. Generally, we will connect with repository here for CRUD operations.

The PersonVO class is used in the presentation layer, and it helps us prevent the PersonEntity class from exposing to the outer world. This also helps in modeling the entity and VO classes according to specific needs to presentation and persistence needs.

import com.howtodoinjava.demo.data.PersonRepository;
import com.howtodoinjava.demo.data.entity.PersonEntity;
import com.howtodoinjava.demo.exception.ResourceNotFoundException;
import com.howtodoinjava.demo.model.PersonVO;
import org.springframework.beans.BeanUtils;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;

@Service
public class PersonService {

  private final PersonRepository personRepository;

  public PersonService(PersonRepository personRepository) {
    this.personRepository = personRepository;
  }
  
  // Fetch all persons
  public Page<PersonVO> getAllPersons(
      String firstName, String lastName, String email, Pageable pageable) {
    
    return personRepository.findByFilters(firstName, lastName, email, pageable)
        .map(this::convertEntityToVo);
  }

  // Fetch person by id with caching
  @Cacheable(cacheNames = "persons", key = "#id")
  public Optional<PersonVO> getPersonById(Long id) {
    
    return personRepository.findById(id).map(this::convertEntityToVo);
  }

  // Add a new person
  public PersonVO addPerson(PersonVO personVo) {
    
    PersonEntity entity = convertVoToEntity(personVo);
    entity = personRepository.save(entity);
    return convertEntityToVo(entity);
  }

  // Update person by id
  @Transactional
  public PersonVO updatePerson(Long id, PersonVO updatedPerson) {
    
    return personRepository.findById(id)
        .map(entity -> {
          BeanUtils.copyProperties(updatedPerson, entity, "id");
          entity = personRepository.save(entity);  // Save and reassign for clarity
          return convertEntityToVo(entity);
        })
        .orElseThrow(() -> new ResourceNotFoundException("Person not found with id: " + id));
  }

  // Delete person by id
  public void deletePerson(Long id) {
    
    if (!personRepository.existsById(id)) {
      throw new ResourceNotFoundException("Person not found with id: " + id);
    }
    personRepository.deleteById(id);
  }

  private PersonVO convertEntityToVo(PersonEntity entity) {
    
    PersonVO vo = new PersonVO();
    BeanUtils.copyProperties(entity, vo);
    return vo;
  }

  private PersonEntity convertVoToEntity(PersonVO vo) {
    
    PersonEntity entity = new PersonEntity();
    BeanUtils.copyProperties(vo, entity);
    return entity;
  }
}

At this step, we can write a few unit tests as well for the service class.

import com.howtodoinjava.demo.data.PersonRepository;
import com.howtodoinjava.demo.data.entity.PersonEntity;
import com.howtodoinjava.demo.model.PersonVO;
import com.howtodoinjava.demo.service.PersonService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

public class PersonServiceTest {

  @InjectMocks
  private PersonService personService;

  @Mock
  private PersonRepository personRepository;

  @BeforeEach
  public void setup() {
    MockitoAnnotations.initMocks(this);
  }

  @Test
  public void testGetAllPersonsWithPagingAndFiltering() {
    Pageable pageable = PageRequest.of(0, 10);
    List<PersonEntity> persons = List.of(
        new PersonEntity(1L, "John", "Doe", "[email protected]"),
        new PersonEntity(2L, "Jane", "Doe", "[email protected]")
    );

    Page<PersonEntity> personPage = new PageImpl<>(persons, pageable, persons.size());

    when(personRepository.findByFilters("John", "Doe", null, pageable)).thenReturn(personPage);

    Page<PersonVO> result = personService.getAllPersons("John", "Doe", null, pageable);

    assertEquals(2, result.getTotalElements());
    assertEquals("John", result.getContent().getFirst().getFirstName());
  }
}

5. Spring Boot REST Controller

Finally, expose all operations to the clients through MVC URLs or REST endpoints. Clients will connect with these endpoints to get, update, or delete the records.

Notice the usage of annotations @RestController, @RequestMapping, @GetMapping, @PostMapping and @DeleteMapping to map various URIs to controller methods.

import com.howtodoinjava.demo.exception.ResourceNotFoundException;
import com.howtodoinjava.demo.model.PersonVO;
import com.howtodoinjava.demo.service.PersonService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/persons")
public class PersonController {

  private final PersonService personService;

  public PersonController(PersonService personService) {
    this.personService = personService;
  }

  @GetMapping
  public ResponseEntity<Page<PersonVO>> getAllPersons(
      @RequestParam(required = false) String firstName,
      @RequestParam(required = false) String lastName,
      @RequestParam(required = false) String email,
      @RequestParam(defaultValue = "0") int page,
      @RequestParam(defaultValue = "10") int size,
      @RequestParam(defaultValue = "id,asc") String[] sort) {

    Pageable pageable = PageRequest.of(page, size, Sort.by(parseSortParameters(sort)));
    Page<PersonVO> personsPage = personService.getAllPersons(firstName, lastName, email, pageable);
    return ResponseEntity.ok(personsPage);
  }

  private Sort.Order parseSortParameters(String[] sortParams) {

    String property = sortParams[0];
    Sort.Direction direction = sortParams.length > 1
        && sortParams[1].equalsIgnoreCase("desc")
        ? Sort.Direction.DESC : Sort.Direction.ASC;
    return new Sort.Order(direction, property);
  }

  @GetMapping("/{id}")
  public ResponseEntity<PersonVO> getPersonById(@PathVariable Long id) {

    return personService.getPersonById(id)
        .map(ResponseEntity::ok)
        .orElseGet(() -> ResponseEntity.notFound().build());
  }

  @PostMapping
  public ResponseEntity<PersonVO> addPerson(@Valid @RequestBody PersonVO person) {

    PersonVO createdPerson = personService.addPerson(person);
    return ResponseEntity.ok(createdPerson);
  }

  @PutMapping("/{id}")
  public ResponseEntity<PersonVO> updatePerson(
      @PathVariable Long id, @Valid @RequestBody PersonVO updatedPerson) {

    try {
      PersonVO person = personService.updatePerson(id, updatedPerson);
      return ResponseEntity.ok(person);
    } catch (ResourceNotFoundException e) {
      return ResponseEntity.notFound().build();
    }
  }

  @DeleteMapping("/{id}")
  public ResponseEntity<Void> deletePerson(@PathVariable Long id) {

    personService.deletePerson(id);
    return ResponseEntity.noContent().build();
  }
}

Notice the usage of bean validation annotations along with @RequestBody annotation. It will help sanitize the input request body as soon as it reaches the server. When a request body is not valid (missing or invalid field values) then the validation framework will throw the MethodArgumentNotValidException.

We will catch all the validation exceptions in the exception handler class and return a unified error message for all kinds of validation errors. The next section discusses this.

6. Exception Handling

A robust exception-handling mechanism is essential for any production-grade application. We will also add a global exception handler using the Problem-detail specification.

public class ResourceNotFoundException extends RuntimeException {

  public ResourceNotFoundException(String message) {
    super(message);
  }
}
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@ControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(ResourceNotFoundException.class)
  public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {

    ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
    problemDetail.setTitle("Resource Not Found");
    problemDetail.setDetail(ex.getMessage());
    problemDetail.setProperty("timestamp", LocalDateTime.now());
    return problemDetail;
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ProblemDetail handleValidationException(MethodArgumentNotValidException ex) {

    ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problemDetail.setTitle("Validation Errors");

    Map<String, List<String>> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
        .collect(Collectors.groupingBy(
            FieldError::getField,
            Collectors.mapping(FieldError::getDefaultMessage, Collectors.toList())
        ));

    problemDetail.setDetail("Validation failed for one or more fields.");
    problemDetail.setProperty("fieldErrors", fieldErrors);
    problemDetail.setProperty("timestamp", LocalDateTime.now());
    return problemDetail;
  }

  // Handle all other uncaught exceptions
  @ExceptionHandler(Exception.class)
  public ProblemDetail handleGlobalException(Exception ex) {

    ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    problemDetail.setTitle("Internal Server Error");
    problemDetail.setDetail("An unexpected error occurred.");
    problemDetail.setProperty("timestamp", LocalDateTime.now());
    return problemDetail;
  }
}

we can test the exception handling logic as well using a unit test.

import com.howtodoinjava.demo.service.PersonService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(PersonController.class)
class PersonControllerTest {

  @Autowired
  private MockMvc mockMvc;

  @MockBean
  private PersonService personService;

  @Test
  void testAddPersonWithInvalidData() throws Exception {
    String invalidPersonJson = """
        {
            "firstName": "",
            "lastName": "D",
            "email": "invalidemail"
        }
        """;

    mockMvc.perform(post("/api/persons")
            .contentType(MediaType.APPLICATION_JSON)
            .content(invalidPersonJson))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.title").value("Validation Errors"))
        .andExpect(jsonPath("$.fieldErrors.firstName", containsInAnyOrder(
            "First name is required",
            "First name should be between 3 and 20 characters"
        )))
        .andExpect(jsonPath("$.fieldErrors.lastName[0]").value("Last name should be between 3 and 20 characters"))
        .andExpect(jsonPath("$.fieldErrors.email[0]").value("Email is invalid"));
  }
}

7. What’s Next?

So far we have added a minimum working functionality in this application that is generally needed in a production class application. There are so many things that should be added or configured with advanced options such as:

  • Application Logging – We should add informative logs in the classes and a log-configuration file that controls the logs generated at various levels.
  • Security: Add security specific to application requirements, such as method-level security in the service layer.
  • Tracing and monitoring: We should use a requirement-specific tracing and monitoring solution to debug the application in case something is not working as expected.
  • Spring Profiles: Use profiles to separate the configuration for each environment (dev, test, prod) etc.
  • Integration Tests: Consider adding a few integration tests that can test the application components together and how they interact with external services.

In the comments, please leave me your questions about creating and exposing CRUD operations in the Spring Boot 3 application using hibernate to manage backend data updates.

Happy Learning !!

Source Code on Github

Weekly Newsletter

Stay Up-to-Date with Our Weekly Updates. Right into Your Inbox.

Comments

Subscribe
Notify of
17 Comments
Most Voted
Newest Oldest
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.