Skip to content

Condition Executors

Condition executors evaluate routing conditions for gateways. In the unified model, ConditionExecutor is both configuration and executor - it's deserialized directly from JSON on workflow edges.

ConditionExecutor Interface

dart
abstract class ConditionExecutor implements SchemaItem {
  const ConditionExecutor({
    required this.schemaType,
    this.name,
  });

  /// Schema type identifier (e.g., 'condition.highValue')
  final String schemaType;

  /// Optional name for display
  final String? name;

  /// Evaluate the condition
  Future<bool> execute(ExecutionContext context);

  /// Serialize to JSON for storage
  Map<String, dynamic> toJson();
}

How Conditions Work

Conditions are stored on edges and deserialized directly to executable instances:

json
{
  "id": "e1",
  "sourceNodeId": "gateway",
  "targetNodeId": "approved",
  "label": "Approved",
  "condition": {
    "schemaType": "condition.approved",
    "name": "Check if approved"
  }
}

When the workflow is loaded, condition.approved is looked up in the registry and the condition is deserialized to an ApprovedCondition instance that can be immediately executed.

Implementing Condition Executors

Simple Condition

dart
class ApprovedCondition extends ConditionExecutor {
  static const _schemaType = 'condition.approved';

  ApprovedCondition() : super(schemaType: _schemaType);

  @override
  Future<bool> execute(ExecutionContext context) async {
    // Use get<T> for previous node output
    final decision = context.get<String>('decision');
    return decision == 'approved';
  }

  static final typeDescriptor = TypeDescriptor<ConditionExecutor>(
    schemaType: _schemaType,
    fromJson: (_) => ApprovedCondition(),
    title: 'Approved Condition',
  );
}

Parameterized Condition

dart
class ThresholdCondition extends ConditionExecutor {
  static const _schemaType = 'condition.threshold';
  final String field;
  final num threshold;
  final String operator; // '>', '<', '>=', '<=', '=='

  ThresholdCondition({
    required this.field,
    required this.threshold,
    this.operator = '>',
  }) : super(schemaType: _schemaType);

  @override
  Future<bool> execute(ExecutionContext context) async {
    // Use get<T> with the parameterized field name
    final value = context.get<num>(field);
    if (value == null) return false;

    switch (operator) {
      case '>': return value > threshold;
      case '<': return value < threshold;
      case '>=': return value >= threshold;
      case '<=': return value <= threshold;
      case '==': return value == threshold;
      default: return false;
    }
  }

  static TypeDescriptor<ConditionExecutor> typeDescriptor() {
    return TypeDescriptor<ConditionExecutor>(
      schemaType: _schemaType,
      fromJson: (json) => ThresholdCondition(
        field: json['field'] as String,
        threshold: json['threshold'] as num,
        operator: json['operator'] as String? ?? '>',
      ),
      title: 'Threshold Condition',
    );
  }
}

Async Condition (with external lookup)

dart
class HasPermissionCondition extends ConditionExecutor {
  static const _schemaType = 'condition.hasPermission';
  final PermissionService permissionService;
  final String permission;

  HasPermissionCondition(this.permissionService, this.permission)
      : super(schemaType: _schemaType);

  @override
  Future<bool> execute(ExecutionContext context) async {
    // Use get<T> for previous node output
    final userId = context.get<String>('userId');
    if (userId == null) return false;

    return await permissionService.hasPermission(userId, permission);
  }
}

Using Condition Executors

In Gateway Branches

dart
// Register via descriptor
final descriptor = WorkflowDescriptor(
  title: 'Conditions',
  conditions: [
    ApprovedCondition.typeDescriptor,
    ThresholdCondition.typeDescriptor(),
  ],
);

// Create context with descriptors
final context = RegistryDeserializationContext(
  descriptors: [DefaultWorkflowDescriptor(), descriptor],
);

// Create engine with context
final engine = WorkflowEngine(
  context: context,
  storage: InMemoryStorage(context: context),
);
await engine.initialize();

// Use in workflow with function-based conditions (most common)
builder.oneOf('routeDecision', [
  Branch.whenFn((o) => o['decision'] == 'approved', then: 'handleApproved', label: 'Approved'),
  Branch.whenFn((o) => (o['amount'] ?? 0) > 10000, then: 'requireManagerApproval', label: 'High Value'),
  Branch.otherwise(then: 'handleRejected'),
]);

Function-Based Conditions

For simple conditions, use inline functions:

dart
builder.oneOf('routeByStatus', [
  Branch.whenFn(
    (output) => output['status'] == 'active',
    then: 'processActive',
    label: 'Active',
  ),
  Branch.whenFn(
    (output) => output['status'] == 'pending',
    then: 'processPending',
    label: 'Pending',
  ),
  Branch.otherwise(then: 'processOther'),
]);

Common Condition Patterns

Boolean Field Check

dart
class IsValidCondition extends ConditionExecutor {
  static const _schemaType = 'condition.isValid';

  IsValidCondition() : super(schemaType: _schemaType);

  @override
  Future<bool> execute(ExecutionContext context) async {
    // Use get<T> for previous node output
    return context.get<bool>('isValid') == true;
  }
}

String Comparison

dart
class StatusEqualsCondition extends ConditionExecutor {
  static const _schemaType = 'condition.statusEquals';
  final String expectedStatus;

  StatusEqualsCondition(this.expectedStatus)
      : super(schemaType: _schemaType);

  @override
  Future<bool> execute(ExecutionContext context) async {
    // Use get<T> for previous node output
    return context.get<String>('status') == expectedStatus;
  }
}

Nested Field Access

dart
class NestedFieldCondition extends ConditionExecutor {
  static const _schemaType = 'condition.nestedField';
  final String path;  // Dot-notation path like 'level1Decision.decision'
  final dynamic expectedValue;

  NestedFieldCondition(this.path, this.expectedValue)
      : super(schemaType: _schemaType);

  @override
  Future<bool> execute(ExecutionContext context) async {
    // Use get<T> with dot-notation path for nested access
    final value = context.get<dynamic>(path);
    return value == expectedValue;
  }
}

// More common: Use inline function or dot-notation path
Branch.whenFn(
  (o) => o['level1Decision']?['decision'] == 'approved',
  then: 'nextLevel',
),

List Contains

dart
class ListContainsCondition extends ConditionExecutor {
  static const _schemaType = 'condition.listContains';
  final String listField;
  final dynamic value;

  ListContainsCondition(this.listField, this.value)
      : super(schemaType: _schemaType);

  @override
  Future<bool> execute(ExecutionContext context) async {
    // Use get<T> for previous node output
    final list = context.get<List>(listField);
    return list?.contains(value) ?? false;
  }
}

Condition Priority

Conditions are evaluated in priority order:

dart
builder.oneOf('decide', [
  Branch.whenFn((o) => o['urgent'] == true, then: 'urgentPath', priority: 100),
  Branch.whenFn((o) => o['important'] == true, then: 'importantPath', priority: 50),
  Branch.whenFn((o) => true, then: 'normalPath', priority: 10),
  Branch.otherwise(then: 'fallbackPath'),  // priority: 0
]);

Higher priority = evaluated first.

Best Practices

  1. Keep conditions simple - Single responsibility
  2. Use descriptive schemaType names - condition.hasApproval not cond1
  3. Handle null values - Check before accessing
  4. Use function conditions for one-offs - Branch.whenFn
  5. Use executor conditions for reusable logic - Custom ConditionExecutor
  6. Always include default branch - Catch unexpected cases

Next Steps