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
- Start the application:
mvn spring-boot:run
- 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"] } ] }' - 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.