Best Practices
Guidelines for building effective and maintainable workflows.
Workflow Design
1. Keep Workflows Focused
One workflow, one business process:
dart
// GOOD: Focused workflow
'order-approval-workflow'
'employee-onboarding-workflow'
'document-review-workflow'
// AVOID: Monolithic workflow
'do-everything-workflow'2. Use Meaningful Names
dart
// GOOD: Descriptive names
builder.task('validateOrderItems', ...);
builder.userTask('managerApproval', ...);
builder.oneOf('routeByOrderValue', ...);
// AVOID: Generic names
builder.task('step1', ...);
builder.userTask('task', ...);
builder.oneOf('gateway1', ...);3. Limit Node Count
Target minimal nodes while maintaining clarity:
| Type | Typical Count | Purpose |
|---|---|---|
| START | 1 | Single entry |
| END | 2-3 | Success, failure, cancelled |
| TASK | As needed | Real work only |
| USER TASK | Per decision | Human interaction |
| GATEWAY | Per routing | Decisions |
| SIGNAL | 1-2 | External triggers |
4. Design Output Schema First
Plan your workflow output structure:
dart
// Document expected output
/**
* Output schema:
* {
* // Input (immutable)
* entityId: string,
* submittedBy: string,
*
* // From validation
* validation: { valid: boolean, errors: string[] },
*
* // From approval
* approval: { decision: string, approvedBy: string, comments: string },
*
* // From completion
* completedAt: string,
* }
*/Task Design
1. Single Responsibility
One task, one purpose:
dart
// GOOD: Focused tasks
builder.task('validateOrder', ...);
builder.task('chargePayment', ...);
builder.task('sendConfirmation', ...);
// AVOID: Kitchen sink task
builder.task('processEverything', execute: (ctx) async {
await validate();
await charge();
await sendEmail();
await updateStatus();
// ...
});2. Combine Related Operations
But group truly related operations:
dart
// GOOD: Related operations together
builder.task('handleApproval', execute: (ctx) async {
// Use get<T> for previous node output
final entityId = ctx.get<String>('entityId')!;
final submittedBy = ctx.get<String>('submittedBy')!;
await updateStatus(entityId, 'approved');
await notifySubmitter(submittedBy);
await logAuditEvent('approved', ctx.input);
return {'approvedAt': DateTime.now().toIso8601String()};
});
// AVOID: Separate tasks for each
builder.task('updateStatus', ...);
builder.task('sendNotification', ...);
builder.task('logEvent', ...);3. Make Tasks Idempotent
Safe to retry:
dart
builder.task('createRecord', execute: (ctx) async {
// Check first - use get<T> for previous node output
final id = ctx.get<String>('id')!;
final existing = await repo.findById(id);
if (existing != null) return {'id': existing.id, 'status': 'exists'};
// Then create
final record = await repo.create(ctx.input);
return {'id': record.id, 'status': 'created'};
});4. Return Focused Output
Only what's needed downstream:
dart
// GOOD: Focused output
return TaskSuccess(output: {
'orderId': order.id,
'status': 'created',
});
// AVOID: Dumping everything
return TaskSuccess(output: order.toJson()); // 50+ fieldsGateway Design
1. Gateway-First Loop Entry
Gateway controls loop, not task:
dart
// GOOD
builder.oneOf('checkHasMore', [
Branch.whenFn((o) => o['hasMore'] == true, then: 'loopBody'),
Branch.otherwise(then: 'exitLoop'),
]);
// AVOID
builder.task('checkAndRoute', execute: (ctx) async {
if (ctx.getAny<bool>('hasMore') == true) return {'next': 'loopBody'};
return {'next': 'exitLoop'};
});2. Always Include Default Branch
Catch unexpected cases:
dart
builder.oneOf('routeDecision', [
Branch.whenFn((o) => o['decision'] == 'approved', then: 'approved'),
Branch.whenFn((o) => o['decision'] == 'rejected', then: 'rejected'),
Branch.otherwise(then: 'handleUnknown'), // Always have this
]);3. Keep Conditions Simple
Complex logic in upstream tasks:
dart
// GOOD: Task computes, gateway reads
builder.task('computeRoute', execute: (ctx) async {
final score = calculateScore(ctx.input);
return {
'routeToFastTrack': score > 80,
'requiresReview': score < 50,
};
});
builder.oneOf('route', [
Branch.whenFn((o) => o['routeToFastTrack'], then: 'fastTrack'),
Branch.whenFn((o) => o['requiresReview'], then: 'review'),
Branch.otherwise(then: 'normal'),
]);User Task Design
1. Clear, Actionable Titles
dart
// GOOD
title: 'Approve Purchase Order: {{poNumber}}'
title: 'Review Document: {{documentName}}'
// AVOID
title: 'Task'
title: 'Pending'2. Include Relevant Context
dart
builder.userTask('approval',
input: {
'entityId': '{{entityId}}',
'entityType': '{{entityType}}',
'amount': '{{amount}}',
'submittedBy': '{{submittedBy}}',
'submittedAt': '{{submittedAt}}',
// Everything needed to make a decision
},
);3. Use Namespaced storeAs
dart
// GOOD: Namespaced
storeAs: 'level1Decision'
storeAs: 'managerApproval'
// AVOID: Generic
storeAs: 'result'
storeAs: 'data'Executor Registration
1. Register via Descriptors
Executors are registered by passing WorkflowDescriptor to the engine:
dart
// Create descriptor with all executors
final myDescriptor = WorkflowDescriptor(
title: 'My Executors',
tasks: [
SendEmailExecutor.typeDescriptor,
ValidateOrderExecutor.typeDescriptor,
],
userTasks: [
ApprovalExecutor.typeDescriptor,
],
conditions: [
ApprovedCondition.typeDescriptor,
],
);
// Create context with all descriptors
final context = RegistryDeserializationContext(
descriptors: [
DefaultWorkflowDescriptor(), // Built-in executors
myDescriptor, // Custom executors
],
);
// Create engine with context and storage
final engine = WorkflowEngine(
context: context,
storage: InMemoryStorage(context: context),
);
await engine.initialize();2. Validation Happens Automatically
The engine validates executors during workflow building. If an executor is not registered, it falls back to a passthrough executor (for testing) or throws during execution if required.
Error Handling
1. Fail Fast
dart
builder.task('validate', execute: (ctx) async {
// Use get<T> for previous node output
if (ctx.get<dynamic>('required') == null) {
return TaskFailure(
errorType: ErrorType.validation,
message: 'Required field is missing',
);
}
// Continue...
});2. Classify Errors
dart
return TaskFailure(
errorType: ErrorType.validation, // Not retryable
message: 'Invalid input',
);
return TaskFailure(
errorType: ErrorType.timeout, // Retryable
message: 'Service timeout',
isRetryable: true,
);Testing
1. Unit Test Executors
dart
test('task executor validates input', () async {
final executor = MyTaskExecutor();
final context = createTestContext(input: {});
final result = await executor.execute(context);
expect(result, isA<TaskFailure>());
expect((result as TaskFailure).errorType, ErrorType.validation);
});2. Integration Test Workflows
dart
test('workflow completes on approval', () async {
final instance = await engine.startWorkflow(defId, input);
await engine.sendSignal(
workflowInstanceId: instance.id,
signalName: 'approval',
payload: {'decision': 'approved'},
);
final completed = await storage.instances.getById(instance.id);
expect(completed.status, WorkflowStatus.completed);
});Summary
| Area | Do | Don't |
|---|---|---|
| Workflows | Keep focused, name clearly | Monolithic, generic names |
| Tasks | Single purpose, idempotent | Kitchen sink, non-idempotent |
| Gateways | Default branch, simple conditions | Complex inline logic |
| User Tasks | Clear titles, relevant context | Generic titles, minimal info |
| Executors | Register early, validate | Lazy registration |
| Errors | Fail fast, classify | Swallow errors |
Next Steps
- Examples - See patterns in action
- API Reference - Complete API docs
- Glossary - Term definitions