Appointment Management & Entity Relationships

(@ManyToOne / @OneToMany)

Business Scenario

In this lab, we will extend the Medicare backend by developing the Appointment Module, which connects patients and doctors through appointment bookings.

Pre-Lab Preparation

Phase 5 —  Appointment Module + @ManyToOne / @OneToMany

Module:

1) Advance Spring data JPA and production ready Rest APi​

Create Appointment Enums

1

  • We don't want strings such as:"pending", "Pending", "PENDING" scattered throughout the project.
  • Create :

entity/AppointmentStatus.java

package com.medicare.entity;
public enum AppointmentStatus {
    PENDING,
    CONFIRMED,
    COMPLETED,
    CANCELLED
}

Create PaymentStatus

2

  • Create :

entity/PaymentStatus.java

package com.medicare.entity;

public enum PaymentStatus {
    UNPAID,
    PAID,
    FAILED
}
  • Right now every new appointment will start as:
AppointmentStatus.PENDING
PaymentStatus.UNPAID
  • Later Razorpay changes those values.

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 - 6

By Content ITV

SpringBoot Lab - 6

  • 299