Automating On-Call Rotations: Building Robust Scheduling Systems in Java

On-call rotations are critical for modern DevOps and IT operations, ensuring that teams have 24/7 coverage for incident response. Building a reliable on-call rotation system in Java helps automate scheduling, reduce human error, and ensure seamless handoffs between team members.


What is On-Call Rotation Management?

On-call rotation management involves:

  • Scheduling engineers for incident response duty
  • Defining rotation patterns (weekly, daily, follow-the-sun)
  • Managing escalations when primary responders don't answer
  • Integrating with alerting systems (PagerDuty, Opsgenie, custom)
  • Tracking on-call history and compliance

System Architecture

[Rotation Scheduler] → [Schedule Storage] → [On-Call Calculator] → [Alert Manager]
|                    |                    |                   |
Define schedules      Persist rotation    Determine current    Notify on-call
and assignments       patterns and        on-call engineer     engineers via
historical data                          multiple channels

Hands-On Tutorial: Building an On-Call Rotation System

Let's build a complete on-call rotation management system with scheduling, escalation policies, and integration capabilities.

Step 1: Project Setup and Dependencies

Maven Dependencies (pom.xml):

<properties>
<spring-boot.version>3.2.0</spring-boot.version>
<quartz.version>2.3.2</quartz.version>
<jackson.version>2.16.1</jackson.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Scheduling -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Email & Notifications -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Step 2: Configuration

application.yml:

app:
oncall:
# Rotation settings
default-handoff-time: "09:00"
timezone: "America/New_York"
# Escalation settings
escalation-timeout-minutes: 15
max-escalation-level: 3
# Notification settings
notification-channels: "EMAIL,SLACK,SMS"
# Database
spring:
datasource:
url: jdbc:h2:file:./oncall-rotation;DB_CLOSE_DELAY=-1
username: sa
password: ""
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
# Email (for notifications)
mail:
host: smtp.gmail.com
port: 587
username: ${EMAIL_USERNAME}
password: ${EMAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
# Quartz Scheduling
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: always
properties:
org:
quartz:
scheduler:
instanceName: OnCallScheduler
jobStore:
class: org.quartz.impl.jdbcjobstore.JobStoreTX
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
tablePrefix: QRTZ_
isClustered: true
threadPool:
class: org.quartz.simpl.SimpleThreadPool
threadCount: 10

Step 3: Domain Models

Engineer Entity:

@Entity
@Table(name = "engineers")
public class Engineer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
private String phoneNumber;
@Column(nullable = false)
private String slackHandle;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EngineerStatus status = EngineerStatus.ACTIVE;
@ElementCollection
@CollectionTable(name = "engineer_skills", joinColumns = @JoinColumn(name = "engineer_id"))
@Column(name = "skill")
private Set<String> skills = new HashSet<>();
@Column(nullable = false)
private String timezone = "America/New_York";
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// Constructors
public Engineer() {}
public Engineer(String name, String email, String phoneNumber, String slackHandle) {
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.slackHandle = slackHandle;
}
// Business methods
public boolean isAvailable() {
return status == EngineerStatus.ACTIVE;
}
public boolean hasSkill(String skill) {
return skills.contains(skill);
}
// Getters and setters...
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPhoneNumber() { return phoneNumber; }
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
public String getSlackHandle() { return slackHandle; }
public void setSlackHandle(String slackHandle) { this.slackHandle = slackHandle; }
public EngineerStatus getStatus() { return status; }
public void setStatus(EngineerStatus status) { this.status = status; }
public Set<String> getSkills() { return skills; }
public void setSkills(Set<String> skills) { this.skills = skills; }
public String getTimezone() { return timezone; }
public void setTimezone(String timezone) { this.timezone = timezone; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
enum EngineerStatus {
ACTIVE, INACTIVE, ON_LEAVE, TERMINATED
}

On-Call Rotation Entity:

@Entity
@Table(name = "oncall_rotations")
public class OnCallRotation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private String team;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private RotationType rotationType;
@Column(nullable = false)
private Integer rotationLengthDays;
@Column(nullable = false)
private String handoffTime; // "09:00"
@Column(nullable = false)
private String timezone = "America/New_York";
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private RotationStatus status = RotationStatus.ACTIVE;
@OneToMany(mappedBy = "rotation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@OrderBy("startTime ASC")
private List<RotationSchedule> schedules = new ArrayList<>();
@OneToMany(mappedBy = "rotation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<EscalationPolicy> escalationPolicies = new ArrayList<>();
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// Constructors
public OnCallRotation() {}
public OnCallRotation(String name, String team, RotationType rotationType, 
Integer rotationLengthDays, String handoffTime) {
this.name = name;
this.team = team;
this.rotationType = rotationType;
this.rotationLengthDays = rotationLengthDays;
this.handoffTime = handoffTime;
}
// Business methods
public boolean isActive() {
return status == RotationStatus.ACTIVE;
}
public void addSchedule(RotationSchedule schedule) {
schedule.setRotation(this);
this.schedules.add(schedule);
}
public void addEscalationPolicy(EscalationPolicy policy) {
policy.setRotation(this);
this.escalationPolicies.add(policy);
}
// Getters and setters...
}
enum RotationType {
DAILY, WEEKLY, BI_WEEKLY, MONTHLY, FOLLOW_THE_SUN
}
enum RotationStatus {
ACTIVE, PAUSED, ARCHIVED
}

Rotation Schedule Entity:

@Entity
@Table(name = "rotation_schedules")
public class RotationSchedule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "rotation_id", nullable = false)
private OnCallRotation rotation;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "engineer_id", nullable = false)
private Engineer engineer;
@Column(nullable = false)
private LocalDateTime startTime;
@Column(nullable = false)
private LocalDateTime endTime;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ScheduleStatus status = ScheduleStatus.SCHEDULED;
private String notes;
@CreationTimestamp
private LocalDateTime createdAt;
// Constructors
public RotationSchedule() {}
public RotationSchedule(Engineer engineer, LocalDateTime startTime, LocalDateTime endTime) {
this.engineer = engineer;
this.startTime = startTime;
this.endTime = endTime;
}
// Business methods
public boolean isActive() {
LocalDateTime now = LocalDateTime.now();
return !startTime.isAfter(now) && !endTime.isBefore(now) && 
status == ScheduleStatus.ACTIVE;
}
public boolean isFuture() {
return startTime.isAfter(LocalDateTime.now());
}
public boolean overlapsWith(LocalDateTime checkStart, LocalDateTime checkEnd) {
return !(endTime.isBefore(checkStart) || startTime.isAfter(checkEnd));
}
// Getters and setters...
}
enum ScheduleStatus {
SCHEDULED, ACTIVE, COMPLETED, CANCELLED, OVERRIDDEN
}

Escalation Policy Entity:

@Entity
@Table(name = "escalation_policies")
public class EscalationPolicy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "rotation_id", nullable = false)
private OnCallRotation rotation;
@Column(nullable = false)
private Integer level; // 1 = primary, 2 = secondary, etc.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "engineer_id")
private Engineer engineer; // Specific engineer at this level
private String team; // Or specific team at this level
@Column(nullable = false)
private Integer timeoutMinutes; // Minutes before escalating to next level
@ElementCollection
@CollectionTable(name = "escalation_notification_channels", 
joinColumns = @JoinColumn(name = "escalation_policy_id"))
@Column(name = "channel")
@Enumerated(EnumType.STRING)
private Set<NotificationChannel> notificationChannels = new HashSet<>();
@CreationTimestamp
private LocalDateTime createdAt;
// Constructors
public EscalationPolicy() {}
public EscalationPolicy(Integer level, Integer timeoutMinutes) {
this.level = level;
this.timeoutMinutes = timeoutMinutes;
}
// Getters and setters...
}
enum NotificationChannel {
EMAIL, SLACK, SMS, PHONE_CALL, PAGER_DUTY, TEAMS
}

Step 4: Core Rotation Service

@Service
@Transactional
public class OnCallRotationService {
private final EngineerRepository engineerRepository;
private final OnCallRotationRepository rotationRepository;
private final RotationScheduleRepository scheduleRepository;
private final EscalationPolicyRepository escalationPolicyRepository;
private final NotificationService notificationService;
private final ShiftCalculator shiftCalculator;
private static final Logger logger = LoggerFactory.getLogger(OnCallRotationService.class);
public OnCallRotationService(EngineerRepository engineerRepository,
OnCallRotationRepository rotationRepository,
RotationScheduleRepository scheduleRepository,
EscalationPolicyRepository escalationPolicyRepository,
NotificationService notificationService,
ShiftCalculator shiftCalculator) {
this.engineerRepository = engineerRepository;
this.rotationRepository = rotationRepository;
this.scheduleRepository = scheduleRepository;
this.escalationPolicyRepository = escalationPolicyRepository;
this.notificationService = notificationService;
this.shiftCalculator = shiftCalculator;
}
/**
* Create a new on-call rotation
*/
public OnCallRotation createRotation(RotationCreateRequest request) {
validateRotationRequest(request);
OnCallRotation rotation = new OnCallRotation(
request.getName(),
request.getTeam(),
request.getRotationType(),
request.getRotationLengthDays(),
request.getHandoffTime()
);
rotation.setDescription(request.getDescription());
rotation.setTimezone(request.getTimezone());
// Save rotation first to get ID
rotation = rotationRepository.save(rotation);
// Add escalation policies
if (request.getEscalationPolicies() != null) {
for (EscalationPolicyRequest policyRequest : request.getEscalationPolicies()) {
EscalationPolicy policy = createEscalationPolicy(policyRequest);
rotation.addEscalationPolicy(policy);
}
}
logger.info("Created new on-call rotation: {} for team: {}", 
rotation.getName(), rotation.getTeam());
return rotationRepository.save(rotation);
}
/**
* Schedule engineers for a rotation
*/
public void scheduleRotation(Long rotationId, RotationScheduleRequest request) {
OnCallRotation rotation = rotationRepository.findById(rotationId)
.orElseThrow(() -> new RotationNotFoundException("Rotation not found: " + rotationId));
if (!rotation.isActive()) {
throw new RotationException("Cannot schedule inactive rotation");
}
LocalDateTime startTime = request.getStartTime();
LocalDateTime endTime = request.getEndTime();
validateScheduleOverlap(rotationId, startTime, endTime);
for (ScheduleAssignment assignment : request.getAssignments()) {
Engineer engineer = engineerRepository.findById(assignment.getEngineerId())
.orElseThrow(() -> new EngineerNotFoundException("Engineer not found: " + assignment.getEngineerId()));
if (!engineer.isAvailable()) {
throw new RotationException("Engineer is not available: " + engineer.getName());
}
RotationSchedule schedule = new RotationSchedule(
engineer,
assignment.getStartTime(),
assignment.getEndTime()
);
schedule.setNotes(assignment.getNotes());
rotation.addSchedule(schedule);
logger.info("Scheduled engineer {} for rotation {} from {} to {}", 
engineer.getName(), rotation.getName(), 
assignment.getStartTime(), assignment.getEndTime());
}
rotationRepository.save(rotation);
// Notify engineers of their upcoming shifts
notifyUpcomingShifts(rotation);
}
/**
* Get current on-call engineer for a rotation
*/
public Engineer getCurrentOnCallEngineer(Long rotationId) {
OnCallRotation rotation = rotationRepository.findById(rotationId)
.orElseThrow(() -> new RotationNotFoundException("Rotation not found: " + rotationId));
LocalDateTime now = LocalDateTime.now();
Optional<RotationSchedule> currentSchedule = scheduleRepository
.findCurrentSchedule(rotationId, now);
if (currentSchedule.isEmpty()) {
throw new NoOnCallEngineerException("No engineer currently on-call for rotation: " + rotation.getName());
}
return currentSchedule.get().getEngineer();
}
/**
* Get on-call escalation chain for a rotation
*/
public List<EscalationTarget> getEscalationChain(Long rotationId) {
OnCallRotation rotation = rotationRepository.findById(rotationId)
.orElseThrow(() -> new RotationNotFoundException("Rotation not found: " + rotationId));
List<EscalationPolicy> policies = escalationPolicyRepository
.findByRotationIdOrderByLevelAsc(rotationId);
return policies.stream()
.map(this::mapToEscalationTarget)
.collect(Collectors.toList());
}
/**
* Generate rotation schedule for a period
*/
public List<RotationSchedule> generateSchedule(Long rotationId, 
LocalDateTime startDate, 
LocalDateTime endDate) {
OnCallRotation rotation = rotationRepository.findById(rotationId)
.orElseThrow(() -> new RotationNotFoundException("Rotation not found: " + rotationId));
List<Engineer> availableEngineers = engineerRepository
.findByStatusAndTeam(EngineerStatus.ACTIVE, rotation.getTeam());
if (availableEngineers.isEmpty()) {
throw new RotationException("No available engineers for team: " + rotation.getTeam());
}
return shiftCalculator.generateSchedule(rotation, availableEngineers, startDate, endDate);
}
/**
* Handle shift handoff between engineers
*/
public void executeShiftHandoff(Long rotationId) {
OnCallRotation rotation = rotationRepository.findById(rotationId)
.orElseThrow(() -> new RotationNotFoundException("Rotation not found: " + rotationId));
LocalDateTime now = LocalDateTime.now();
// Find ending shift
Optional<RotationSchedule> endingShift = scheduleRepository
.findEndingShift(rotationId, now);
// Find starting shift
Optional<RotationSchedule> startingShift = scheduleRepository
.findStartingShift(rotationId, now);
if (endingShift.isPresent() && startingShift.isPresent()) {
RotationSchedule ending = endingShift.get();
RotationSchedule starting = startingShift.get();
// Mark shifts as completed/active
ending.setStatus(ScheduleStatus.COMPLETED);
starting.setStatus(ScheduleStatus.ACTIVE);
scheduleRepository.saveAll(List.of(ending, starting));
// Notify engineers
notificationService.notifyShiftHandoff(
ending.getEngineer(), 
starting.getEngineer(), 
rotation
);
logger.info("Executed shift handoff for rotation {}: {} -> {}", 
rotation.getName(), ending.getEngineer().getName(), 
starting.getEngineer().getName());
}
}
/**
* Override schedule for emergency situations
*/
public RotationSchedule overrideSchedule(ScheduleOverrideRequest request) {
Engineer engineer = engineerRepository.findById(request.getEngineerId())
.orElseThrow(() -> new EngineerNotFoundException("Engineer not found: " + request.getEngineerId()));
// Cancel or modify existing schedules in the override period
List<RotationSchedule> affectedSchedules = scheduleRepository
.findOverlappingSchedules(request.getRotationId(), 
request.getStartTime(), request.getEndTime());
for (RotationSchedule schedule : affectedSchedules) {
if (schedule.isActive()) {
schedule.setStatus(ScheduleStatus.OVERRIDDEN);
}
}
// Create override schedule
RotationSchedule override = new RotationSchedule(
engineer,
request.getStartTime(),
request.getEndTime()
);
override.setNotes("OVERRIDE: " + request.getReason());
override.setStatus(ScheduleStatus.ACTIVE);
OnCallRotation rotation = rotationRepository.findById(request.getRotationId())
.orElseThrow(() -> new RotationNotFoundException("Rotation not found: " + request.getRotationId()));
rotation.addSchedule(override);
scheduleRepository.saveAll(affectedSchedules);
rotationRepository.save(rotation);
// Notify about override
notificationService.notifyScheduleOverride(override, request.getReason());
logger.info("Created schedule override for rotation {}: engineer {} from {} to {}", 
rotation.getName(), engineer.getName(), 
request.getStartTime(), request.getEndTime());
return override;
}
private void validateRotationRequest(RotationCreateRequest request) {
if (request.getRotationLengthDays() <= 0) {
throw new RotationException("Rotation length must be positive");
}
if (!isValidTimeFormat(request.getHandoffTime())) {
throw new RotationException("Invalid handoff time format. Use HH:mm");
}
}
private void validateScheduleOverlap(Long rotationId, LocalDateTime startTime, LocalDateTime endTime) {
List<RotationSchedule> overlapping = scheduleRepository
.findOverlappingSchedules(rotationId, startTime, endTime);
if (!overlapping.isEmpty()) {
throw new RotationException("Schedule overlaps with existing schedules");
}
}
private boolean isValidTimeFormat(String time) {
try {
LocalTime.parse(time);
return true;
} catch (DateTimeParseException e) {
return false;
}
}
private EscalationTarget mapToEscalationTarget(EscalationPolicy policy) {
EscalationTarget target = new EscalationTarget();
target.setLevel(policy.getLevel());
target.setTimeoutMinutes(policy.getTimeoutMinutes());
target.setNotificationChannels(policy.getNotificationChannels());
if (policy.getEngineer() != null) {
target.setEngineer(policy.getEngineer());
} else if (policy.getTeam() != null) {
target.setTeam(policy.getTeam());
}
return target;
}
private void notifyUpcomingShifts(OnCallRotation rotation) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime tomorrow = now.plusDays(1);
List<RotationSchedule> upcomingShifts = scheduleRepository
.findUpcomingShifts(rotation.getId(), now, tomorrow);
for (RotationSchedule shift : upcomingShifts) {
notificationService.notifyUpcomingShift(shift);
}
}
}

Step 5: Shift Calculation Engine

@Service
public class ShiftCalculator {
/**
* Generate automated schedule based on rotation type
*/
public List<RotationSchedule> generateSchedule(OnCallRotation rotation,
List<Engineer> availableEngineers,
LocalDateTime startDate,
LocalDateTime endDate) {
List<RotationSchedule> schedules = new ArrayList<>();
LocalDateTime currentTime = startDate;
int engineerIndex = 0;
while (currentTime.isBefore(endDate)) {
LocalDateTime shiftEnd = calculateShiftEnd(rotation, currentTime);
if (shiftEnd.isAfter(endDate)) {
shiftEnd = endDate;
}
Engineer engineer = availableEngineers.get(engineerIndex);
RotationSchedule schedule = new RotationSchedule(engineer, currentTime, shiftEnd);
schedules.add(schedule);
// Move to next engineer (round-robin)
engineerIndex = (engineerIndex + 1) % availableEngineers.size();
currentTime = shiftEnd;
}
return schedules;
}
private LocalDateTime calculateShiftEnd(OnCallRotation rotation, LocalDateTime startTime) {
return switch (rotation.getRotationType()) {
case DAILY -> startTime.plusDays(1);
case WEEKLY -> startTime.plusDays(7);
case BI_WEEKLY -> startTime.plusDays(14);
case MONTHLY -> startTime.plusMonths(1);
case FOLLOW_THE_SUN -> calculateFollowTheSunShiftEnd(startTime);
};
}
private LocalDateTime calculateFollowTheSunShiftEnd(LocalDateTime startTime) {
// Follow-the-sun: 8-hour shifts covering different timezones
return startTime.plusHours(8);
}
/**
* Calculate fair distribution of on-call hours
*/
public Map<Engineer, Duration> calculateOnCallHours(List<RotationSchedule> schedules,
LocalDateTime startDate,
LocalDateTime endDate) {
Map<Engineer, Duration> hoursByEngineer = new HashMap<>();
for (RotationSchedule schedule : schedules) {
if (schedule.getStartTime().isBefore(endDate) && 
schedule.getEndTime().isAfter(startDate)) {
LocalDateTime effectiveStart = schedule.getStartTime().isBefore(startDate) ? 
startDate : schedule.getStartTime();
LocalDateTime effectiveEnd = schedule.getEndTime().isAfter(endDate) ? 
endDate : schedule.getEndTime();
Duration duration = Duration.between(effectiveStart, effectiveEnd);
hoursByEngineer.merge(schedule.getEngineer(), duration, Duration::plus);
}
}
return hoursByEngineer;
}
}

Step 6: Notification Service

@Service
public class NotificationService {
private final JavaMailSender mailSender;
private final SlackService slackService;
private final SmsService smsService;
private static final Logger logger = LoggerFactory.getLogger(NotificationService.class);
public NotificationService(JavaMailSender mailSender, 
SlackService slackService,
SmsService smsService) {
this.mailSender = mailSender;
this.slackService = slackService;
this.smsService = smsService;
}
/**
* Notify engineer of upcoming shift
*/
public void notifyUpcomingShift(RotationSchedule schedule) {
Engineer engineer = schedule.getEngineer();
OnCallRotation rotation = schedule.getRotation();
String subject = String.format("Upcoming On-Call Shift: %s", rotation.getName());
String message = String.format(
"Hello %s,\n\nYou have an upcoming on-call shift for %s.\n" +
"Start: %s\nEnd: %s\n\nPlease be prepared to respond to incidents.",
engineer.getName(), rotation.getName(),
formatDateTime(schedule.getStartTime()),
formatDateTime(schedule.getEndTime())
);
// Send email
sendEmail(engineer.getEmail(), subject, message);
// Send Slack notification
slackService.sendMessage(engineer.getSlackHandle(), message);
logger.info("Notified engineer {} of upcoming shift", engineer.getName());
}
/**
* Notify shift handoff
*/
public void notifyShiftHandoff(Engineer endingEngineer, 
Engineer startingEngineer, 
OnCallRotation rotation) {
// Notify ending engineer
String endSubject = String.format("On-Call Shift Completed: %s", rotation.getName());
String endMessage = String.format(
"Hello %s,\n\nYour on-call shift for %s has ended. Thank you for your coverage!",
endingEngineer.getName(), rotation.getName()
);
sendEmail(endingEngineer.getEmail(), endSubject, endMessage);
// Notify starting engineer
String startSubject = String.format("On-Call Shift Started: %s", rotation.getName());
String startMessage = String.format(
"Hello %s,\n\nYour on-call shift for %s has started. You are now the primary responder.",
startingEngineer.getName(), rotation.getName()
);
sendEmail(startingEngineer.getEmail(), startSubject, startMessage);
// Team notification
slackService.sendTeamMessage(rotation.getTeam(), 
String.format("On-call handoff: %s → %s", 
endingEngineer.getName(), startingEngineer.getName()));
}
/**
* Escalate incident through notification channels
*/
public void escalateIncident(EscalationTarget target, String incidentId, String message) {
String escalationMessage = String.format(
"ESCALATION Level %d: %s\nIncident: %s", 
target.getLevel(), message, incidentId
);
for (NotificationChannel channel : target.getNotificationChannels()) {
switch (channel) {
case EMAIL:
if (target.getEngineer() != null) {
sendEmail(target.getEngineer().getEmail(), 
"Incident Escalation", escalationMessage);
}
break;
case SLACK:
if (target.getEngineer() != null) {
slackService.sendUrgentMessage(
target.getEngineer().getSlackHandle(), escalationMessage);
}
break;
case SMS:
if (target.getEngineer() != null) {
smsService.sendSms(
target.getEngineer().getPhoneNumber(), escalationMessage);
}
break;
case PHONE_CALL:
if (target.getEngineer() != null) {
smsService.makePhoneCall(
target.getEngineer().getPhoneNumber(), escalationMessage);
}
break;
}
}
logger.info("Escalated incident {} to level {}", incidentId, target.getLevel());
}
private void sendEmail(String to, String subject, String body) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(body);
mailSender.send(message);
} catch (Exception e) {
logger.error("Failed to send email to: {}", to, e);
}
}
private String formatDateTime(LocalDateTime dateTime) {
return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z"));
}
}

Step 7: REST Controllers

@RestController
@RequestMapping("/api/oncall")
public class OnCallController {
private final OnCallRotationService rotationService;
public OnCallController(OnCallRotationService rotationService) {
this.rotationService = rotationService;
}
@PostMapping("/rotations")
public ResponseEntity<OnCallRotation> createRotation(@RequestBody @Valid RotationCreateRequest request) {
OnCallRotation rotation = rotationService.createRotation(request);
return ResponseEntity.status(201).body(rotation);
}
@PostMapping("/rotations/{rotationId}/schedule")
public ResponseEntity<Void> scheduleRotation(@PathVariable Long rotationId,
@RequestBody @Valid RotationScheduleRequest request) {
rotationService.scheduleRotation(rotationId, request);
return ResponseEntity.accepted().build();
}
@GetMapping("/rotations/{rotationId}/current")
public ResponseEntity<Engineer> getCurrentOnCall(@PathVariable Long rotationId) {
Engineer engineer = rotationService.getCurrentOnCallEngineer(rotationId);
return ResponseEntity.ok(engineer);
}
@GetMapping("/rotations/{rotationId}/escalation-chain")
public ResponseEntity<List<EscalationTarget>> getEscalationChain(@PathVariable Long rotationId) {
List<EscalationTarget> chain = rotationService.getEscalationChain(rotationId);
return ResponseEntity.ok(chain);
}
@PostMapping("/rotations/{rotationId}/override")
public ResponseEntity<RotationSchedule> overrideSchedule(@PathVariable Long rotationId,
@RequestBody @Valid ScheduleOverrideRequest request) {
request.setRotationId(rotationId);
RotationSchedule override = rotationService.overrideSchedule(request);
return ResponseEntity.ok(override);
}
@PostMapping("/rotations/{rotationId}/handoff")
public ResponseEntity<Void> executeHandoff(@PathVariable Long rotationId) {
rotationService.executeShiftHandoff(rotationId);
return ResponseEntity.accepted().build();
}
}
// DTO Classes
class RotationCreateRequest {
@NotBlank
private String name;
@NotBlank
private String team;
@NotNull
private RotationType rotationType;
@NotNull
@Min(1)
private Integer rotationLengthDays;
@NotBlank
private String handoffTime;
private String description;
private String timezone = "America/New_York";
private List<EscalationPolicyRequest> escalationPolicies;
// Getters and setters...
}
class RotationScheduleRequest {
@NotNull
private LocalDateTime startTime;
@NotNull
private LocalDateTime endTime;
@NotEmpty
private List<ScheduleAssignment> assignments;
// Getters and setters...
}
class ScheduleOverrideRequest {
@NotNull
private Long rotationId;
@NotNull
private Long engineerId;
@NotNull
private LocalDateTime startTime;
@NotNull
private LocalDateTime endTime;
@NotBlank
private String reason;
// Getters and setters...
}

Step 8: Quartz Scheduler for Automated Handoffs

@Component
public class ShiftHandoffScheduler {
private final Scheduler scheduler;
private final OnCallRotationService rotationService;
public ShiftHandoffScheduler(Scheduler scheduler, 
OnCallRotationService rotationService) {
this.scheduler = scheduler;
this.rotationService = rotationService;
}
@PostConstruct
public void scheduleHandoffJobs() {
try {
// Schedule handoff job to run every minute
JobDetail job = JobBuilder.newJob(ShiftHandoffJob.class)
.withIdentity("shiftHandoffJob", "onCallGroup")
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("shiftHandoffTrigger", "onCallGroup")
.startNow()
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInMinutes(1)
.repeatForever())
.build();
scheduler.scheduleJob(job, trigger);
} catch (SchedulerException e) {
throw new RuntimeException("Failed to schedule handoff job", e);
}
}
}
public class ShiftHandoffJob implements Job {
private static final Logger logger = LoggerFactory.getLogger(ShiftHandoffJob.class);
@Autowired
private OnCallRotationService rotationService;
@Autowired
private OnCallRotationRepository rotationRepository;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
// Get all active rotations
List<OnCallRotation> activeRotations = rotationRepository.findByStatus(RotationStatus.ACTIVE);
for (OnCallRotation rotation : activeRotations) {
try {
rotationService.executeShiftHandoff(rotation.getId());
} catch (Exception e) {
logger.error("Failed to execute handoff for rotation: {}", rotation.getName(), e);
}
}
logger.debug("Executed shift handoff check for {} rotations", activeRotations.size());
} catch (Exception e) {
logger.error("Error in shift handoff job", e);
throw new JobExecutionException(e);
}
}
}

Running the Application

  1. Start the application:
   mvn spring-boot:run
  1. Create a rotation:
   curl -X POST http://localhost:8080/api/oncall/rotations \
-H "Content-Type: application/json" \
-d '{
"name": "Production Support",
"team": "Platform Engineering", 
"rotationType": "WEEKLY",
"rotationLengthDays": 7,
"handoffTime": "09:00",
"escalationPolicies": [
{
"level": 1,
"timeoutMinutes": 15,
"notificationChannels": ["SLACK", "EMAIL"]
}
]
}'
  1. Get current on-call:
   curl http://localhost:8080/api/oncall/rotations/1/current

Best Practices

1. Fairness and Compliance

  • Track on-call hours to prevent burnout
  • Implement minimum rest periods between shifts
  • Consider timezone preferences for follow-the-sun
  • Provide adequate compensation for on-call duty

2. Reliability

  • Implement circuit breakers for notification services
  • Use database transactions for schedule updates
  • Maintain audit logs of all schedule changes
  • Implement retry mechanisms for failed handoffs

3. Integration

  • Webhook endpoints for external systems
  • REST APIs for schedule queries
  • Export capabilities for compliance reporting
  • Integration with calendar systems

Conclusion

Building an on-call rotation system in Java provides:

  • Automated scheduling that reduces manual effort
  • Reliable escalations ensuring incidents are never missed
  • Fair distribution of on-call responsibilities
  • Comprehensive auditing for compliance requirements
  • Flexible integration with existing alerting systems

By implementing this system, organizations can ensure 24/7 incident coverage while maintaining engineer wellbeing and operational efficiency.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper