⚠️

For technical research and educational purposes only. All system designs, architecture decisions, data models, and API patterns described in this article are proprietary and confidential. They may not be reproduced, adapted, reverse-engineered, or used commercially without explicit written permission from the author. Code references and schema details have been simplified for illustrative purposes and do not represent the exact production implementation.

Healthcare Engineering · Spring Boot · Queue Systems

Building an OPD Queue
Management System

A technical walkthrough of how a hospital outpatient department tames the chaos of patient queues — from data modelling and booking flows to real-time token tracking and shift orchestration.

By Sameer Shaikh · January 2026 · Spring Boot · JPA · REST

The Problem with OPD Queues

Walk into any busy hospital outpatient department before this system and you will find the same scene: rows of anxious patients clutching paper tokens, no idea when they will be called, staff shouting names over a noisy lobby, and doctors sitting idle because the next patient stepped out for tea.

The pain points break down into three categories:

Patient pain
Blind waiting
No estimated wait time, no SMS alert when their turn approaches. Patients cannot leave and return.
Staff pain
Manual juggling
Receptionist maintains paper registers. Re-ordering for emergencies requires reshuffling physical tokens.
Doctor pain
Idle consultations
No system signal when the current patient is done. The next appointment isn't auto-advanced.
Operations pain
No visit history
Walk-ins and pre-bookings exist in separate silos. Analytics and audit trails are absent.
What QMS solves: A single digital queue per doctor per shift, with token assignment, real-time status, patient SMS notifications, drag-and-drop reordering, and a complete visit history — all exposed through a clean REST API.

System Architecture

The QMS backend follows a classic layered architecture built on Spring Boot. A single controller routes all incoming requests to two specialised service classes, which in turn talk to the database through JPA repositories.

Layered architecture — request flows top to bottom
Client / Mobile App HTTP / REST QmsAppointmentController Routes HTTP requests · extracts SessionDetail · builds ApiResponse wrappers QmsAppointmentService Booking · queue ordering token generation · visits QmsShiftSessionService Start / end shift shift status checks JPA / Hibernate · PostgreSQL qms_appointments · qms_shift_sessions · related config tables
Figure 1 — Three-layer architecture. Horizontal bars represent layers; arrows show data flow direction.

Key design principle: thin controller

The QmsAppointmentController does no business logic. It unpacks the session from the request attribute, delegates entirely to a service, and wraps the response in a standard ApiResponse<T> envelope. This keeps services fully testable in isolation and makes the controller trivially readable.

The split between QmsAppointmentService and QmsShiftSessionService reflects a clean domain boundary: one service owns the appointment lifecycle (bookings, tokens, queue ordering), the other owns the shift session lifecycle (starting/ending doctor shifts). They are deliberately decoupled.

Data Model

The entire system pivots around a single entity: QmsAppointment. It inherits audit fields from BaseEntity (id, createdAt, deleted, createdBy, etc.) and extends them with all appointment-specific concerns.

BaseEntity — the audit foundation

Every table in this system inherits from BaseEntity, a mapped superclass that automatically injects creation and update timestamps (both human-readable LocalDateTime and epoch-second longs), a soft-delete flag, and creator/updater tracking. The dual timestamp approach (LocalDateTime + epoch) is deliberate — epoch integers are fast to compare in queries while the LocalDateTime forms are human-readable in logs and UI.

QmsAppointment field groups

The fields cluster into five logical groups:

Identity
workspace_id · address_id
doctor_id · patient_id
qms_work_shift_id
Multi-tenant: every appointment is scoped to a workspace, branch address, and shift.
Scheduling
appointment_date · status
type · mode · duration
appointment_token · position
The token is a sequential number for the day; position allows drag-and-drop reordering.
Payment
total_fee · discount
consultation_charge · currency
payment_mode · payment_status
invoice_id
Captures the financial snapshot of the visit at booking time.
Patient info
patient_phone_number · patient_email
symptoms · reason_for_visit
appointment_notes
Contact details are denormalised here so the token SMS/email can be sent even if the patient record changes.
Lifecycle events
reminder_sent · reminder_sent_at
patient_checked_in · cancelled_at
cancellation_reason · skipped_at
rescheduled_from_id
Each status transition leaves a timestamp trail, enabling full audit and analytics.
Source tracking
source (enum)
meeting_id
Was this booked via app, reception desk, or a teleconsult meeting link?
Entity relationship diagram — qms_appointments at centre
erDiagram QMS_APPOINTMENT { bigint id PK bigint workspace_id FK bigint address_id FK bigint doctor_id FK bigint patient_id FK bigint qms_work_shift_id FK date appointment_date varchar status varchar type varchar mode bigint appointment_token bigint position boolean patient_checked_in varchar payment_status double total_fee varchar source timestamp created_at boolean deleted } WORKSPACE ||--o{ QMS_APPOINTMENT : "hosts" DOCTOR ||--o{ QMS_APPOINTMENT : "sees" PATIENT ||--o{ QMS_APPOINTMENT : "attends" QMS_WORK_SHIFT ||--o{ QMS_APPOINTMENT : "slots" QMS_APPOINTMENT ||--o| QMS_SHIFT_SESSION : "tracked_by"
Figure 2 — Core entity relationships. Only key columns shown; full schema has ~35 fields.

Why soft deletes?

The deleted flag on BaseEntity means no appointment is ever physically removed. This preserves visit history for regulatory compliance, enables "uncancel" workflows, and ensures invoice references never become dangling foreign keys.

Booking Flows

The system supports two distinct booking modes that map directly to two controller endpoints. They share the same request DTO but follow different validation and queue-insertion logic.

Pre-booking
POST /v1/qms-appointments/pre/book
For future dates. Validates shift availability, assigns a token but the appointment stays in BOOKED status until the shift starts.
Immediate / walk-in
POST /v1/qms-appointments/immediate/book
For today's date. Slot availability is checked against live queue length. Returns null (and a failure message) if the doctor's shift is full.
Pre-booking flow — POST /pre/book
Client sends booking request QmsAppointmentRequestDTO Validate request fields @Valid annotation on DTO Doctor shift exists on date? Query qms_work_shifts No Return 4xx Shift not found Yes Slot capacity available? Count existing BOOKED records No Return 4xx Slot full Yes Create QmsAppointment record status = BOOKED, source, payment Assign token number Sequential for doctor+date+shift Return 201 Created QmsAppointmentResponseDTO
Figure 3 — Pre-booking flow. Two decision gates protect the queue from invalid bookings.
Immediate / walk-in booking flow — POST /immediate/book
Patient walks in today Receptionist initiates booking Has shift started today? Query QmsShiftSession for today No Shift not started Return null / failure Yes Live queue within capacity? Count BOOKED + IN_PROGRESS No Queue full Return null / failure Yes Create at next queue position max(position) + 1 for this shift Assign token & notify patient Encrypted token link via SMS Return 201 Created Booking confirmed
Figure 4 — Immediate booking. The live-queue-capacity check prevents overloading an active shift.

A critical difference between the two flows: in immediate booking, the response message is dynamic — "Appointment booking failed!" when the service returns null, versus the generic "Appointment booked." for pre-booking (where a null response is treated as success with a flag). This is intentional: immediate booking failure is user-visible, while pre-booking failures throw exceptions earlier in the chain.

Queue & Appointment Lifecycle

Every appointment travels through a state machine. The QmsAppointmentStatus enum drives the entire queue logic — which appointment is currently active, what's next, and what's done.

BOOKED IN_PROGRESS COMPLETED CANCELLED SKIPPED
Appointment status state machine
BOOKED Waiting in queue shift start / next IN_PROGRESS Doctor consulting done COMPLETED Visit done no show SKIPPED Patient absent re-queue cancel CANCELLED Soft deleted also cancellable Normal transition Optional / recovery path
Figure 5 — Appointment status state machine. SKIPPED appointments can be re-queued.

The position field

Separate from the token number, the position field represents the appointment's place in today's live queue. It starts equal to the token number but can be reordered via PUT /position/update. This lets a receptionist drag an emergency case to the top without changing anyone's token number (the token is the permanent identifier sent to the patient's phone).

Why separate token and position? The token is the patient's reference number — it's printed on the slip, sent via SMS, and should never change. Position is the operational sequence and can change freely as the queue is managed by staff. Conflating the two would make reordering impossible without re-sending notifications.

Shift Management

A doctor's shift is not just a time window — it's a session object that must be explicitly started. This models the real-world handoff: a doctor arrives, signals readiness, and the system activates the first appointment. Without an explicit shift start, no appointment advances to IN_PROGRESS.

The QmsShiftSessionService manages three operations: start, end, and status check.

Sequence diagram — shift start and status update flow
Staff / Doctor Controller REST API ShiftService business logic Database JPA / PostgreSQL A — Starting a shift POST /shift/start startShift(params, userId) Query BOOKED, order by position appointments list First → IN_PROGRESS, save session ShiftSession created 200 OK B — Advancing the queue PUT /{id}/update status=COMPLETED updateStatus(appointmentId, status) Current → COMPLETED Next BOOKED → IN_PROGRESS updated appointment DTO 200 Updated Request Response
Figure 6 — Sequence diagram for shift start (A) and queue advancement (B). Dashed arrows are return/response paths.

Why an explicit shift start?

Shift hours alone cannot reliably indicate "the doctor is ready." Doctors may be late, handle paperwork first, or start early. The POST /shift/start endpoint gives the clinic control and creates an audit record: who started the shift and when. The shift status check endpoint (GET /shift/status) lets the frontend disable the "Start shift" button if it's already running, preventing duplicate starts.

Token & Real-Time Status

The token system is how a patient knows their turn without standing in line. When an appointment is booked (or confirmed at the shift start), the patient receives a short-link via SMS or email. That link carries an encrypted payload that uniquely identifies their appointment in the queue.

Token generation and patient status check flow
Appointment created token assigned Encrypt token appointment ID + doctor context Send to patient SMS or email reminder_sent = true GET /status ?data=<encrypted> returns ETA + queue pos 1. Book 2. Encrypt 3. Notify 4. Patient polls GET /tokens/send?date=... Bulk-sends tokens for entire day
Figure 7 — Token lifecycle. The /tokens/send endpoint is typically run once per morning.

Encrypted data param

The GET /status?data= endpoint accepts an encrypted string rather than a raw appointment ID. This design prevents patients from polling other patients' statuses (no sequential ID enumeration) and hides the internal database key from the public-facing URL.

ETA calculation

The status response includes the current token number being served and an estimated wait time. This is computed from the appointments ahead in the queue multiplied by the average consultation duration (stored as duration_in_seconds on past appointments). As each appointment completes, all ETA values downstream automatically become more accurate.

API Reference

All endpoints are under the base path /v1/qms-appointments. Session context (workspace, user identity) is resolved from a SessionDetail object injected by the authentication filter, not from request parameters.

Method Endpoint Purpose
POST /pre/book Book an appointment for a future date. Validates shift, assigns token.
POST /immediate/book Walk-in booking on the day of appointment. Checks live queue capacity.
GET /doctor/shifts List available shifts for a doctor on a given date. Used to populate booking UI.
PUT /{appointmentId}/update Update appointment status (COMPLETED, CANCELLED, SKIPPED, etc.).
PUT /position/update Drag-and-drop reorder: change a patient's position in the live queue.
GET /visits Paginated visit history with filters: date, doctor, patient, status, search.
GET /status?data= Public endpoint. Patient polls queue position and ETA via encrypted token.
GET /tokens/send Bulk-send token SMS/email to all patients booked for a given date.
POST /shift/start Start a doctor's shift. Marks the first BOOKED appointment as IN_PROGRESS.
POST /shift/end End a doctor's shift. Closes out remaining appointments.
GET /shift/status Check whether a specific shift has been started or ended.
POST /bulk/create Testing utility. Creates multiple appointments in one call.
GET /token/generate/all Testing utility. Generates tokens for all appointments on a date.
Standard response envelope: Every endpoint wraps its payload in ApiResponse<T> with success (boolean), message (human-readable), and data fields. This lets clients check success before deserialising the payload, and gives frontend teams predictable error messaging without inspecting HTTP status codes alone.