Skip to content

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:

TypeTypical CountPurpose
START1Single entry
END2-3Success, failure, cancelled
TASKAs neededReal work only
USER TASKPer decisionHuman interaction
GATEWAYPer routingDecisions
SIGNAL1-2External 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();
  // ...
});

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+ fields

Gateway 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

AreaDoDon't
WorkflowsKeep focused, name clearlyMonolithic, generic names
TasksSingle purpose, idempotentKitchen sink, non-idempotent
GatewaysDefault branch, simple conditionsComplex inline logic
User TasksClear titles, relevant contextGeneric titles, minimal info
ExecutorsRegister early, validateLazy registration
ErrorsFail fast, classifySwallow errors

Next Steps