Doctor Search & Dynamic Filtering with Spring Data JPA

Business Scenario

In this lab, we will enhance the Medicare backend by implementing doctor search and dynamic filtering based on specialization, doctor name, and maximum consultation fee.

Pre-Lab Preparation

Phase 6 —  Doctor Search & Dynamic Filtering

Understand Request Parameters

1

  • Until now we used things like:

Module:

1) Additional Components in Project​

GET /api/doctors/5
  • Here: 5 identifies one particular doctor.
GET /api/doctors?specialization=Cardiologist
  • That's a: @PathVariable
  • Filtering is different.
  • For example:
  • Here: specialization=Cardiologist doesn't identify one resource.
  • It modifies/filter the collection.
  • Therefore we'll use: @RequestParam
  • Think of it like:

@PathVariable → Which resource? @RequestParam → How should I filter/search?

First Understand Derived Queries

2

  • Spring Data allows us to create methods such as:
List<DoctorProfile> findBySpecializationIgnoreCase( String specialization );
  • Spring interprets the method name:
find
By
Specialization
IgnoreCase

and automatically generates the database query.

  • Similarly:
List<DoctorProfile> findByConsultationFeeLessThanEqual(BigDecimal maxFee);
  • means :
WHERE consultation_fee <= maxFee

Searching Doctor Name Is Interesting

3

  • Remember our structure:

DoctorProfile --> User --> name

  • DoctorProfile itself does not contain: name because name belongs to: User

  • So Spring Data can navigate the relationship:
List<DoctorProfile> findByUser_NameContainingIgnoreCase(String name);
  • Read that as:

DoctorProfile -> User -> Name -> Containing -> IgnoreCase

  • For example Search: anaya can match
Dr. Ananya Sharma

Create TimeSlot

3

  • Instead of allowing users to type any random value such as:
package com.medicare.entity;
public enum TimeSlot {
"10"
"Morning"
"10am"
"Ten AM"
  • Create: 

entity/TimeSlot.java

    MORNING_10_AM,
    AFTERNOON_2_PM,
    EVENING_5_PM
}
  • These correspond to the three planned appointment slots in the original project
  • Later our React frontend can display:
10:00 AM
02:00 PM
05:00 PM
  • while internally sending:
MORNING_10_AM
AFTERNOON_2_PM
EVENING_5_PM

Create Appointment

4

  • Create: 

entity/Appointment.java

package com.medicare.entity;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "appointments")
public class Appointment {

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

    @ManyToOne
    @JoinColumn(name = "patient_id", nullable = false)
    private User patient;

    @ManyToOne
    @JoinColumn(name = "doctor_id", nullable = false)
    private DoctorProfile doctor;

    @Column(name = "appointment_date", nullable = false)
    private LocalDate appointmentDate;

    @Enumerated(EnumType.STRING)
    @Column(name = "time_slot", nullable = false)
    private TimeSlot timeSlot;

    @Column(name = "consultation_fee", nullable = false, precision = 10, scale = 2)
    private BigDecimal consultationFee;

    @Enumerated(EnumType.STRING)
    @Column(name = "appointment_status", nullable = false)
    private AppointmentStatus appointmentStatus = AppointmentStatus.PENDING;
    @Enumerated(EnumType.STRING)
    @Column(name = "payment_status", nullable = false)
    private PaymentStatus paymentStatus = PaymentStatus.UNPAID;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

    public Appointment() {
    }

    public Long getId() {
        return id;
    }

    public User getPatient() {
        return patient;
    }

    public void setPatient(User patient) {
        this.patient = patient;
    }

    public DoctorProfile getDoctor() {
        return doctor;
    }

    public void setDoctor(DoctorProfile doctor) {
        this.doctor = doctor;
    }
    public LocalDate getAppointmentDate() {
        return appointmentDate;
    }

    public void setAppointmentDate(LocalDate appointmentDate) {
        this.appointmentDate = appointmentDate;
    }

    public TimeSlot getTimeSlot() {
        return timeSlot;
    }

    public void setTimeSlot(TimeSlot timeSlot) {
        this.timeSlot = timeSlot;
    }

    public BigDecimal getConsultationFee() {
        return consultationFee;
    }

    public void setConsultationFee(BigDecimal consultationFee) {
        this.consultationFee = consultationFee;
    }

    public AppointmentStatus getAppointmentStatus() {
        return appointmentStatus;
    }

    public void setAppointmentStatus(AppointmentStatus appointmentStatus) {
        this.appointmentStatus = appointmentStatus;
    }

    public PaymentStatus getPaymentStatus() {
        return paymentStatus;
    }

    public void setPaymentStatus(PaymentStatus paymentStatus) {
        this.paymentStatus = paymentStatus;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }
}

Add @OneToMany to User

5

  • Inside: User.java add imports:
import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonIgnore;

import jakarta.persistence.OneToMany;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.OneToMany;
  • Then add:
@OneToMany(mappedBy = "patient")
@JsonIgnore
private List<Appointment> appointments = new ArrayList<>();

Add @OneToMany to DoctorProfile

6

  • Inside: DoctorProfile.java add:
import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonIgnore;

import jakarta.persistence.OneToMany;
  • Then add:
@OneToMany(mappedBy = "doctor")
@JsonIgnore
private List<Appointment> appointments =
    new ArrayList<>();

Create Appointment Request DTO

7

  • This is another case where DTO is genuinely useful.
  • We don't want frontend requests like:
{
    "patient": {
        "id": 1
    },
    "doctor": {
        "id": 2
    } }
  • Instead:
{
    "patientId": 1,
    "doctorId": 2,
    "appointmentDate": "2026-09-20",
    "timeSlot": "MORNING_10_AM"
}
  • Create

dto/AppointmentRequest.java

package com.medicare.dto;
import java.time.LocalDate;
import com.medicare.entity.TimeSlot;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotNull;

public class AppointmentRequest {

    @NotNull(message = "Patient id is required")
    private Long patientId;

    @NotNull(message = "Doctor id is required")
    private Long doctorId;

    @NotNull(message = "Appointment date is required")
      @FutureOrPresent( message = "Appointment date cannot be in the past")
    private LocalDate appointmentDate;
    @NotNull(message = "Time slot is required")
    private TimeSlot timeSlot;

    public Long getPatientId() {
        return patientId;
    }

    public void setPatientId(Long patientId) {
        this.patientId = patientId;
    }

    public Long getDoctorId() {
        return doctorId;
    }

    public void setDoctorId(Long doctorId) {
        this.doctorId = doctorId;
    }

    public LocalDate getAppointmentDate() {
        return appointmentDate;
    }

    public void setAppointmentDate(
            LocalDate appointmentDate) {

        this.appointmentDate = appointmentDate;
    }

    public TimeSlot getTimeSlot() {
        return timeSlot;
    }

    public void setTimeSlot(TimeSlot timeSlot) {
        this.timeSlot = timeSlot;
    }
}

Create Appointment Repository

8

  • Create :
package com.medicare.repository;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.medicare.entity.Appointment;
import com.medicare.entity.TimeSlot;

repository/AppointmentRepository.java

public interface AppointmentRepository extends JpaRepository<Appointment, Long> {

    List<Appointment> findByPatientId( Long patientId );
    List<Appointment> findByDoctorId( Long doctorId );

    boolean existsByDoctorIdAndAppointmentDateAndTimeSlot(
            Long doctorId,
            LocalDate appointmentDate,
            TimeSlot timeSlot
    );
}

Create Appointment Repository

9

  • Create :

repository/AppointmentRepository.java

package com.medicare.repository;
import java.time.LocalDate;
import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

import com.medicare.entity.Appointment;
import com.medicare.entity.TimeSlot;

public interface AppointmentRepository
        extends JpaRepository<Appointment, Long> {

    List<Appointment> findByPatientId(
            Long patientId
    );

    List<Appointment> findByDoctorId(
            Long doctorId
    );

    boolean existsByDoctorIdAndAppointmentDateAndTimeSlot(
            Long doctorId,
            LocalDate appointmentDate,
            TimeSlot timeSlot
    );
}

Create Appointment Exception

10

  • Create :

exception/AppointmentNotFoundException.java

package com.medicare.exception;

public class AppointmentNotFoundException
        extends RuntimeException {

    public AppointmentNotFoundException(
            String message) {

        super(message);
    }
}

Create Slot Conflict Exception

11

  • Create :

exception/AppointmentConflictException.java

package com.medicare.exception;

public class AppointmentConflictException
        extends RuntimeException {

    public AppointmentConflictException(
            String message) {

        super(message);
    }
}

Create Appointment Service

12

  • Create :

service/AppointmentService.java

package com.medicare.service;
import java.util.List;
import org.springframework.stereotype.Service;
import com.medicare.dto.AppointmentRequest;
import com.medicare.entity.Appointment;
import com.medicare.entity.AppointmentStatus;
import com.medicare.entity.DoctorProfile;
import com.medicare.entity.PaymentStatus;
import com.medicare.entity.Role;
import com.medicare.entity.User;
import com.medicare.exception.AppointmentConflictException;
import com.medicare.exception.AppointmentNotFoundException;
import com.medicare.exception.DoctorNotFoundException;
import com.medicare.exception.UserNotFoundException;
import com.medicare.repository.AppointmentRepository;
import com.medicare.repository.DoctorProfileRepository;
import com.medicare.repository.UserRepository;
@Service
public class AppointmentService {

    private final AppointmentRepository appointmentRepository;
    private final UserRepository userRepository;
    private final DoctorProfileRepository doctorRepository;

    public AppointmentService(AppointmentRepository appointmentRepository, 
    UserRepository userRepository, DoctorProfileRepository doctorRepository) {
        this.appointmentRepository = appointmentRepository;
        this.userRepository = userRepository;
        this.doctorRepository = doctorRepository;
    }

    public Appointment createAppointment(AppointmentRequest request) {
        User patient = userRepository.findById(request.getPatientId())
                .orElseThrow(() -> 
                new UserNotFoundException("Patient not found with id: " + 
                   request.getPatientId()));

        if (patient.getRole() != Role.PATIENT) {
            throw new IllegalArgumentException(
            "Appointment can only be booked for a PATIENT user");
        }

        DoctorProfile doctor = doctorRepository.findById(request.getDoctorId())
                .orElseThrow(() -> 
                new DoctorNotFoundException("Doctor not found with id: " 
                + request.getDoctorId()));
                   boolean slotAlreadyBooked = appointmentRepository
        .existsByDoctorIdAndAppointmentDateAndTimeSlot(
                doctor.getId(),
                request.getAppointmentDate(),
               request.getTimeSlot()
        );

        if (slotAlreadyBooked) {
            throw new AppointmentConflictException(
            "Selected appointment slot is already booked");
        }

        Appointment appointment = new Appointment();
        appointment.setPatient(patient);
        appointment.setDoctor(doctor);
        appointment.setAppointmentDate(request.getAppointmentDate());
        appointment.setTimeSlot(request.getTimeSlot());

        /*
         * Consultation fee comes from the doctor's profile.
         * We do not trust the frontend to send the fee.
         */
        appointment.setConsultationFee(doctor.getConsultationFee());
        appointment.setAppointmentStatus(AppointmentStatus.PENDING);
        appointment.setPaymentStatus(PaymentStatus.UNPAID);

        return appointmentRepository.save(appointment);
    }
    public List<Appointment> getAllAppointments() {
        return appointmentRepository.findAll();
    }

    public Appointment getAppointmentById(Long id) {
        return appointmentRepository.findById(id)
                .orElseThrow(() ->
                  new AppointmentNotFoundException("Appointment not found with id: "
                  + id));
    }

    public List<Appointment> getAppointmentsByPatient(Long patientId) {
        return appointmentRepository.findByPatientId(patientId);
    }

    public List<Appointment> getAppointmentsByDoctor(Long doctorId) {
        return appointmentRepository.findByDoctorId(doctorId);
    }
}

Create Appointment Controller

13

  • Create :

controller/AppointmentController.java

package com.medicare.controller;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.medicare.dto.AppointmentRequest;
import com.medicare.entity.Appointment;
import com.medicare.service.AppointmentService;

import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/appointments")
public class AppointmentController {

    private final AppointmentService appointmentService;
    public AppointmentController(
             AppointmentService appointmentService) {

        this.appointmentService =
                appointmentService;
    }

    @PostMapping
    public ResponseEntity<Appointment>
            createAppointment( @Valid @RequestBody AppointmentRequest request) {

        Appointment appointment = appointmentService .createAppointment(request);

        return ResponseEntity.status(HttpStatus.CREATED).body(appointment);
    }

    @GetMapping
    public ResponseEntity<List<Appointment>>
            getAllAppointments() {
        return ResponseEntity.ok(appointmentService.getAllAppointments());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Appointment>
            getAppointmentById(@PathVariable Long id) {
        return ResponseEntity.ok(appointmentService.getAppointmentById(id));
    }

    @GetMapping("/patient/{patientId}")
    public ResponseEntity<List<Appointment>>
            getAppointmentsByPatient( @PathVariable Long patientId) {

        return ResponseEntity.ok(appointmentService.getAppointmentsByPatient(
                                patientId
                        )
        );
    }

    @GetMapping("/doctor/{doctorId}")
    public ResponseEntity<List<Appointment>>
            getAppointmentsByDoctor( @PathVariable Long doctorId) {
        return ResponseEntity.ok( appointmentService.getAppointmentsByDoctor(doctorId)
        );
    }
}

Update Global Exception Handler

14

  • Add:
@ExceptionHandler(AppointmentNotFoundException.class)
public ResponseEntity<ErrorResponse>
        handleAppointmentNotFound(
              AppointmentNotFoundException exception,HttpServletRequest request) {

    ErrorResponse errorResponse =
            new ErrorResponse(LocalDateTime.now(),HttpStatus.NOT_FOUND.value(),
            exception.getMessage(), request.getRequestURI() );

    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
  • And:
@ExceptionHandler(AppointmentConflictException.class)
public ResponseEntity<ErrorResponse>
        handleAppointmentConflict( AppointmentConflictException exception,
                HttpServletRequest request) {

    ErrorResponse errorResponse =
            new ErrorResponse(LocalDateTime.now(),HttpStatus.CONFLICT.value(),
                    exception.getMessage(),request.getRequestURI());

    return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
  • Now we have another HTTP status:

409 CONFLICT

Restart and Check Database

15

  • After restarting:
SHOW TABLES

DESC appointments;
  • You should see approximately:

Prepare Test Data

16

  • Suppose:

  

id
patient_id
doctor_id
appointment_date
time_slot
consultation_fee
appointment_status
payment_status
created_at
User ID 1
Rahul
Role = PATIENT
  • And:
DoctorProfile 
ID 1
User: Dr. Ananya Sharma
Specialization: Cardiologist
Fee: ₹1200
DoctorProfile 
ID 1
User: Dr. Ananya Sharma
Specialization: Cardiologist
Fee: ₹1200

Create Appointment

17

  • Postman:

POST : http://localhost:8080/api/appointments

  • Body:
{
    "patientId": 1,
    "doctorId": 1,
    "appointmentDate": "2026-09-20",
    "timeSlot": "MORNING_10_AM"
}
  • Expected:

201 CREATED

  • Response approximately:
{
    "id": 1,
    "patient": {
        "id": 1,
        "name": "Rahul Shah",
        "email": "rahul@example.com",
        "phone": "9876543210",
        "role": "PATIENT"
    },
    "doctor": {
        "id": 1,
        "user": {
            "id": 2,
            "name": "Dr. Ananya Sharma",
            "email": "ananya@example.com",
            "phone": "9876543211",
            "role": "DOCTOR"
        },
        "specialization": "Cardiologist",
        "experienceYears": 8,
        "consultationFee": 1200,
        "photoUrl": "https://example.com/ananya.jpg"
    },
    "appointmentDate": "2026-09-20",
    "timeSlot": "MORNING_10_AM",
    "consultationFee": 1200,
    "appointmentStatus": "PENDING",
    "paymentStatus": "UNPAID"
}

Check Database

18

SELECT * FROM appointments;
  • Expected conceptually:
id       1
patient  1
doctor   1
date     2026-09-20
slot     MORNING_10_AM
fee      1200
status   PENDING
payment  UNPAID

  

19

  • Send exactly the same appointment for another patient:
{
    "patientId": 3,
    "doctorId": 1,
    "appointmentDate": "2026-09-20",
    "timeSlot": "MORNING_10_AM"
}

Test Duplicate Slot

18

  • Expected output:

409 CONFLICT

  • Response :
{
    "timestamp": "...",
    "status": 409,
    "message": "Selected appointment slot is already booked",
    "path": "/api/appointments"
}

  

19

  • Try a date in the past.

Test Invalid Date

18

  • Because we have:
@FutureOrPresent
  • the API returns:

400 BAD REQUEST

  • with:

Appointment date cannot be in the past

  

20

  • Suppose: User 2 = DOCTOR

Try Using DOCTOR as Patient

18

{
    "patientId": 2,
    "doctorId": 1,
    "appointmentDate": "2026-09-20",
    "timeSlot": "EVENING_5_PM"
}
  • Send:
  • Expected:

400 BAD REQUEST

Appointment can only be booked for a PATIENT user

  

21

Get Appointment

18

GET : http://localhost:8080/api/appointments

  • Expected:

200 OK

22

GET : http://localhost:8080/api/appointments/patient/1

  • Expected:

Get Patient Appointments

  • This represents: Show Rahul's appointments.
  • Later React will use this for: My Appointments

22

GET : http://localhost:8080/api/appointments/doctor/1

  • This represents: Show all appointments assigned to Dr. Ananya

Get Patient Appointments

  • Later this powers: Doctor Dashboard

We are done with this lab. The latest source code has been uploaded to GitHub. You can access the latest commit using the link below: 

 

Great job!

You have successfully completed Lab 6  by connecting patients and doctors through appointment bookings using @ManyToOne and @OneToMany relationships.

Checkpoint

   Git Push

git push origin branchName

Next-Lab Preparation

Module:

1) Additional Components in Project​

SpringBoot Lab - 7

By Content ITV

SpringBoot Lab - 7

  • 61