Routing and validation are where Groovy scripts create the most value — and the most trouble — in SAP Advanced Workflow. A routing script that sends the approval task to the wrong person costs hours of manual escalation. A validation script that returns the wrong data type silently fails, letting invalid data through. This lesson teaches you the patterns that work and the common pitfalls that don't.
Approval Routing: Getting the Right Task to the Right Person
Routing is the process of directing a workflow task to a specific person or role. The route object provides two primary methods: setApprover(userId) to route to a specific person, and setApproverByRole(roleName) to route to a role (which the system resolves to a person). Understanding when to use each, and how to construct robust routing logic, is critical.
Routing Strategy 1: Route by Hierarchy (Direct Manager)
The simplest and most common routing strategy is to send tasks up the organizational hierarchy. Route to the participant's direct manager, who approves and routes further up if necessary.
// Get manager ID safely with null handling def managerId = participant?.manager?.id ?: "DEFAULT_APPROVER" log.info("Manager ID: " + managerId) // Route to the manager route.setApprover(managerId)
This pattern is reliable but fragile if you don't handle nulls. The manager field can be null for new hires or organizational leaders. Always use safe navigation and a fallback.
Routing Strategy 2: Multi-Level Hierarchy (Skip-Level Management)
Some workflows require approval at multiple levels. You might route to the direct manager first, then require the skip-level manager's approval for large plans. The pattern is the same, but you access two levels of hierarchy.
// Access two levels of manager hierarchy def skipLevelId = participant?.manager?.manager?.id log.info("Skip-level manager ID: " + skipLevelId) // Check if skip-level exists, otherwise fall back if (skipLevelId) { route.setApprover(skipLevelId) log.info("Routed to skip-level manager") } else { // Fall back to direct manager def managerId = participant?.manager?.id ?: "DEFAULT_APPROVER" route.setApprover(managerId) log.info("Skip-level unavailable, routed to direct manager") }
This routing handles the common case where the direct manager's manager may not exist. Safe navigation chains protect you at every level.
Routing Strategy 3: Route by Plan Value Threshold
Different plan values may require different approval levels. A $50k plan goes to the manager, a $200k plan goes to a director, and a $500k plan goes to a VP. This is threshold-based routing, and it's common in compensation workflows.
// Get plan amount safely def totalAmount = plan.totalIncentiveAmount ?: 0 log.info("Total incentive: " + totalAmount) // Route based on threshold if (totalAmount > 500000) { log.info("Large plan, routing to VP") route.setApproverByRole("VP_COMPENSATION") } else if (totalAmount > 100000) { log.info("Medium plan, routing to director") route.setApproverByRole("DIRECTOR_COMPENSATION") } else { log.info("Standard plan, routing to manager") def managerId = participant?.manager?.id ?: "DEFAULT_APPROVER" route.setApprover(managerId) }
Threshold routing combines plan data with role-based routing. Notice that for small plans, we still route to the specific manager, while large plans go to roles. This is a practical pattern — roles are good for institutional approvals, individuals are good for personal relationships.
Routing Strategy 4: Route by Participant Position
Different position types in the organization may have different approval chains. Executives might require C-level review, managers require director approval, and individual contributors require manager approval.
// Get participant position def position = participant.position log.info("Participant position: " + position) // Route based on position type switch (position) { case "Executive": log.info("Executive position, routing to CFO") route.setApproverByRole("CFO") break case "Manager": log.info("Manager position, routing to director") route.setApproverByRole("DIRECTOR") break default: log.info("IC position, routing to manager") def managerId = participant?.manager?.id ?: "DEFAULT_APPROVER" route.setApprover(managerId) }
Combined Routing: Multiple Strategies
In real workflows, you often combine multiple routing strategies. Check position first, then check plan amount, then route accordingly.
// Step 1: Get participant position def position = participant.position log.info("Position: " + position) // Step 2: Get plan amount def amount = plan.totalIncentiveAmount ?: 0 log.info("Amount: " + amount) // Step 3: Route based on both position and amount if (position == "Executive") { route.setApproverByRole("CFO") } else if (amount > 500000) { // Large plans even for ICs require VP review route.setApproverByRole("VP_COMPENSATION") } else { // Standard routing to manager def managerId = participant?.manager?.id ?: "DEFAULT_APPROVER" route.setApprover(managerId) } log.info("Routing decision complete")
Escalation Routing: Handling Unavailable Approvers
A real-world problem: what if the intended approver is unavailable, on leave, or has left the company? A robust workflow includes escalation logic to route to a backup approver.
// Get manager and check if available def managerId = participant?.manager?.id // In a real system, you'd check manager's availability status // For now, we'll use a simple fallback pattern if (managerId) { log.info("Routing to manager: " + managerId) route.setApprover(managerId) } else { // No manager — escalate to compensation team log.warn("Manager unavailable, escalating to admin") route.setApproverByRole("COMPENSATION_ADMIN") } log.info("Routing complete")
Pre-Submit Validation: The Critical Map Return Type
Pre-submit validation scripts run before a workflow step completes, allowing you to block invalid submissions. This is where most validation errors occur, because developers don't return the correct data type. This single rule breaks more workflows than any other:
valid (Boolean) and message (String). Returning anything else — a String, a Boolean, null, a List — causes the validation step to fail silently. The user sees no error message, and the workflow either hangs or continues with invalid data.The Correct Return Type
// Return valid with no errors return [valid: true, message: ""] // Return invalid with error message return [valid: false, message: "Quota must be positive"] // Multi-line error message return [ valid: false, message: "Validation failed:\n" + "- Quota is zero\n" + "- Effective date is in the past" ]
The Wrong Return Types (Will Fail Silently)
// DON'T: Return a String return "Validation passed" // WRONG — causes silent failure // DON'T: Return a Boolean return true // WRONG — causes silent failure // DON'T: Return null return null // WRONG — causes silent failure
If you return a String, Boolean, or null, the validation step doesn't process your return value correctly. The user sees no error message, even if validation should fail. The workflow either hangs or continues. This is why the error type matters so much.
Complete Validation Script: Simple Quota Validation
// Validate that quota target is positive log.info("START: Validation") def quota = plan.quotaTarget ?: 0 log.info("Quota: " + quota) if (quota <= 0) { log.error("Quota validation FAILED") return [valid: false, message: "Quota target must be greater than zero"] } log.info("Quota validation PASSED") return [valid: true, message: ""]
Complete Validation Script: Multi-Field Validation
// Validate multiple fields before approval log.info("START: Multi-field validation") def errors = [] // Check 1: Quota must be positive def quota = plan.quotaTarget ?: 0 if (quota <= 0) { errors << "Quota target must be positive" } // Check 2: Plan must have at least one component if (plan?.components?.isEmpty()) { errors << "Plan must have at least one component" } // Check 3: Participant must be active if (participant.status != "ACTIVE") { errors << "Participant is not active" } // Return result if (errors.isEmpty()) { log.info("All validation checks PASSED") return [valid: true, message: ""] } else { log.error("Validation FAILED with " + errors.size() + " errors") return [valid: false, message: errors.join("\n")] }
Complete Validation Script: Plan Assignment Validation
// Validate that participant has active plan assignments log.info("START: Plan assignment validation") def assignments = participant.planAssignments ?: [] log.info("Plan assignments: " + assignments.size()) if (assignments.isEmpty()) { log.error("No plan assignments found") return [ valid: false, message: "Participant " + participant.name + " has no active plan assignments for this period" ] } log.info("Assignment validation PASSED") return [valid: true, message: ""]
Routing and Validation Strategy Comparison
| Strategy | When to Use | Advantages | Challenges |
|---|---|---|---|
| Route by Hierarchy | Standard org chart approvals | Simple, familiar, flexible for changes | Must handle null managers, new hires |
| Route by Plan Value | Different approvers for different amounts | Scales well, clear business logic | Requires definition of thresholds, plan data must be accurate |
| Route by Position | Different position types have different chains | Institution-level approval rules | Requires position type accuracy, may miss edge cases |
| Route by Role | Institutional roles (VP, Director, Admin) | Decoupled from individual people, scalable | Requires role setup in system, may route to incorrect person if role is misconfigured |
| Escalation Routing | Handle unavailable approvers | Workflow never hangs, always routes somewhere | Requires logic to detect unavailability, escalation path must be clear |
Return Type Comparison for Validation Scripts
| Return Type | Example | Result | Correct? |
|---|---|---|---|
| [valid: true, message: ""] | return [valid: true, message: ""] |
Validation passes, workflow continues | YES |
| [valid: false, message: "..."] | return [valid: false, message: "Quota is zero"] |
Validation fails, user sees error, workflow blocks | YES |
| String | return "Passed" |
Silent failure, workflow hangs or continues with no error | NO |
| Boolean | return true |
Silent failure, workflow hangs or continues with no error | NO |
| null | return null |
Silent failure, workflow hangs or continues with no error | NO |
| List | return ["error1", "error2"] |
Silent failure, workflow hangs or continues with no error | NO |
Logging in Routing and Validation
Log every routing decision and every validation check. This is the only way to debug failed workflows. Here's the pattern:
// Always log at the START and END of your script log.info("START: Routing participant " + participant.id) // Log every value you're about to use log.info("Manager ID: " + (participant?.manager?.id ?: "NONE")) log.info("Plan amount: " + (plan.totalIncentiveAmount ?: 0)) // Log every decision point if (plan.totalIncentiveAmount > 500000) { log.info("Large plan, routing to VP") route.setApproverByRole("VP") } else { log.info("Standard plan, routing to manager") route.setApprover(participant?.manager?.id ?: "DEFAULT") } log.info("END: Routing complete")
Testing Routing and Validation in Sandbox
Before deploying any routing or validation logic, test it in the sandbox with real participant and plan data:
- Create test participants with different manager hierarchies (has manager, no manager, multi-level)
- Create test plans with different amounts (below threshold, above threshold, edge cases)
- Create test participants with different positions (Executive, Manager, IC)
- Run the workflow with each test participant and verify correct routing
- For validation scripts, test with invalid data and verify you get the correct error message
- Check the workflow execution log and verify all log statements appear
Checklist: Before Deploying Routing/Validation Scripts
- Every manager access uses safe navigation:
participant?.manager?.id - Every plan value access has a fallback:
plan.totalIncentiveAmount ?: 0 - Validation scripts return a Map with
validandmessagekeys only - Routing logic covers all edge cases (no manager, empty list, null field)
- All routing decisions are logged
- All validation checks are logged with the values being checked
- Escalation logic routes to a sensible fallback if intended approver is unavailable
- Sandbox testing passes with real and edge-case data