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
Module:
1) Additional Components in Project
GET /api/doctors/5GET /api/doctors?specialization=Cardiologist@PathVariable → Which resource? @RequestParam → How should I filter/search?
First Understand Derived Queries
2
List<DoctorProfile> findBySpecializationIgnoreCase( String specialization );find By Specialization IgnoreCase
and automatically generates the database query.
List<DoctorProfile> findByConsultationFeeLessThanEqual(BigDecimal maxFee);
WHERE consultation_fee <= maxFee
Searching Doctor Name Is Interesting
3
DoctorProfile --> User --> name
DoctorProfile itself does not contain: name because name belongs to: User
List<DoctorProfile> findByUser_NameContainingIgnoreCase(String name);DoctorProfile -> User -> Name -> Containing -> IgnoreCase
Dr. Ananya Sharma
ANANYA ananya AnAnYa
all work because of: IgnoreCase
But there is a problem...
specialization name maxFee specialization + name
specialization + maxFee name + maxFee specialization + name + maxFee
findBySpecializationIgnoreCaseAndConsultationFeeLessThanEqualAndUser_NameContainingIgnoreCase(...)Add a Doctor Response DTO
4
{
"id": 1,
"user": {
"id": 2,
"name": "Dr. Ananya Sharma",
"email": "ananya@example.com",
"phone": "9876543211",
"role": "DOCTOR"
},
"specialization": "Cardiologist",
"experienceYears": 8,
"consultationFee": 1200
}email phone role
Doctor ID Doctor Name Specialization Experience Fee Photo
Create DoctorResponse
5
dto/ └── DoctorResponse.java
package com.medicare.dto;
import java.math.BigDecimal;
public class DoctorResponse {
private Long id;
private String name;
private String specialization;
private Integer experienceYears;
private BigDecimal consultationFee;
private String photoUrl;Create:
public DoctorResponse(
Long id,
String name,
String specialization,
Integer experienceYears,
BigDecimal consultationFee,
String photoUrl) {
this.id = id;
this.name = name;
this.specialization = specialization;
this.experienceYears = experienceYears;
this.consultationFee = consultationFee;
this.photoUrl = photoUrl;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getSpecialization() {
return specialization;
}
public Integer getExperienceYears() {
return experienceYears; }
public BigDecimal getConsultationFee() {
return consultationFee;
}
public String getPhotoUrl() {
return photoUrl;
}
}Update DoctorProfileRepository
6
Instead of allowing users to type any random value such as:
repository/ └── DoctorProfileRepository.java
Create:
package com.medicare.repository;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.medicare.entity.DoctorProfile;
public interface DoctorProfileRepository
extends JpaRepository<DoctorProfile, Long> {
boolean existsByUserId(Long userId);
@Query("""
SELECT d FROM DoctorProfile d JOIN d.user u WHERE
(:specialization IS NULL
OR LOWER(d.specialization) = LOWER(:specialization))
AND
(:maxFee IS NULL OR d.consultationFee <= :maxFee)
AND
(:name IS NULL
OR LOWER(u.name) LIKE CONCAT(CONCAT('%', LOWER(:name)), '%'))
""")
List<DoctorProfile> searchDoctors( @Param("specialization")
String specialization,
@Param("maxFee")
BigDecimal maxFee,
@Param("name")
String name
);
}Understand JPQL
7
Notice: DoctorProfile and not: doctor_profiles
Why JOIN d.user u?
DoctorProfile -> User -> Name
Optional Specialization
(:specialization IS NULL
OR LOWER(d.specialization) = LOWER(:specialization))Did user provide specialization? │ ┌───┴───┐ NO YES │ │ ignore match specialization filter
Maximum Fee
(:maxFee IS NULL OR d.consultationFee <= :maxFee)
means if: maxFee = 1000 return doctors charging:
₹300 ₹500 ₹700 ₹900 ₹1000
₹1200 ₹1500 ₹2000
Doctor Name Search
LOWER(u.name)
LIKE CONCAT(CONCAT('%', LOWER(:name)), '%')Update DoctorService
8
package com.medicare.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import com.medicare.dto.DoctorProfileRequest;
import com.medicare.dto.DoctorResponse;
import com.medicare.entity.DoctorProfile;
import com.medicare.entity.Role;
import com.medicare.entity.User;
import com.medicare.exception.DoctorNotFoundException;
import com.medicare.exception.UserNotFoundException;
import com.medicare.repository.DoctorProfileRepository;
import com.medicare.repository.UserRepository;
@Service
public class DoctorService {
private final DoctorProfileRepository doctorRepository;
private final UserRepository userRepository;
public DoctorService(
DoctorProfileRepository doctorRepository,
UserRepository userRepository) { this.doctorRepository = doctorRepository;
this.userRepository = userRepository;
}
public DoctorResponse createDoctor(DoctorProfileRequest request) {
User user = findUserById( request.getUserId() );
if (user.getRole() != Role.DOCTOR) {
throw new IllegalArgumentException(
"Doctor profile can only be created for a DOCTOR user"
);
}
if (doctorRepository.existsByUserId(user.getId())) {
throw new IllegalArgumentException(
"Doctor profile already exists for this user"
);
}
DoctorProfile doctor = new DoctorProfile();
doctor.setUser(user);
doctor.setSpecialization(
request.getSpecialization()
); doctor.setExperienceYears(request.getExperienceYears());
doctor.setConsultationFee(request.getConsultationFee());
doctor.setPhotoUrl( request.getPhotoUrl());
DoctorProfile savedDoctor = doctorRepository.save(doctor);
return mapToDoctorResponse(savedDoctor);
}
public List<DoctorResponse> searchDoctors(
String specialization, BigDecimal maxFee, String name) {
specialization = normalizeText(specialization);
name = normalizeText(name);
if (maxFee != null && maxFee.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Maximum consultation fee must be greater than zero"
);
}
List<DoctorProfile> doctors = doctorRepository.searchDoctors(
specialization, maxFee, name);
List<DoctorResponse> responses = new ArrayList<>();
for (DoctorProfile doctor : doctors) {
responses.add(mapToDoctorResponse(doctor));
}
return responses;
} public DoctorResponse getDoctorById(Long id) {
DoctorProfile doctor = findDoctorById(id);
return mapToDoctorResponse(doctor);
}
public DoctorResponse updateDoctor(Long id, DoctorProfileRequest request) {
DoctorProfile doctor = findDoctorById(id);
doctor.setSpecialization(request.getSpecialization());
doctor.setExperienceYears(request.getExperienceYears());
doctor.setConsultationFee(request.getConsultationFee());
doctor.setPhotoUrl(request.getPhotoUrl());
DoctorProfile updatedDoctor =doctorRepository.save(doctor);
return mapToDoctorResponse(updatedDoctor);
}
public void deleteDoctor(Long id) {
DoctorProfile doctor = findDoctorById(id);
doctorRepository.delete(doctor);
}
private DoctorProfile findDoctorById(Long id) {
return doctorRepository.findById(id).orElseThrow(() ->
new DoctorNotFoundException("Doctor not found with id: "
+ id));
}
private User findUserById(Long id) { return userRepository
.findById(id)
.orElseThrow(() ->
new UserNotFoundException(
"User not found with id: " + id
)
);
}
/*
* Converts DoctorProfile entity into the simpler
* response sent to the frontend.
*/
private DoctorResponse mapToDoctorResponse(
DoctorProfile doctor) {
return new DoctorResponse(
doctor.getId(),
doctor.getUser().getName(),
doctor.getSpecialization(),
doctor.getExperienceYears(),
doctor.getConsultationFee(),
doctor.getPhotoUrl()
);
}
/*
* Empty request parameters should behave
* the same as parameters that were not supplied.
*/ private String normalizeText(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}Why normalizeText()?
"" " " " "
null
so those empty filters don't affect the query.
Update DoctorController
9
@GetMapping public ResponseEntity<List<DoctorProfile>> getAllDoctors()
will now become dynamic.
package com.medicare.controller;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.medicare.dto.DoctorProfileRequest;
import com.medicare.dto.DoctorResponse;
import com.medicare.service.DoctorService;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/doctors")
public class DoctorController {
private final DoctorService doctorService;
public DoctorController(DoctorService doctorService) { this.doctorService = doctorService;
}
@PostMapping
public ResponseEntity<DoctorResponse>
createDoctor(@Valid @RequestBodyDoctorProfileRequest request) {
DoctorResponse savedDoctor = doctorService.createDoctor(request);
return ResponseEntity.status(HttpStatus.CREATED).body(savedDoctor);
}
@GetMapping
public ResponseEntity<List<DoctorResponse>>
searchDoctors(
@RequestParam(required = false)
String specialization,
@RequestParam(required = false)
BigDecimal maxFee,
@RequestParam(required = false)
String name) {
List<DoctorResponse> doctors =
doctorService.searchDoctors(specialization,maxFee,name);
return ResponseEntity.ok(doctors);
}
@GetMapping("/{id}")
public ResponseEntity<DoctorResponse>
getDoctorById(@PathVariable Long id) { return ResponseEntity.ok(
doctorService.getDoctorById(id)
);
}
@PutMapping("/{id}")
public ResponseEntity<DoctorResponse>
updateDoctor(
@PathVariable Long id,
@Valid
@RequestBody
DoctorProfileRequest request) {
return ResponseEntity.ok(doctorService.updateDoctor(id,request)
);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteDoctor(@PathVariable Long id) {
doctorService.deleteDoctor(id);
return ResponseEntity.noContent().build();
}
}/api/doctors/all /api/doctors/filter /api/doctors/search /api/doctors/by-name /api/doctors/by-fee
Test: Get All Doctors
10
GET http://localhost:8080/api/doctorsSearch by Specialization
11
GET http://localhost:8080/api/doctors?specialization=Cardiologist
200 OK
[
{
"id": 1, "name": "Dr. Ananya Sharma", "specialization": "Cardiologist", "experienceYears": 8, "consultationFee": 1200.00, "photoUrl": "https://example.com/ananya.jpg" } ]
Search by Doctor Name
12
GET http://localhost:8080/api/doctors?name=ananya
Filter by Maximum Fee
13
GET http://localhost:8080/api/doctors?maxFee=1000
Dr. Rohan ₹700 Dr. Priya ₹900 Dr. Ananya ₹1200 Dr. Arjun ₹1500
Dr. Rohan
Dr. Priya
Dr. Ananya
Dr. Arjun
Combine Filters
14
GET /api/doctors?specialization=Cardiologist&maxFee=1500&name=ananya
Search With Only Two Filters
15
GET /api/doctors?specialization=Cardiologist&maxFee=1300
Invalid Maximum Fee
16
GET /api/doctors?maxFee=-500
maxFee.compareTo(BigDecimal.ZERO) <= 0
IllegalArgumentException
400 BAD REQUEST
What If No Doctor Matches?
17
GET /api/doctors?specialization=SpaceDoctor
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 implemented doctor search and dynamic filtering in the Medicare backend using Spring Data JPA, custom queries, and DTOs.
Checkpoint
Git Push
git push origin branchNameNext-Lab Preparation
Module:
1) Additional Components in Project