Lab: Spring Boot Data JPA with PostgreSQL


By the end of this lab, you’ll understand how object-relational mapping works in Spring Boot applications and gain practical experience implementing data persistence.

Learning Objectives

  • Understand the core concepts of Spring Data JPA
  • Connect a Spring Boot application to PostgreSQL
  • Create entity classes and repositories
  • Implement CRUD operations using JPA repositories
  • Test data persistence operations

Prerequisites

  • Basic Java programming knowledge
  • Familiarity with Spring Boot fundamentals
  • PostgreSQL installed locally or Docker for running PostgreSQL in a container
  • IDE (IntelliJ IDEA, Eclipse, or VS Code with Spring extensions)
  • Maven or Gradle

Section 1: Setup and Configuration

Step 1.1: Create a Spring Boot Project

  1. Go to Spring Initializr
  2. Configure the project:
  • Project: Maven
  • Language: Java
  • Spring Boot: 3.1.x (or latest stable version)
  • Group: com.example
  • Artifact: student-management
  • Dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, Lombok (optional, but helpful)
  1. Generate and download the project
  2. Extract and import the project into your IDE

Step 1.2: Configure PostgreSQL Database

  1. Create a PostgreSQL database named student_db
# Using psql command-line
psql -U postgres
CREATE DATABASE student_db;

Or using Docker:

# Run PostgreSQL in Docker
docker run --name postgres-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=student_db -p 5432:5432 -d postgres

Step 1.3: Configure Database Connection

Open src/main/resources/application.properties and add:

# Database configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/student_db
spring.datasource.username=postgres
spring.datasource.password=postgres

# JPA/Hibernate properties
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

The properties explained:

  • spring.datasource.*: Defines connection parameters to your PostgreSQL database
  • spring.jpa.hibernate.ddl-auto=update: Automatically updates database schema based on entity classes
  • spring.jpa.show-sql=true: Shows generated SQL in logs (helpful for learning)

Section 2: Creating the Domain Model

Step 2.1: Create a Student Entity

Create a new package com.example.studentmanagement.model and add a Student.java class:

package com.example.studentmanagement.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
// If using Lombok, include these:
// import lombok.AllArgsConstructor;
// import lombok.Data;
// import lombok.NoArgsConstructor;

@Entity
@Table(name = "students")
// If using Lombok, uncomment these:
// @Data
// @NoArgsConstructor
// @AllArgsConstructor
public class Student {

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

    private String firstName;
    private String lastName;
    private String email;
    private int age;
    private String major;

    // Constructor, getters, and setters
    // (Not needed if using Lombok's @Data, @NoArgsConstructor, and @AllArgsConstructor)

    public Student() {
    }

    public Student(String firstName, String lastName, String email, int age, String major) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
        this.age = age;
        this.major = major;
    }

    // Getters and setters
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getMajor() {
        return major;
    }

    public void setMajor(String major) {
        this.major = major;
    }

    @Override
    public String toString() {
        return "Student{" +
                "id=" + id +
                ", firstName='" + firstName + '\'' +
                ", lastName='" + lastName + '\'' +
                ", email='" + email + '\'' +
                ", age=" + age +
                ", major='" + major + '\'' +
                '}';
    }
}

Key annotations explained:

  • @Entity: Marks this class as a JPA entity, which will be mapped to a database table
  • @Table(name = "students"): Specifies the table name in the database
  • @Id: Marks the primary key field
  • @GeneratedValue: Defines how the primary key should be generated

Section 3: Creating JPA Repository

Step 3.1: Create a StudentRepository Interface

Create a new package com.example.studentmanagement.repository and add a StudentRepository.java interface:

package com.example.studentmanagement.repository;

import com.example.studentmanagement.model.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {

    // Custom finder methods
    List<Student> findByMajor(String major);

    List<Student> findByAgeLessThan(int age);

    List<Student> findByLastNameContaining(String namePattern);

    // Custom JPQL query
    @Query("SELECT s FROM Student s WHERE s.age >= :minAge AND s.age <= :maxAge")
    List<Student> findStudentsInAgeRange(int minAge, int maxAge);
}

The repository interface explained:

  • Extends JpaRepository<Student, Long> where:
    • Student is the entity type
    • Long is the type of the primary key
  • Spring Data JPA automatically implements basic CRUD operations
  • Custom finder methods follow a naming convention that Spring translates into SQL queries
  • @Query annotation allows for custom JPQL queries

Section 4: Creating a Service Layer

Step 4.1: Create a StudentService Interface

Create a new package com.example.studentmanagement.service and add a StudentService.java interface:

package com.example.studentmanagement.service;

import com.example.studentmanagement.model.Student;

import java.util.List;
import java.util.Optional;

public interface StudentService {

    Student saveStudent(Student student);

    List<Student> getAllStudents();

    Optional<Student> getStudentById(Long id);

    Student updateStudent(Student student);

    void deleteStudent(Long id);

    List<Student> getStudentsByMajor(String major);

    List<Student> getStudentsYoungerThan(int age);

    List<Student> getStudentsByLastNamePattern(String pattern);

    List<Student> getStudentsInAgeRange(int minAge, int maxAge);
}

Step 4.2: Implement the StudentService

Create a StudentServiceImpl.java class:

package com.example.studentmanagement.service;

import com.example.studentmanagement.model.Student;
import com.example.studentmanagement.repository.StudentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class StudentServiceImpl implements StudentService {

    private final StudentRepository studentRepository;

    @Autowired
    public StudentServiceImpl(StudentRepository studentRepository) {
        this.studentRepository = studentRepository;
    }

    @Override
    public Student saveStudent(Student student) {
        return studentRepository.save(student);
    }

    @Override
    public List<Student> getAllStudents() {
        return studentRepository.findAll();
    }

    @Override
    public Optional<Student> getStudentById(Long id) {
        return studentRepository.findById(id);
    }

    @Override
    public Student updateStudent(Student student) {
        // First check if student exists
        if (studentRepository.existsById(student.getId())) {
            return studentRepository.save(student);
        }
        throw new RuntimeException("Student with ID " + student.getId() + " not found");
    }

    @Override
    public void deleteStudent(Long id) {
        studentRepository.deleteById(id);
    }

    @Override
    public List<Student> getStudentsByMajor(String major) {
        return studentRepository.findByMajor(major);
    }

    @Override
    public List<Student> getStudentsYoungerThan(int age) {
        return studentRepository.findByAgeLessThan(age);
    }

    @Override
    public List<Student> getStudentsByLastNamePattern(String pattern) {
        return studentRepository.findByLastNameContaining(pattern);
    }

    @Override
    public List<Student> getStudentsInAgeRange(int minAge, int maxAge) {
        return studentRepository.findStudentsInAgeRange(minAge, maxAge);
    }
}

The service implementation:

  • Uses @Service to mark it as a Spring service component
  • Uses constructor-based dependency injection to get the repository
  • Delegates most operations to the repository
  • Adds business logic where needed (like checking if a student exists before updating)

Section 5: Creating the REST Controller

Step 5.1: Create a StudentController

Create a new package com.example.studentmanagement.controller and add a StudentController.java class:

package com.example.studentmanagement.controller;

import com.example.studentmanagement.model.Student;
import com.example.studentmanagement.service.StudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/students")
public class StudentController {

    private final StudentService studentService;

    @Autowired
    public StudentController(StudentService studentService) {
        this.studentService = studentService;
    }

    @PostMapping
    public ResponseEntity<Student> createStudent(@RequestBody Student student) {
        Student savedStudent = studentService.saveStudent(student);
        return new ResponseEntity<>(savedStudent, HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity<List<Student>> getAllStudents() {
        List<Student> students = studentService.getAllStudents();
        return new ResponseEntity<>(students, HttpStatus.OK);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Student> getStudentById(@PathVariable Long id) {
        return studentService.getStudentById(id)
                .map(student -> new ResponseEntity<>(student, HttpStatus.OK))
                .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Student> updateStudent(@PathVariable Long id, @RequestBody Student student) {
        student.setId(id);
        try {
            Student updatedStudent = studentService.updateStudent(student);
            return new ResponseEntity<>(updatedStudent, HttpStatus.OK);
        } catch (RuntimeException e) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteStudent(@PathVariable Long id) {
        studentService.deleteStudent(id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }

    @GetMapping("/major/{major}")
    public ResponseEntity<List<Student>> getStudentsByMajor(@PathVariable String major) {
        List<Student> students = studentService.getStudentsByMajor(major);
        return new ResponseEntity<>(students, HttpStatus.OK);
    }

    @GetMapping("/younger-than/{age}")
    public ResponseEntity<List<Student>> getStudentsYoungerThan(@PathVariable int age) {
        List<Student> students = studentService.getStudentsYoungerThan(age);
        return new ResponseEntity<>(students, HttpStatus.OK);
    }

    @GetMapping("/by-lastname/{pattern}")
    public ResponseEntity<List<Student>> getStudentsByLastNamePattern(@PathVariable String pattern) {
        List<Student> students = studentService.getStudentsByLastNamePattern(pattern);
        return new ResponseEntity<>(students, HttpStatus.OK);
    }

    @GetMapping("/age-range")
    public ResponseEntity<List<Student>> getStudentsInAgeRange(
            @RequestParam int minAge, @RequestParam int maxAge) {
        List<Student> students = studentService.getStudentsInAgeRange(minAge, maxAge);
        return new ResponseEntity<>(students, HttpStatus.OK);
    }
}

The controller explained:

  • @RestController: Combines @Controller and @ResponseBody to create a RESTful controller
  • @RequestMapping("/api/students"): Base URL for all endpoints in this controller
  • Implements all CRUD operations using appropriate HTTP methods
  • Returns appropriate HTTP status codes for different outcomes

Section 6: Running and Testing the Application

Step 6.1: Run the Application

Run the main application class:

package com.example.studentmanagement;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class StudentManagementApplication {

    public static void main(String[] args) {
        SpringApplication.run(StudentManagementApplication.class, args);
    }
}

Step 6.2: Create Test Data

Let’s create a class to initialize some test data. Create a new class DataInitializer.java in the main package:

package com.example.studentmanagement;

import com.example.studentmanagement.model.Student;
import com.example.studentmanagement.repository.StudentRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataInitializer {

    @Bean
    CommandLineRunner initDatabase(StudentRepository repository) {
        return args -> {
            // Only add test data if repository is empty
            if (repository.count() == 0) {
                repository.save(new Student("John", "Doe", "[email protected]", 20, "Computer Science"));
                repository.save(new Student("Jane", "Smith", "[email protected]", 22, "Mathematics"));
                repository.save(new Student("Alice", "Johnson", "[email protected]", 19, "Computer Science"));
                repository.save(new Student("Bob", "Williams", "[email protected]", 21, "Physics"));
                repository.save(new Student("Charlie", "Brown", "[email protected]", 23, "Mathematics"));
                System.out.println("Sample data initialized!");
            }
        };
    }
}

Step 6.3: Test the REST API

Use tools like Postman, cURL, or simply your web browser to test the API endpoints.

Example cURL commands:

  1. Get all students:
curl -X GET http://localhost:8080/api/students
  1. Get a student by ID:
curl -X GET http://localhost:8080/api/students/1
  1. Create a new student:
curl -X POST http://localhost:8080/api/students \
  -H "Content-Type: application/json" \
  -d '{"firstName":"David","lastName":"Miller","email":"[email protected]","age":24,"major":"Psychology"}'
  1. Update a student:
curl -X PUT http://localhost:8080/api/students/1 \
  -H "Content-Type: application/json" \
  -d '{"firstName":"John","lastName":"Doe","email":"[email protected]","age":21,"major":"Computer Science"}'
  1. Delete a student:
curl -X DELETE http://localhost:8080/api/students/3
  1. Get students by major:
curl -X GET http://localhost:8080/api/students/major/Computer%20Science
  1. Get students in age range:
curl -X GET "http://localhost:8080/api/students/age-range?minAge=20&maxAge=22"

Lab Questions and Exercises

Now that you’ve completed building the application, try to answer the following questions to solidify your understanding:

  1. What is the purpose of the @Entity annotation? How does it relate to database tables?

  2. Explain what happens when Spring Data JPA encounters a method like findByMajor(String major) in a repository interface.

  3. How does Spring Boot know how to connect to your PostgreSQL database?

  4. What is the difference between spring.jpa.hibernate.ddl-auto=update and spring.jpa.hibernate.ddl-auto=create?

  5. Add a new entity class called Course with fields for id, name, code, and credits. Then create:

  • A many-to-many relationship between Student and Course
  • A repository interface for the Course entity
  • Service layer methods to add/remove students from courses
  • Controller endpoints to manage courses and student enrollment
  1. Implement validation for student data (e.g., email format, age range) using Bean Validation (JSR 380).

  2. Add pagination and sorting support to the endpoints that return multiple students.

Additional Resources

Conclusion

In this lab, you’ve learned how to use Spring Boot Data JPA to implement data persistence with PostgreSQL. You’ve created entity classes, repositories, services, and REST controllers to manage student data. This foundational knowledge can be applied to build more complex applications with sophisticated data models and relationships.

Remember that Spring Data JPA greatly simplifies database operations by providing a high-level abstraction over JPA. This allows you to focus on your business logic rather than writing boilerplate code for data access.


By Wahid Hamdi