Approval Workflows
Patterns for building approval and review workflows.
Simple Approval
Single-level approval with approve/reject:
dart
final workflow = WorkflowBuilder(
id: 'simple-approval',
code: 'APPROVAL',
name: 'Simple Approval',
)
.start('begin')
// Validate request
.task('validate', execute: (ctx) async {
// Use getInitial<T> for original workflow input
final entityId = ctx.getInitial<String>('entityId')!;
final submittedBy = ctx.getInitial<String>('submittedBy')!;
final entity = await entityService.get(entityId);
return {
'entityId': entityId,
'entityName': entity.name,
'entityType': entity.type,
'submittedBy': submittedBy,
};
})
// Wait for approval
.userTask('approvalDecision',
schemaType: 'approval',
title: 'Approve {{entityType}}: {{entityName}}',
description: 'Please review and approve this request.',
assignToRole: 'approvers',
storeAs: 'approval',
)
// Route based on decision
.oneOf('routeDecision', [
Branch.when("approval.decision == 'approved'", then: 'processApproval'),
Branch.otherwise(then: 'processRejection'),
])
// Handle approved
.task('processApproval', execute: (ctx) async {
// Use getAny<T> for accumulated output from earlier nodes
final entityId = ctx.getAny<String>('entityId')!;
await entityService.approve(entityId);
await notifyService.notifyApproval(ctx.input);
return {'approvedAt': DateTime.now().toIso8601String()};
})
// Handle rejected
.task('processRejection', execute: (ctx) async {
// Use getAny<T> for accumulated output from earlier nodes
final entityId = ctx.getAny<String>('entityId')!;
await entityService.reject(entityId);
await notifyService.notifyRejection(ctx.input);
return {'rejectedAt': DateTime.now().toIso8601String()};
})
.end('approved')
.end('rejected')
.connect('begin', 'validate')
.connect('validate', 'approvalDecision')
.connect('approvalDecision', 'routeDecision')
.connect('processApproval', 'approved')
.connect('processRejection', 'rejected')
.build();Approval with Revision
Allow submitter to revise and resubmit:
dart
builder
.userTask('approvalDecision', storeAs: 'approval', ...)
.oneOf('routeDecision', [
Branch.whenFn((o) => o['approval']?['decision'] == 'approved', then: 'complete'),
Branch.whenFn((o) => o['approval']?['decision'] == 'revision', then: 'requestRevision'),
Branch.otherwise(then: 'rejected'),
])
// Request revision from submitter
.userTask('revisionTask',
schemaType: 'revision',
title: 'Revision Required: {{entityName}}',
assignToUser: '{{submittedBy}}', // Dynamic assignment
storeAs: 'revision',
)
// After revision, go back to approval
.connect('revisionTask', 'approvalDecision') // Loop back
.end('complete')
.end('rejected');Multi-Level Approval
Sequential approval chain:
dart
builder
// Build approval chain based on business rules
.task('buildApprovalChain', execute: (ctx) async {
// Use getInitial<T> for original workflow input
final amount = ctx.getInitial<double>('amount')!;
final type = ctx.getInitial<String>('type')!;
List<Map<String, dynamic>> chain = [];
// Supervisor always
chain.add({'level': 1, 'role': 'supervisor', 'name': 'Supervisor'});
// Manager for > $5,000
if (amount > 5000) {
chain.add({'level': 2, 'role': 'manager', 'name': 'Manager'});
}
// Director for > $25,000
if (amount > 25000) {
chain.add({'level': 3, 'role': 'director', 'name': 'Director'});
}
// VP for > $100,000
if (amount > 100000) {
chain.add({'level': 4, 'role': 'vp', 'name': 'VP'});
}
return {
'approvalChain': chain,
'currentLevel': 0,
'totalLevels': chain.length,
'hasMoreLevels': chain.isNotEmpty,
};
})
// Loop through levels (see Loop Patterns)
.oneOf('checkHasMoreLevels', ...)
.userTask('levelApproval', ...)
.oneOf('routeLevelDecision', ...)
.task('incrementLevel', ...);Parallel Approval
Multiple approvers review simultaneously:
dart
builder
// Fork to multiple reviewers
.allOf('startReviews', ['technicalReview', 'legalReview', 'financeReview'])
// Each review is a user task
.userTask('technicalReview',
schemaType: 'review',
title: 'Technical Review',
assignToRole: 'technical_reviewers',
storeAs: 'technicalReviewResult',
)
.userTask('legalReview',
schemaType: 'review',
title: 'Legal Review',
assignToRole: 'legal_reviewers',
storeAs: 'legalReviewResult',
)
.userTask('financeReview',
schemaType: 'review',
title: 'Finance Review',
assignToRole: 'finance_reviewers',
storeAs: 'financeReviewResult',
)
// Wait for all reviews
.allOf('waitForAllReviews', ['evaluateReviews'])
.connect('technicalReview', 'waitForAllReviews')
.connect('legalReview', 'waitForAllReviews')
.connect('financeReview', 'waitForAllReviews')
// Evaluate combined results
.task('evaluateReviews', execute: (ctx) async {
// Use getAny<T> for accumulated output from parallel branches
final tech = ctx.getAny<bool>('technicalReviewResult.approved') ?? false;
final legal = ctx.getAny<bool>('legalReviewResult.approved') ?? false;
final finance = ctx.getAny<bool>('financeReviewResult.approved') ?? false;
return {
'allApproved': tech && legal && finance,
'rejectionReasons': [
if (!tech) 'Technical',
if (!legal) 'Legal',
if (!finance) 'Finance',
],
};
})
.oneOf('finalDecision', [
Branch.whenFn((o) => o['allApproved'] == true, then: 'approved'),
Branch.otherwise(then: 'rejected'),
]);Escalation
Auto-escalate if not actioned in time:
dart
builder
.userTask('approvalWithDeadline',
schemaType: 'approval',
dueDate: DateTime.now().add(Duration(hours: 24)),
storeAs: 'approval',
)
// Timer sends signal if deadline passes
.signalWait('waitForDeadline', signal: 'deadline_passed', storeAs: 'deadlineResult')
// Check if timed out or completed
.oneOf('checkTimeout', [
Branch.whenFn((o) => o['approval'] != null, then: 'routeDecision'),
Branch.whenFn((o) => o['deadlineResult']?['expired'] == true, then: 'escalate'),
])
// Escalate to manager
.task('escalate', execute: (ctx) async {
// Use getAny<T> for accumulated output
final assignedTo = ctx.getAny<String>('assignedTo')!;
final managerId = await getManager(assignedTo);
return {'escalatedTo': managerId};
})
.userTask('escalatedApproval',
schemaType: 'approval',
title: 'ESCALATED: {{entityName}}',
assignToUser: '{{escalatedTo}}',
);Delegation
Allow assignee to delegate to someone else:
dart
builder
.userTask('approval', storeAs: 'decision', ...)
.oneOf('routeDecision', [
Branch.whenFn((o) => o['decision']?['action'] == 'delegate', then: 'handleDelegation'),
Branch.whenFn((o) => o['decision']?['decision'] == 'approved', then: 'approved'),
Branch.otherwise(then: 'rejected'),
])
// Create new task for delegate
.task('handleDelegation', execute: (ctx) async {
// Use get<T> with dot-notation for previous node output
return {
'delegatedTo': ctx.get<String>('decision.delegateTo'),
'delegatedBy': ctx.get<String>('decision.completedBy'),
};
})
.userTask('delegatedApproval',
schemaType: 'approval',
assignToUser: '{{delegatedTo}}',
storeAs: 'decision',
)
.connect('delegatedApproval', 'routeDecision'); // Loop backBest Practices
- Define clear approval rules - Who approves what and when
- Set appropriate deadlines - Enable escalation
- Track approval history - Store each decision
- Notify all parties - On submission, approval, rejection
- Allow revision - Not just approve/reject
- Handle delegation - Real organizations delegate
Next Steps
- Error Handling - Error patterns
- Idempotency - Crash recovery
- Best Practices - Design guidance