Skip to content

Type Registries

Type registries let persisted workflow definitions refer to executable logic by schemaType.

Why They Exist

Stored workflow definitions are data:

json
{
  "type": "serviceTask",
  "config": {
    "schemaType": "task.email.send",
    "storeAs": "email"
  }
}

At runtime, the engine resolves task.email.send to a TaskExecutor through descriptors.

Descriptor Setup

dart
final descriptor = WorkflowDescriptor(
  title: 'Notification executors',
  tasks: [SendEmailExecutor.typeDescriptor],
  userTasks: [ApprovalUserTaskExecutor.typeDescriptor],
  conditions: [RequiresManagerCondition.typeDescriptor],
);

final context = RegistryTypeResolver(
  descriptors: [
    DefaultWorkflowDescriptor(),
    descriptor,
  ],
);

The resolver creates and initializes a WorkflowRegistry lazily from the descriptor list.

Task TypeDescriptor

dart
class SendEmailExecutor extends TaskExecutor {
  static const type = 'task.email.send';

  static final typeDescriptor = TypeDescriptor<TaskExecutor>(
    schemaType: type,
    fromJson: (_) => SendEmailExecutor(),
    title: 'Send Email',
  );

  @override
  String get schemaType => type;

  @override
  String get name => 'Send Email';

  @override
  Future<TaskResult> execute(ExecutionContext context) async {
    final to = context.getRequired<String>('recipient');
    await emailService.send(to: to);

    return TaskSuccess([
      SetOutputEffect(output: {'sent': true}),
    ]);
  }
}

User Task TypeDescriptor

dart
class ApprovalUserTaskExecutor extends UserTaskExecutor {
  static const type = 'userTask.approval';

  static final typeDescriptor = TypeDescriptor<UserTaskExecutor>(
    schemaType: type,
    fromJson: (_) => ApprovalUserTaskExecutor(),
    title: 'Approval',
  );

  @override
  String get schemaType => type;

  @override
  String get name => 'Approval';

  @override
  Future<WaitForUserTaskResult> execute(ExecutionContext context) async {
    return WaitForUserTaskResult(
      signalName: context.signalName ?? 'approval_decision',
      config: UserTaskConfiguration(
        title: 'Approve request',
        schemaType: schemaType,
        assignment: TaskAssignment.role('approvers'),
        input: context.accumulated,
      ),
    );
  }
}

Condition TypeDescriptor

dart
class RequiresManagerCondition extends ConditionExecutor {
  static const type = 'condition.requires_manager';

  static final typeDescriptor = TypeDescriptor<ConditionExecutor>(
    schemaType: type,
    fromJson: (_) => const RequiresManagerCondition(),
    title: 'Requires Manager',
  );

  const RequiresManagerCondition();

  @override
  String get schemaType => type;

  @override
  Future<bool> execute(ExecutionContext context) async {
    final amount = context.getAny<num>('amount') ?? 0;
    return amount > 5000;
  }

  @override
  Map<String, dynamic> toJson() => {'schemaType': schemaType};
}

What Gets Resolved

Runtime pieceResolution rule
NodeConfigurationResolved by node type, such as serviceTask -> ServiceTaskNodeConfiguration.
TaskExecutorResolved by task config schemaType.
UserTaskExecutorResolved by user-task config schemaType, with default user-task fallback.
ConditionExecutorResolved from condition JSON schemaType.
NodeProcessorResolved by NodeType from the default node processor registry.
Lifecycle hooksResolved by workflow code through descriptors.

Loading With A Resolver

Use engine loading APIs so the resolver scope is applied:

dart
final workflow = engine.loadWorkflowDefinition(definition);

or:

dart
final workflow = engine.loadWorkflowJSON(json);

Avoid direct Workflow.fromJson(json) unless you wrap it in context.run(...).

Simulation Resolver

SimulationDeserializationContext keeps node configs typed but substitutes passthrough task/user-task behavior:

dart
final engine = WorkflowEngine(
  context: SimulationDeserializationContext(),
  storage: InMemoryStorage(),
  executionMode: ExecutionMode.simulation,
);

This is the path used by the editor and tests when workflows should be explored without running production side effects.

Template Descriptors

Templates contribute descriptors just like apps:

dart
final template = ApprovalTemplate(domains: [approvalDomain]);

final context = RegistryTypeResolver(
  descriptors: [
    DefaultWorkflowDescriptor(),
    template.buildDescriptor(),
  ],
);

For the canonical approval workflow, the descriptor registers approval loop executors, outcome dispatchers, approval user-task executors, and revision user-task executors.

Duplicate Schema Types

Descriptors are processed in order. Avoid duplicate schema types unless overriding is deliberate and tested. Put DefaultWorkflowDescriptor() first so app descriptors can override built-ins when needed.

See Also