A backend service for managing vehicle maintenance appointments at car dealerships. Automates technician assignment, manages work shifts and leaves, enforces scheduling business rules, and handles concurrent booking safely.
Since I don't use English very often in presentations, I've prepared a short summary of the main features of my project.
The project also includes several management screens, including:
Customer Management
Appointment Management
Technician Management
The main features include:
Book a single service or multiple services in one request (batch booking), with services scheduled sequentially.
Automatically select the earliest available time slot if no preferred time is provided.
Priority-based scheduling, where VIP customers are processed before normal customers.
A configurable technician scoring engine with multiple scoring criteria.
Conflict detection and distributed locking to prevent double-booking.
Suggest available time slots with guaranteed technician and service bay availability.
Manager override, including manual assignment, reassignment, locking, and unlocking assignments.
Automatically unassign appointments when a technician registers a leave request.
A background job that assigns technicians to unassigned appointments every 30 minutes, with VIP appointments processed first.
A background job that automatically cancels confirmed appointments after the configured grace period.
An SLA monitoring job that logs warnings when an appointment remains unassigned for more than 60 minutes.
A daily pricing job that calculates slot-level discounts based on service bay occupancy and automatically applies them during booking.
| Layer | Technology |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 4.1 |
| Database | PostgreSQL 16 |
| Cache | Redis 7 + Caffeine |
| Build | Maven 3.9 |
| Container | Docker / Docker Compose |
- Docker & Docker Compose — to run PostgreSQL and Redis
- Java 17+ — to run the application
- Maven 3.9+ — to build (or use the included
./mvnw)
Step 1 — Start PostgreSQL and Redis
docker-compose up -dThis creates:
- PostgreSQL at
localhost:5432, databaseappointment_db - Redis at
localhost:6379
Step 2 — Build the project
./mvnw clean package -DskipTestsStep 3 — Run the application
java -jar target/appointment-scheduler-1.0.0.jarApp starts at http://localhost:8080.
Schema and seed data are automatically created from src/main/resources/db/init.sql on first startup.
docker-compose up -d
docker build -t appointment-scheduler .
docker run -p 8080:8080 \
-e DB_URL=jdbc:postgresql://host.docker.internal:5432/appointment_db \
-e DB_USERNAME=postgres \
-e DB_PASSWORD=postgres123 \
-e REDIS_HOST=host.docker.internal \
-e REDIS_PASSWORD=redis123 \
appointment-scheduler| Variable | Default | Description |
|---|---|---|
DB_URL |
jdbc:postgresql://localhost:5432/appointment_db |
JDBC URL |
DB_USERNAME |
postgres |
DB username |
DB_PASSWORD |
postgres123 |
DB password |
REDIS_HOST |
localhost |
Redis host |
REDIS_PORT |
6379 |
Redis port |
REDIS_PASSWORD |
redis123 |
Redis password |
Controller → UseCase → DomainService → Repository (interface)
↓
RepositoryImpl → JPA Entity → PostgreSQL
Following Clean Architecture:
domain/— pure business logic, no framework dependencyapplication/— use cases, controllers, DTOsinfrastructure/— JPA, Redis, scheduled jobs
Base URL: http://localhost:8080/api/v1
All responses follow this format:
{ "success": true, "data": { ... } }
{ "success": false, "message": "error detail" }| Method | Endpoint | Description |
|---|---|---|
POST |
/appointments |
Book one or multiple services |
GET |
/appointments |
Get all (filter: ?dealershipId=&status=) |
GET |
/appointments/{id} |
Get by ID |
PATCH |
/appointments/{id}/status |
Update appointment status |
DELETE |
/appointments/{id} |
Cancel appointment |
PATCH |
/appointments/{id}/assign |
Manager force-assign a technician |
PATCH |
/appointments/{id}/reassign |
Manager re-assign to another technician |
PATCH |
/appointments/{id}/unlock-assignment |
Unlock so auto-job can re-assign |
GET |
/appointments/conflicts |
Check technician schedule conflict |
GET |
/appointments/technicians/{technicianId}/schedule |
Get technician's schedule |
GET |
/appointments/available-slots |
Suggest available slots for a service |
serviceTypes accepts one or multiple services. For batch booking, services are scheduled sequentially — each service starts at the endTime of the previous one.
If desiredTime is omitted, the system automatically finds the earliest available slot within the next 7 days.
curl -X POST http://localhost:8080/api/v1/appointments \
-H "Content-Type: application/json" \
-d '{
"customerId": "C1",
"customerName": "John Doe",
"customerPhone": "0901234567",
"customerEmail": "john@example.com",
"licensePlate": "51A-12345",
"vehicleMake": "Toyota",
"vehicleModel": "Camry",
"vehicleYear": 2022,
"serviceTypes": ["OIL_CHANGE", "TIRE_ROTATION"],
"dealershipId": "DEALER_001",
"desiredTime": "2026-07-30T09:00:00"
}'Response:
{
"success": true,
"data": {
"customerId": "C1",
"customerName": "John Doe",
"licensePlate": "51A-12345",
"dealershipId": "DEALER_001",
"services": [
{
"appointmentId": "...",
"serviceType": "OIL_CHANGE",
"startTime": "2025-12-01T09:00:00",
"endTime": "2025-12-01T09:30:00",
"technicianName": "Charlie",
"bayName": "BAY-1",
"estimatedPrice": 50.00
},
{
"appointmentId": "...",
"serviceType": "TIRE_ROTATION",
"startTime": "2025-12-01T09:30:00",
"endTime": "2025-12-01T10:30:00",
"technicianName": "Bob",
"bayName": "BAY-2",
"estimatedPrice": 40.00
}
]
}
}# All appointments
curl http://localhost:8080/api/v1/appointments
# Filter by dealership
curl "http://localhost:8080/api/v1/appointments?dealershipId=DEALER_001"
# Filter by status
curl "http://localhost:8080/api/v1/appointments?dealershipId=DEALER_001&status=INIT"Results are always sorted priority DESC, startTime ASC.
curl http://localhost:8080/api/v1/appointments/{id}Must send the current version to prevent concurrent update conflicts.
# Confirm appointment
curl -X PATCH http://localhost:8080/api/v1/appointments/{id}/status \
-H "Content-Type: application/json" \
-d '{ "status": "CONFIRMED", "version": 0 }'
# Mark in progress
curl -X PATCH http://localhost:8080/api/v1/appointments/{id}/status \
-H "Content-Type: application/json" \
-d '{ "status": "IN_PROGRESS", "version": 1 }'
# Complete with final price
curl -X PATCH http://localhost:8080/api/v1/appointments/{id}/status \
-H "Content-Type: application/json" \
-d '{ "status": "COMPLETED", "version": 2, "finalPrice": 500000 }'Valid status transitions:
INIT → CONFIRMED → IN_PROGRESS → COMPLETED
INIT → CANCELLED
CONFIRMED → CANCELLED
curl -X DELETE "http://localhost:8080/api/v1/appointments/{id}?version=0&reason=Customer+request"Manager can override any existing assignment. lock: true prevents auto-job from overwriting.
curl -X PATCH http://localhost:8080/api/v1/appointments/{id}/assign \
-H "Content-Type: application/json" \
-d '{ "technicianId": "T1", "note": "VIP customer request", "lock": true }'No need to unassign first.
curl -X PATCH http://localhost:8080/api/v1/appointments/{id}/reassign \
-H "Content-Type: application/json" \
-d '{ "technicianId": "T2", "note": "T1 called in sick" }'Returns scheduling control back to the auto-assignment job.
curl -X PATCH http://localhost:8080/api/v1/appointments/{id}/unlock-assignmentReturns true if there is a conflict, false otherwise.
curl "http://localhost:8080/api/v1/appointments/conflicts?technicianId=T1&dealershipId=DEALER_001&start=2025-12-01T09:00:00&end=2025-12-01T10:00:00"curl "http://localhost:8080/api/v1/appointments/technicians/T1/schedule?dealershipId=DEALER_001"Returns slots where both a bay and a technician are confirmed available (shift, leave, workload cap, and conflict all checked). Steps through the range in 30-minute increments.
curl "http://localhost:8080/api/v1/appointments/available-slots?dealershipId=DEALER_001&serviceType=OIL_CHANGE&from=2025-12-01T08:00:00&searchDays=3&maxResults=5"Response:
{
"success": true,
"data": [
{
"startTime": "2025-12-01T09:00:00",
"endTime": "2025-12-01T09:30:00",
"technicianId": "T3",
"technicianName": "Charlie",
"technicianLevel": "JUNIOR",
"bayId": "BAY-1"
}
]
}| Method | Endpoint | Description |
|---|---|---|
POST |
/technicians |
Create a technician |
GET |
/technicians |
Get all (filter: ?dealershipId=) |
GET |
/technicians/{id} |
Get by ID |
PUT |
/technicians/{id} |
Update technician info |
PATCH |
/technicians/{id}/deactivate |
Deactivate technician |
DELETE |
/technicians/{id} |
Delete (only if no appointment history) |
Valid levels: JUNIOR | SENIOR | EXPERT
curl -X POST http://localhost:8080/api/v1/technicians \
-H "Content-Type: application/json" \
-d '{
"name": "Alice",
"phone": "0901111111",
"email": "alice@dealer.com",
"yearsOfExperience": 8,
"level": "EXPERT",
"status": "ACTIVE",
"dealershipId": "DEALER_001",
"qualifications": ["ENGINE_REPAIR", "FULL_SERVICE", "BRAKE_INSPECTION"]
}'# All technicians
curl http://localhost:8080/api/v1/technicians
# Filter by dealership
curl "http://localhost:8080/api/v1/technicians?dealershipId=DEALER_001"curl http://localhost:8080/api/v1/technicians/T1curl -X PUT http://localhost:8080/api/v1/technicians/T1 \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Updated",
"phone": "0901111111",
"email": "alice@dealer.com",
"yearsOfExperience": 9,
"level": "EXPERT",
"status": "ACTIVE",
"dealershipId": "DEALER_001",
"qualifications": ["ENGINE_REPAIR", "FULL_SERVICE", "BRAKE_INSPECTION"]
}'curl -X PATCH http://localhost:8080/api/v1/technicians/T1/deactivateOnly allowed if the technician has no appointment history.
curl -X DELETE http://localhost:8080/api/v1/technicians/T1| Method | Endpoint | Description |
|---|---|---|
POST |
/technicians/shifts |
Create a shift |
GET |
/technicians/{technicianId}/shifts |
Get all shifts for a technician |
DELETE |
/technicians/shifts/{shiftId} |
Delete a shift |
curl -X POST http://localhost:8080/api/v1/technicians/shifts \
-H "Content-Type: application/json" \
-d '{
"technicianId": "T1",
"date": "2025-12-01",
"startTime": "08:00:00",
"endTime": "17:00:00"
}'curl http://localhost:8080/api/v1/technicians/T1/shiftscurl -X DELETE http://localhost:8080/api/v1/technicians/shifts/S1| Method | Endpoint | Description |
|---|---|---|
POST |
/technicians/leaves |
Register a leave |
GET |
/technicians/{technicianId}/leaves |
Get all leaves for a technician |
DELETE |
/technicians/leaves/{leaveId} |
Delete a leave record |
When a leave is added, the system automatically unassigns all affected appointments for that technician on that day and resets them to INIT for the auto-job to re-assign.
Valid leave types: ANNUAL_LEAVE | SICK_LEAVE | PUBLIC_HOLIDAY | BREAK
startTime/endTime = null means full-day leave. Providing values means partial leave (used for BREAK).
# Full-day sick leave
curl -X POST http://localhost:8080/api/v1/technicians/leaves \
-H "Content-Type: application/json" \
-d '{
"technicianId": "T1",
"leaveType": "SICK_LEAVE",
"date": "2025-12-01",
"startTime": null,
"endTime": null
}'
# Partial leave (break)
curl -X POST http://localhost:8080/api/v1/technicians/leaves \
-H "Content-Type: application/json" \
-d '{
"technicianId": "T1",
"leaveType": "BREAK",
"date": "2025-12-01",
"startTime": "12:00:00",
"endTime": "13:00:00"
}'curl http://localhost:8080/api/v1/technicians/T1/leavescurl -X DELETE http://localhost:8080/api/v1/technicians/leaves/{leaveId}| Method | Endpoint | Description |
|---|---|---|
GET |
/technicians/skill-matrix?dealershipId= |
Technicians and their qualifications |
GET |
/technicians/skill-matrix/by-service?dealershipId= |
Matrix grouped by service type |
curl "http://localhost:8080/api/v1/technicians/skill-matrix?dealershipId=DEALER_001"curl "http://localhost:8080/api/v1/technicians/skill-matrix/by-service?dealershipId=DEALER_001"Response:
{
"success": true,
"data": {
"OIL_CHANGE": ["Charlie"],
"TIRE_ROTATION": ["Bob", "Charlie"],
"BRAKE_INSPECTION": ["Alice", "Bob"],
"ENGINE_REPAIR": ["Alice"],
"FULL_SERVICE": ["Alice", "Bob"]
}
}| Method | Endpoint | Description |
|---|---|---|
POST |
/service-configs |
Create a config |
PUT |
/service-configs |
Update a config |
GET |
/service-configs |
Get all configs |
GET |
/service-configs/lookup?serviceType=&dealershipId= |
Lookup by service type |
DELETE |
/service-configs?serviceType=&dealershipId= |
Deactivate a config |
Valid service types: OIL_CHANGE | TIRE_ROTATION | BRAKE_INSPECTION | ENGINE_REPAIR | FULL_SERVICE
durationHours and durationMinutes are summed for total duration. At least one must be > 0.
curl -X POST http://localhost:8080/api/v1/service-configs \
-H "Content-Type: application/json" \
-d '{
"serviceType": "OIL_CHANGE",
"dealershipId": "DEALER_001",
"durationHours": 0,
"durationMinutes": 30,
"autoCancelMinutes": 15,
"estimatedPrice": 50.00
}'Response includes totalDurationMinutes for client-side ETA calculation:
{
"serviceType": "OIL_CHANGE",
"durationHours": 0,
"durationMinutes": 30,
"totalDurationMinutes": 30,
"autoCancelMinutes": 15,
"estimatedPrice": 50.00
}curl -X PUT http://localhost:8080/api/v1/service-configs \
-H "Content-Type: application/json" \
-d '{
"serviceType": "OIL_CHANGE",
"dealershipId": "DEALER_001",
"durationHours": 0,
"durationMinutes": 45,
"autoCancelMinutes": 15,
"estimatedPrice": 55.00
}'curl http://localhost:8080/api/v1/service-configscurl "http://localhost:8080/api/v1/service-configs/lookup?serviceType=OIL_CHANGE&dealershipId=DEALER_001"curl -X DELETE "http://localhost:8080/api/v1/service-configs?serviceType=OIL_CHANGE&dealershipId=DEALER_001"| Method | Endpoint | Description |
|---|---|---|
GET |
/customers |
Get all customers |
GET |
/customers/{id} |
Get by ID |
GET |
/customers/phone/{phone} |
Find by phone number |
PATCH |
/customers/{id}/tier?tier=VIP |
Update customer tier |
Valid tiers: VIP | NORMAL
Tier directly affects appointment priority at booking time (VIP=3, NORMAL=1).
curl http://localhost:8080/api/v1/customerscurl http://localhost:8080/api/v1/customers/C1curl http://localhost:8080/api/v1/customers/phone/0901234567curl -X PATCH "http://localhost:8080/api/v1/customers/C1/tier?tier=VIP"| Job | Interval | Description |
|---|---|---|
TechnicianAssignmentJob |
30 min | Assigns technicians to unassigned INIT appointments, skips locked |
AutoCancelAppointmentJob |
5 min | Cancels CONFIRMED appointments past startTime + autoCancelMinutes |
SlaMonitoringJob |
5 min | Logs WARN when an appointment has been unassigned for > 60 min |
AvailabilityStatsJob |
Configurable | Computes availability statistics |
SLA threshold is configurable via scheduler.sla.unassigned-threshold-minutes (default: 60).
Automatic Technician Assignment Job runs every 30 minutes (and immediately on booking), finds the best technician using a 6-criteria scoring engine:
- Workload (50%) — prefer less busy technicians
- Specialization (30%) — prefer specialists for the service
- Level (30%) — match service complexity to technician level
- Experience (20%) — years of experience
- Availability (20%) — remaining daily capacity
- Priority (10%) — boost for VIP appointments
Weights are configurable per dealership in the scoring_weight_config table.
Hard Cap Workload Each technician is capped at 6 jobs/day. Technicians at the cap are excluded from candidates regardless of score.
Optimistic Locking
Every status update requires the current version. Concurrent updates result in 409 Conflict.
Distributed Lock Redis locks prevent double-booking when multiple requests arrive simultaneously. Lock TTL is 30 seconds, auto-released after booking completes.
Auto Cancel
Job runs every 5 minutes, automatically cancels CONFIRMED appointments that have passed startTime + autoCancelMinutes.
Manual Override
Managers can force-assign any technician, set lock=true to prevent auto-job overrides, re-assign directly, or unlock to return control to the auto-job.
Batch Booking
POST /appointments accepts serviceTypes as an array. Services are scheduled sequentially — each starts at the endTime of the previous. Each service independently finds the best available bay and technician.
| Table | Description |
|---|---|
customers |
Customer info with tier (VIP/NORMAL) |
vehicles |
Customer vehicles (PK: license_plate) |
dealerships |
Dealership info with business hours |
service_bays |
Physical service bays at a dealership |
technicians |
Technician records |
technician_qualifications |
Service types a technician is qualified for |
technician_shifts |
Work shifts by date |
technician_leaves |
Leave records (annual, sick, holiday, break) |
appointments |
Appointments with priority, assignment_locked, version |
service_config |
Service duration, price, and auto-cancel config |
scoring_weight_config |
Scoring weights per dealership |
Available after first startup:
| Type | Data |
|---|---|
| Dealership | DEALER_001 — Main Dealership (08:00–18:00) |
| Technicians | T1 Alice (EXPERT), T2 Bob (SENIOR), T3 Charlie (JUNIOR) |
| Customers | C1 John Doe (VIP), C2 Jane Smith (NORMAL) |
| Vehicles | 51A-12345 Toyota Camry (C1), 51B-67890 Honda Civic (C2) |
| Service Bays | BAY-1, BAY-2 at DEALER_001 |
| Service Configs | 5 service types with realistic durations (dealership DEFAULT) |
| Shifts | T1, T2, T3 have shifts for today through the next 6 days (08:00–17:00) |
Default service configs:
| Service | Duration | Estimated Price |
|---|---|---|
| OIL_CHANGE | 30 min | $50 |
| TIRE_ROTATION | 60 min | $40 |
| BRAKE_INSPECTION | 105 min | $80 |
| ENGINE_REPAIR | 240 min | $300 |
| FULL_SERVICE | 180 min | $150 |
Default qualifications:
| Technician | Qualified Services |
|---|---|
| T1 Alice | ENGINE_REPAIR, FULL_SERVICE, BRAKE_INSPECTION |
| T2 Bob | BRAKE_INSPECTION, FULL_SERVICE, TIRE_ROTATION |
| T3 Charlie | OIL_CHANGE, TIRE_ROTATION |
| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created successfully |
400 |
Bad request (validation failure, missing parameter) |
404 |
Resource not found |
409 |
Conflict (business rule violation, version mismatch, or scheduling conflict) |
500 |
Internal server error |