Branching: Conditional Routing in Workflows
Branching: Conditional Routing in Workflows
Section titled “Branching: Conditional Routing in Workflows”The Problem: One Size Doesn’t Fit All
Section titled “The Problem: One Size Doesn’t Fit All”Your insurance company processes claims. Auto claims need vehicle inspection. Property claims need on-site assessment. Health claims need medical review. Each type follows a different path.
The naive approach: Giant switch statement in a single handler:
// This quickly becomes unmaintainableif (claimType == "Auto") { /* 50 lines of auto logic */ }else if (claimType == "Property") { /* 60 lines of property logic */ }else if (claimType == "Health") { /* 40 lines of health logic */ }else { /* fallback */ }Problems:
- All logic in one place (no separation of concerns)
- Hard to test individual paths
- No visibility into which path was taken
- Adding new claim types means modifying existing code
What you need: A workflow that:
- Routes to different step sequences based on state
- Keeps each path as a separate, testable unit
- Automatically rejoins after the branch
- Generates a transition table showing all valid paths
This is the Branch pattern—conditional routing with automatic rejoining.
Learning Objectives
Section titled “Learning Objectives”After this example, you will understand:
- Branch routing based on state values
- Value matching with
whenclauses - Boolean branching for true/false decisions
- Otherwise clauses for handling unmatched values
- Multi-step branches for complex paths
- Generated transition tables showing valid paths
Conceptual Foundation
Section titled “Conceptual Foundation”Branching vs. If-Else
Section titled “Branching vs. If-Else”Traditional branching uses if-else or switch statements:
// Traditional - all in one placeswitch (claimType){ case "Auto": return ProcessAutoClaim(claim); case "Property": return ProcessPropertyClaim(claim); default: return ProcessManualReview(claim);}Workflow branching is declarative:
// Declarative - separate paths, automatic rejoining.Branch(state => state.ClaimType, when: ClaimType.Auto, then: flow => flow.Then<AutoClaimProcessor>(), when: ClaimType.Property, then: flow => flow .Then<PropertyInspection>() .Then<PropertyClaimProcessor>(), otherwise: flow => flow.Then<ManualReview>()).Finally<NotifyClaimant>() // All paths rejoin hereWhy declarative branching?
| Aspect | Traditional | Declarative |
|---|---|---|
| Visibility | Hidden in code | Visible in workflow definition |
| Testing | Test entire switch | Test each path independently |
| Rejoining | Manual bookkeeping | Automatic |
| Valid paths | Not explicit | Generated transition table |
The Branch Selector
Section titled “The Branch Selector”The branch selector extracts a value from state to determine routing:
.Branch(state => state.ClaimType, // Selector extracts ClaimType when: ClaimType.Auto, then: ..., when: ClaimType.Property, then: ..., otherwise: ...)The selector can return:
- Enum values (most common)
- Booleans (for simple true/false)
- Strings (for dynamic routing)
- Any type with equality comparison
Otherwise: The Safety Net
Section titled “Otherwise: The Safety Net”Always include an otherwise clause:
.Branch(state => state.ClaimType, when: ClaimType.Auto, then: flow => flow.Then<AutoProcessor>(), when: ClaimType.Property, then: flow => flow.Then<PropertyProcessor>(), otherwise: flow => flow.Then<ManualReview>()) // Catches unexpected valuesWhy?
- New enum values won’t crash the workflow
- Explicit fallback behavior is documented
- No silent failures
Design Decisions
Section titled “Design Decisions”| Decision | Why This Approach | Alternative | Trade-off |
|---|---|---|---|
| Enum-based selectors | Type-safe, exhaustive | Strings | Compile-time checking |
| Mandatory otherwise | No runtime exceptions | Optional | More code, but safer |
| Auto-rejoin after branch | Simpler mental model | Manual rejoin | Less flexible, but clearer |
| Multi-step paths | Complex paths supported | Single step per branch | More power |
When to Use This Pattern
Section titled “When to Use This Pattern”Good fit when:
- Different inputs need different processing
- Paths are mutually exclusive
- Paths should rejoin after branch-specific logic
- You want visibility into routing decisions
Poor fit when:
- Routing logic is highly dynamic (use custom step)
- Paths don’t rejoin (use separate workflows)
- Simple transformation (use single step with logic)
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”| Anti-Pattern | Problem | Correct Approach |
|---|---|---|
| No otherwise | Runtime exceptions on new values | Always provide otherwise |
| Overlapping conditions | Unpredictable routing | Use mutually exclusive values |
| Giant branch paths | Hard to test | Keep paths focused, use composition |
| Dynamic string selectors | No compile-time safety | Prefer enums |
| Nested branches | Complex, hard to follow | Flatten or use sub-workflows |
Building the Workflow
Section titled “Building the Workflow”The Shape First
Section titled “The Shape First” ┌─────────────────────┐ │ AutoClaimProcessor │ ┌───▶│ │────┐ │ └─────────────────────┘ │ │ │┌─────────────┐ [Auto]│ ┌──────────────────────┐ │ ┌────────────────┐│ AssessClaim │──────────┤ │ PropertyInspection │ ├──▶│ NotifyClaimant ││ │ │ │ ↓ │ │ │ ││ Classify │ [Property]───▶│ PropertyClaimProcessor│──┘ │ Send decision ││ the claim │ │ └──────────────────────┘ │ to claimant ││ │ │ └────────────────┘└─────────────┘ [Other] │ ┌─────────────────────┐ └───▶│ ManualReview │────┘ │ │ └─────────────────────┘State: What We Track
Section titled “State: What We Track”[WorkflowState]public record ClaimState : IWorkflowState{ // Identity public Guid WorkflowId { get; init; }
// Input claim public InsuranceClaim Claim { get; init; } = null!;
// Classification result (determines routing) public ClaimType ClaimType { get; init; }
// Branch-specific outputs public ClaimAssessment? Assessment { get; init; } public InspectionReport? Inspection { get; init; } // Property only
// Final decision (set by all paths) public ClaimDecision? Decision { get; init; }
// Notification status public bool ClaimantNotified { get; init; }}Why this design?
ClaimType: The routing discriminator, set byAssessClaimInspection: Only populated by the Property pathDecision: Set by all paths—the common output
The Supporting Records
Section titled “The Supporting Records”public record InsuranceClaim( string ClaimantId, string PolicyNumber, decimal Amount, string Description);
public record ClaimAssessment( ClaimType RecommendedType, decimal Confidence, string Rationale);
public record InspectionReport( string InspectorId, DateOnly InspectionDate, string Findings);
public record ClaimDecision( bool Approved, decimal ApprovedAmount, string Reason);
public enum ClaimType { Auto, Property, Health, Other }The Workflow Definition
Section titled “The Workflow Definition”var workflow = Workflow<ClaimState> .Create("process-claim") .StartWith<AssessClaim>() .Branch(state => state.ClaimType, when: ClaimType.Auto, then: flow => flow .Then<AutoClaimProcessor>(), when: ClaimType.Property, then: flow => flow .Then<PropertyInspection>() .Then<PropertyClaimProcessor>(), otherwise: flow => flow .Then<ManualReview>()) .Finally<NotifyClaimant>();Reading this definition:
- Assess the claim (classify it)
- Branch based on claim type:
- Auto claims → process automatically
- Property claims → inspect, then process
- Everything else → manual review
- All paths rejoin at NotifyClaimant
Branch Patterns
Section titled “Branch Patterns”Simple value matching:
.Branch(state => state.ClaimType, when: ClaimType.Auto, then: flow => flow.Then<AutoProcessor>(), when: ClaimType.Property, then: flow => flow.Then<PropertyProcessor>(), otherwise: flow => flow.Then<DefaultProcessor>())Boolean branching (true/false decisions):
.Branch(state => state.Amount > 10000m, whenTrue: flow => flow .AwaitApproval<SeniorAdjuster>() .Then<HighValueProcessor>(), whenFalse: flow => flow .Then<StandardProcessor>())Complex routing logic (using a helper method):
.Branch(state => ClassifyRisk(state), when: RiskLevel.Low, then: flow => flow.Then<AutoApprove>(), when: RiskLevel.Medium, then: flow => flow.Then<StandardReview>(), when: RiskLevel.High, then: flow => flow .Then<DetailedAnalysis>() .AwaitApproval<RiskCommittee>(), otherwise: flow => flow.Then<EscalateToManagement>())
private static RiskLevel ClassifyRisk(ClaimState state){ if (state.Amount < 1000m) return RiskLevel.Low; if (state.Amount < 10000m) return RiskLevel.Medium; return RiskLevel.High;}The Classification Step
Section titled “The Classification Step”public class AssessClaim : IWorkflowStep<ClaimState>{ private readonly IClaimAssessor _assessor;
public AssessClaim(IClaimAssessor assessor) { _assessor = assessor; }
public async Task<StepResult<ClaimState>> ExecuteAsync( ClaimState state, StepContext context, CancellationToken ct) { // AI or rules engine classifies the claim var assessment = await _assessor.AssessAsync(state.Claim, ct);
// Set the routing discriminator return state .With(s => s.Assessment, assessment) .With(s => s.ClaimType, assessment.RecommendedType) .AsResult(); }}The key insight: This step sets ClaimType, which determines which branch path executes.
A Branch Path Step: PropertyInspection
Section titled “A Branch Path Step: PropertyInspection”public class PropertyInspection : IWorkflowStep<ClaimState>{ private readonly IInspectionService _inspectionService;
public PropertyInspection(IInspectionService inspectionService) { _inspectionService = inspectionService; }
public async Task<StepResult<ClaimState>> ExecuteAsync( ClaimState state, StepContext context, CancellationToken ct) { // Schedule and complete inspection (might be async over days) var report = await _inspectionService.ScheduleAndCompleteAsync( state.Claim, ct);
return state .With(s => s.Inspection, report) .AsResult(); }}The Rejoin Step
Section titled “The Rejoin Step”public class NotifyClaimant : IWorkflowStep<ClaimState>{ private readonly INotificationService _notifications;
public NotifyClaimant(INotificationService notifications) { _notifications = notifications; }
public async Task<StepResult<ClaimState>> ExecuteAsync( ClaimState state, StepContext context, CancellationToken ct) { // All paths have set Decision by now await _notifications.SendClaimDecisionAsync( state.Claim.ClaimantId, state.Decision!, ct);
return state .With(s => s.ClaimantNotified, true) .AsResult(); }}Note: This step assumes Decision is set. All branch paths must populate it.
Generated Artifacts
Section titled “Generated Artifacts”Phase Enum
Section titled “Phase Enum”public enum ProcessClaimPhase{ NotStarted, AssessClaim, AutoClaimProcessor, PropertyInspection, PropertyClaimProcessor, ManualReview, NotifyClaimant, Completed, Failed}Transition Table
Section titled “Transition Table”The generator produces a transition table showing valid paths:
public static class ProcessClaimTransitions{ public static readonly IReadOnlyDictionary<ProcessClaimPhase, ProcessClaimPhase[]> Valid = new Dictionary<ProcessClaimPhase, ProcessClaimPhase[]> { [ProcessClaimPhase.AssessClaim] = [ ProcessClaimPhase.AutoClaimProcessor, ProcessClaimPhase.PropertyInspection, ProcessClaimPhase.ManualReview ], [ProcessClaimPhase.AutoClaimProcessor] = [ProcessClaimPhase.NotifyClaimant], [ProcessClaimPhase.PropertyInspection] = [ProcessClaimPhase.PropertyClaimProcessor], [ProcessClaimPhase.PropertyClaimProcessor] = [ProcessClaimPhase.NotifyClaimant], [ProcessClaimPhase.ManualReview] = [ProcessClaimPhase.NotifyClaimant], [ProcessClaimPhase.NotifyClaimant] = [ProcessClaimPhase.Completed], };}This table makes routing decisions explicit and verifiable.
The “Aha Moment”
Section titled “The “Aha Moment””Branches are declarative routing, not imperative control flow.
When you write
Branch(state => state.ClaimType, ...), you’re not writing if-else logic—you’re declaring a routing table. The generated transition table proves what paths are valid. The workflow definition documents what happens to each claim type.Six months from now, when someone asks “what happens to a property claim?”, you can point to the workflow definition—not grep through hundreds of lines of code.
Extension Exercises
Section titled “Extension Exercises”Exercise 1: Add Health Claims Path
Section titled “Exercise 1: Add Health Claims Path”Add a dedicated path for health claims:
- Add
ClaimType.Healthto the enum - Create
HealthClaimReviewstep - Create
MedicalRecordsVerificationstep - Add the path:
Health → HealthClaimReview → MedicalRecordsVerification
Exercise 2: Add Risk-Based Approval
Section titled “Exercise 2: Add Risk-Based Approval”Require approval for high-value claims:
- Add
RequiresApprovalcomputed property - Create nested branch:
if (Amount > 50000m) → AwaitApproval - Otherwise continue to processor
Exercise 3: Add Fraud Detection
Section titled “Exercise 3: Add Fraud Detection”Add a fraud check that can short-circuit any path:
- Create
FraudCheckstep that runs before branching - If fraud detected, route to
InvestigationQueue(skip all normal paths) - Use a boolean branch around the main claim type branch
Key Takeaways
Section titled “Key Takeaways”- Branch routing is declarative—visible in workflow definition
- Always include otherwise—handles unexpected values safely
- Paths automatically rejoin—no manual bookkeeping
- Multi-step paths supported—complex branch logic is fine
- Transition tables are generated—valid paths are explicit
- The selector determines routing—keep it simple and testable
Related
Section titled “Related”- Basic Workflow - Sequential steps without branching
- Fork/Join Pattern - Parallel execution (not exclusive like branching)
- Approval Flow Pattern - Human checkpoints within branches