TL;DR
Routing decisions send the workflow to the correct approver using three strategies: by hierarchy (manager, skip-level), by plan value (threshold-based routing), or by participant position (position types).
Pre-submit validation scripts MUST return a Map with two keys: valid (Boolean) and message (String). Returning a String, Boolean, or any other type causes silent failure. This is the single most important rule about validation scripts.
Escalation routing handles unavailable approvers — check backup approvers and log every routing decision. Log, null-check, return the correct Map type, and test in sandbox.

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.

Basic Manager Routing
// 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.

Skip-Level Manager Routing (Two Levels)
// 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.

Threshold-Based Routing (Plan Value)
// 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.

Position-Based Routing
// 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.

Complex Routing (Position + Plan Value)
// 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.

Escalation Routing with 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:

⚠️CRITICAL: A validation script MUST return a Map with two keys: 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

CORRECT: Map 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)

WRONG: String Return (Silent Failure)
// 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

Validation: Quota Must Be Positive
// 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

Validation: Multiple Fields
// 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

Validation: Participant Has Plans
// 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:

Comprehensive Logging 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 valid and message keys 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